---
url: https://docs.nemo12.com/ops/seo.md
description: >-
  SEO và ảnh chia sẻ mạng xã hội (SRC-1211): bảng og:image theo chương trình,
  cổng SEO tự động và kết quả lượt rà ngày 03.10.2026.
---

# SEO và ảnh chia sẻ mạng xã hội

SRC-1211 (chủ dự án, 03.10.2026): link một trang chương trình dán lên Facebook hay Zalo phải hiện
đúng ảnh, tiêu đề và mô tả của chương trình đó, ở cả `nemo12.com` lẫn `learn.nemo12.com`; và mọi
trang `nemo12.com` phải qua một lượt rà SEO, kèm một cổng tự động để lỗi không quay lại.

## Ảnh chia sẻ (og:image)

| Phần | Ở đâu | Việc |
| --- | --- | --- |
| Bảng thẻ | `apps/web/src/site/shareCards.ts` | Chương trình nào có thẻ (mọi chương trình trong `programs.ts` không `soon`), đường dẫn nào thuộc chương trình nào (đoạn đầu, sau `/parents`), tên file. |
| Bộ vẽ | `apps/web/scripts/og-images.mjs` | Lúc build: ảnh hero của trang gốc chương trình (`public/art/pages/<gốc>.svg`, xem [ảnh minh họa](illustrations.md)) + tên chương trình + câu hứa (`PROGRAMS[id].line`), vẽ ra PNG 1200x630 bằng `@resvg/resvg-wasm` với font Be Vietnam Pro. Ra `public/og/*.png` (gitignore, sinh lại mỗi build), mỗi ảnh khoảng 80 tới 130 KB, quá 300 KB là build đỏ. |
| Gắn vào trang | `apps/web/scripts/prerender.mjs` (`withShareCard`) | Mọi trang dưới gốc một chương trình (cả hai vai, mọi tab, trang nội dung) mang thẻ của chương trình: `og:image`, `og:image:alt`, `og:image:type`, `twitter:image`. Trang khác giữ thẻ chung `/og-image.png`. |

PNG chứ không SVG: Facebook và Zalo không hiện `og:image` dạng SVG. Tên file là URL công khai ổn
định (learn và các link đã chia sẻ trỏ vào), nên không đổi theo đường dẫn: NEMO WALK luôn là
`walk.png` dù trang www đang ở `/nemo-walk` hay `/walk`.

Thẻ hiện có: `ielts`, `sat`, `ap`, `grammar`, `essay`, `speak`, `walk`, `math`, `ai-teen`, cùng hai
bản tiếng Anh `ielts-en` và `walk-en` cho learn (luật ngôn ngữ trong CLAUDE.md: learn `/ielts`
tiếng Anh, learn `/walk` tiếng Anh rất đơn giản).

### learn.nemo12.com

learn là SPA phục vụ bằng static assets với `not_found_handling: "single-page-application"`, nên mọi
URL trả cùng một `index.html`; bot xem trước link không chạy JavaScript nên thấy cùng một tiêu đề
cho mọi chương trình. `apps/learn/scripts/share-meta.mjs` chạy sau `vite build` và ghi một vỏ HTML
cho mỗi trang vào của chương trình (`dist/ielts.html`, `dist/sat.html`...): đúng `index.html` đã
build, chỉ khác `<html lang>` và các thẻ trong `<head>`. Cloudflare phục vụ `ielts.html` tại
`/ielts` (`html_handling` mặc định); đường sâu hơn (`/ielts/reading/...`) vẫn rơi về `index.html`,
nên SPA không đổi gì. Hệ quả nhỏ: `/ielts/` (có gạch chéo cuối) nay 307 về `/ielts`.

Mười một trang vào: `/ielts`, `/walk` (en, `og:locale` `en_US`), `/sat`, `/ap`, `/grammar`,
`/essay`, `/speak`, `/math`, `/ai-teen`, `/programs`, `/books` (vi, `vi_VN`). Ảnh trỏ sang
`https://nemo12.com/og/*.png`. `public/robots.txt` của learn vẫn chặn máy tìm kiếm, nhưng cho các
bot xem trước link (facebookexternalhit, Facebot, Zalo, Twitterbot, LinkedInBot...) đọc đúng mười
một đường dẫn ấy; test `src/shareMeta.test.ts` giữ hai danh sách khớp nhau.

### Facebook giữ ảnh cũ

Facebook (và Zalo) cache bản xem trước theo URL. Link đã từng được chia sẻ trước bản deploy này vẫn
hiện ảnh cũ cho tới khi được quét lại: mở Facebook Sharing Debugger
(`developers.facebook.com/tools/debug`), dán URL, bấm "Scrape Again". Việc này chủ dự án làm tay,
mỗi URL quan trọng một lần.

## Cổng SEO

`apps/web/scripts/seo-gate.mjs`, chạy cuối `npm run build` (sau prerender), đọc đúng HTML trong
`dist/` mà bot nhận được. Build đỏ khi:

* trang được lập chỉ mục thiếu `<title>`, meta description, canonical, hay một trong các thẻ
  `og:title/description/url/image/image:width/image:height/image:alt/type/locale/site_name`,
  `twitter:card`;
* hai trang tự canonical trùng tiêu đề hoặc trùng mô tả;
* canonical trỏ vào một bí danh 301, vào trang không tồn tại, hoặc vào trang lại canonical tiếp đi
  chỗ khác; `og:url` khác canonical;
* `og:image` không phải URL https tuyệt đối, là SVG, file không có trong `dist/`, hoặc trang chương
  trình không mang thẻ của chương trình mình;
* sitemap có URL chuyển hướng, URL không có trang, URL `noindex`, hoặc URL không phải canonical của
  chính nó;
* link nội bộ `<a href>` dẫn tới 404 (link qua một 301 thì đếm và in ra, không chặn);
* JSON-LD không parse được, breadcrumb trong JSON-LD trỏ vào bí danh 301, hoặc trang có số `h1`
  khác 1;
* `robots.txt` thiếu dòng `Sitemap` hoặc chặn cả site.

Độ dài (tiêu đề trên 65 ký tự, mô tả ngoài khoảng 70 tới 170) chỉ là cảnh báo: vài tên chương
trình dài có chủ đích. `npm run seo:gate -- --report` in toàn bộ.

Trang bài viết dựng từ D1 (`worker/articles`) nằm ngoài `dist/`, nên cùng luật head được giữ bằng
test `worker/articles/handler.test.ts` cho ba loại trang: hub, category, bài.

## Kết quả lượt rà 03.10.2026

Đo trên bản build của `origin/main` trước khi sửa: 639 trang, 577 trang tự canonical, 577 URL
sitemap. Phần lớn nền đã đúng từ các lượt trước (SRC-694, SRC-1150, SRC-1190): canonical, sitemap
chỉ chứa bản chính, một `h1`, JSON-LD.

| Lỗi | Trước | Sau |
| --- | --- | --- |
| Link nội bộ gãy (`/speak/resources` trỏ `/speak/nemo-walk`) | 1 | 0 |
| Link nội bộ đi qua 301 (`/start` trỏ `/courses/ielts-writing`) | 1 | 0 |
| Trang gốc chương trình có tiêu đề của MỤC TIÊU ("IELTS · Luyện IELTS, SAT, Grammar") | 10 | 0 |
| Trang chương trình dùng ảnh chia sẻ chung thay vì ảnh của chương trình | mọi trang | 0 |
| Trang gốc chương trình không khai `Course` trong JSON-LD | 14 | 0 |
| Cảnh báo độ dài tiêu đề/mô tả (chủ yếu mô tả sinh tự động quá ngắn) | 83 | 7 |
| Trang learn có thẻ chia sẻ riêng | 0/11 | 11/11 |

Bảy cảnh báo còn lại là chữ viết tay, để chủ dự án quyết nếu muốn: mô tả `/ap/courses` (204 ký
tự), `/speak/curriculum` (171), `/speak/resources` (182), `/teachers/tefl/compare` (181), dòng
`/parents/ap` (63, dùng chung với thẻ trên trang mục tiêu), và `/login` (`noindex`, không quan
trọng).
