---
url: >-
  https://docs.nemo12.com/architecture/sdd-029-public-site-ia/content-families.md
description: >-
  Bốn lỗi SEO kỹ thuật, cỗ máy họ trang nội dung và cổng chất lượng, rà soát
  khâu nhập, khu khám phá SAT và AP.
---

# SDD-029 · Lỗi SEO kỹ thuật, họ trang nội dung và khu khám phá SAT, AP

Một phần của [SDD-029](./index.md). Bốn lỗi SEO kỹ thuật, cỗ máy họ trang nội dung và cổng chất lượng, rà soát khâu nhập, khu khám phá SAT và AP.

## 5.5 Bốn lỗi SEO kỹ thuật đã sửa (SRC-694, chỉ đạo 2026-09-08)

Chỉ đạo: rà soát UI để làm SEO, điểm SEOQuake và PageSpeed phải rất cao. Đo trước khi sửa cho ra
bốn lỗi mà không cổng nào bắt được, vì cả bốn đều **im lặng** — site vẫn chạy đúng, chỉ là công cụ
tìm kiếm nhìn thấy một thứ khác với thứ người dùng nhìn thấy.

**1. Trang chủ rỗng với bot.** DOM sau khi React chạy có 0 thẻ `h1`, 138 ký tự chữ và ĐÚNG MỘT link
(`/privacy`). Hai card chọn vai là `<button>`, mà `<button>` không phải cạnh của đồ thị web. Trang
mạnh nhất của tên miền vừa không xếp hạng được, vừa không dẫn con bò sang 113 trang còn lại. Sửa:
`<button>` → `<a href>` (mắt không phân biệt được), thêm `h1` chỉ đọc-bằng-trình-đọc-màn-hình, và
một dải link chữ nhỏ ở ĐÁY, dưới hai card. Bản "trần trụi hai card" ở §1.1 giữ nguyên ở nửa trên
màn hình — chủ dự án chốt phương án này 2026-09-08.

**2. Soft 404.** `not_found_handling: "single-page-application"` trả `index.html` kèm **mã 200** cho
mọi đường dẫn. Với Google, một site trả 200 cho mọi chuỗi ký tự có vô hạn trang, và ngân sách thu
thập bị tiêu vào URL rác. Sửa: `apps/web/worker/index.ts` đứng trước kho tĩnh, trả 404 thật cho
đường dẫn không có và 200 + SPA cho đường dẫn động hợp lệ. Danh sách đường dẫn hợp lệ import thẳng
từ `routes.ts`, không gõ tay.

**3. Hai bản của cùng một trang.** `www.nemo12.com` và `nemo12.com` cùng trả 200. Canonical đã trỏ
về bản không-www nhưng canonical là gợi ý, còn 301 là sự thật. Worker gộp bằng 301. Cùng lúc,
mặc định `auto-trailing-slash` của Cloudflare 307 `/students` → `/students/`, trong khi canonical VÀ
sitemap đều ghi bản không gạch chéo: mọi URL trong sitemap là một lượt chuyển hướng, và trang đích
lại khai canonical ngược về bản vừa chuyển đi. Đổi sang `drop-trailing-slash`.

### Lỗi 3 chỉ chạy được từ 2026-09-09 (SRC-695)

Worker viết đúng từ đầu, nhưng **không hề chạy**. Mặc định của Cloudflare là kho tĩnh phục vụ
trước: đường dẫn nào có sẵn file trong `dist` thì trả thẳng file, worker không được gọi. Nên cú 301
`www` → apex chỉ nổ với đường dẫn KHÔNG có file — `www.nemo12.com/khong-co-gi` thì chuyển hướng,
còn `www.nemo12.com/` và mọi trang thật thì không.

Đo trên production 2026-09-09, sau khi §5.5 đã deploy: `www` vẫn trả **200, không `Location`**, kể
cả khi thêm tham số phá cache. Ba lỗi kia đã hết (404 thật, có `ld+json`, sitemap thành index) —
đúng vì cả ba chỉ cần worker chạy khi KHÔNG có file, còn lỗi 3 cần nó chạy khi CÓ file.

Sửa: `"run_worker_first": true` trong `apps/web/wrangler.jsonc`. Giá phải trả là mỗi request tốn
một lượt gọi worker; bước 2 của worker vẫn trả file qua `env.ASSETS.fetch` nên nội dung và
`html_handling` không đổi.

Bài học: một bản vá deploy xanh không có nghĩa là nó đang chạy. Cả ba cổng CI đều xanh suốt thời
gian cú 301 nằm chết, vì không cổng nào gửi một request thật tới `www`.

**4. Không có dữ liệu có cấu trúc.** `apps/web/src/site/jsonld.ts` sinh `@graph` cho mọi trang từ
chính bảng route: Organization + WebSite + BreadcrumbList ở mọi trang, `Course` cho trang lớp,
`Article` cho bài viết, `CollectionPage` cho trang danh mục. Luật: **chỉ khai thứ có thật** — không
`offers`, không `aggregateRating`, không `courseWorkload`, vì bảng route không giữ giá lẫn số buổi
và dữ liệu có cấu trúc sai sự thật là lý do Google gỡ rich result của cả tên miền.

Phần hiệu năng: `initAnalytics()` chạy ở dòng đầu `main.tsx`, trước `createRoot`, nên gần 300 kB thẻ
đo giành đúng khoảng thời gian trình duyệt cần để vẽ khung hình đầu (FCP 5,1s trên 4G, điểm
Performance mobile 66). Dời xuống `requestIdleCallback`, thêm `preconnect`, tách gói theo trang, và
sửa một lỗi tương phản màu. Đo lại trên bản build mới: Performance mobile 97, Accessibility 100,
Best Practices 100, SEO 100, CLS 0.

## 6.1 Họ trang nội dung — cỗ máy sinh trang không phình bundle (SRC-694)

Chỉ đạo cùng ngày: đưa site lên khoảng **1000 trang được lập chỉ mục**. Vấn đề kiến trúc phải giải
trước khi viết chữ nào: `routes.ts` bị ứng dụng React import (để đặt `<title>` lúc điều hướng), nên
mọi byte trong đó đi thẳng vào bundle trình duyệt. Nhân 114 trang hiện tại lên 1000 thì riêng phần
chữ đã vài trăm kB mà 999/1000 người dùng không bao giờ đọc tới.

Cách giải: nội dung của các họ trang lớn sống ở `apps/web/content/seo/`, **ngoài `src/`**, và chỉ
hai script Node lúc build đọc nó. Trình duyệt nhận nội dung theo hai đường, không đường nào kéo
theo bundle:

* **Lượt tải đầu**: chữ nằm sẵn trong HTML tĩnh, cộng một khối JSON nhúng để React dựng lại y hệt
  mà không gọi mạng thêm lần nào.
* **Điều hướng trong SPA**: tải đúng một file `/data/<đường-dẫn>.json` của trang đang mở.

Đo được: thêm 139 trang nội dung làm gói chính tăng **0,6 kB** (482,3 → 482,9 kB).

Ứng dụng React chỉ biết một danh sách **tiền tố** (`src/site/contentPrefixes.ts`, vài chuỗi), rồi
giao cho `ContentPage.tsx` tự lấy dữ liệu. Slug không tồn tại dưới một tiền tố không lọt qua im
lặng: worker trả 404 thật vì không có file tĩnh, và điều hướng trong SPA thì `ContentPage` không
tải được JSON nên hiện 404.

`sitemap.xml` trở thành một **index** trỏ tới các file con, một file cho mỗi họ. Lý do không phải
dung lượng (một file chứa được 50.000 URL) mà là Search Console báo số trang đã lập chỉ mục theo
từng file: gộp tất cả thì khi 200 trang không được lập chỉ mục, thứ đọc được chỉ là con số 200.

### 6.2 Cổng chất lượng — vì sao cần máy kiểm chứ không cần cẩn thận

Sinh trang hàng loạt hỏng theo một kiểu rất riêng: nó không bao giờ hỏng ở trang đang được nhìn.
Trang vừa viết luôn ổn; cái hỏng là trang thứ 240, viết lúc mệt, dùng lại nguyên khuôn của trang 239
và đổi vài từ khoá. Không ai mở trang 240 ra đọc — cho tới lúc Google xếp cả cụm vào diện *scaled
content abuse* và gỡ chỉ mục của toàn bộ họ trang, kể cả 239 trang tử tế phía trước.

`scripts/check-seo-content.mjs` (trong `npm run check:code`) chặn đúng chuyện đó bằng máy:

| Kiểm | Vì sao |
| --- | --- |
| Nội dung KHÔNG được import vào `src/` | Lọt vào bundle thì triệu chứng duy nhất là site chậm dần, không lỗi, không ai truy ra |
| Tối thiểu 450 âm tiết mỗi trang chi tiết | Tiếng Việt viết rời từng âm tiết; 450 ≈ 300 từ tiếng Anh |
| Title, description, thân bài không trùng nhau | Trùng thân bài chính là định nghĩa của nội dung sinh hàng loạt |
| Mọi link nội bộ phải trỏ tới trang có thật | Link gãy không báo lỗi cho ai |
| Mọi trang chi tiết phải được ít nhất một trang khác trỏ tới | Trang chỉ vào được qua sitemap là một hòn đảo |
| `contentPrefixes.ts` khớp các họ đã dựng | Quên một bên thì bản tĩnh đúng còn bấm trong SPA ra 404 — lỗi chỉ hiện khi BẤM, nên lọt qua mọi lần kiểm bằng mắt |

### 6.3 Họ trang đầu tiên: IELTS

Chọn IELTS trước vì nó là chỗ trùng khít giữa ba thứ hiếm khi trùng nhau: thứ người Việt tìm nhiều
nhất trong giáo dục, thứ Nemo12 đang thật sự dạy, và thứ Nemo12 nói được **mà không cần trích số
liệu của bên thứ ba**.

Điểm cuối quan trọng hơn vẻ ngoài. Họ trang trường chuyên (~250 trang, cùng đợt chỉ đạo) bị chặn bởi
chính luật của dự án: chỉ đạo 2026-08-17 trong `scripts/seed-schools-alumni.sql` yêu cầu mọi thông
tin phải được kiểm chứng trước khi vào hệ thống, và API công khai hiện trả về **0 trường** vì tất cả
đang ở `draft`. Điểm chuẩn và tỉ lệ chọi không được bịa. Chủ dự án chốt 2026-09-08: dựng bộ máy,
để số trang tăng theo tốc độ duyệt dữ liệu trong Dolphin chứ không theo tốc độ gõ.

## Lịch sử quyết định

### 7.5 Cảm nhận: máy đã đủ, đường đưa vào thì chưa (SRC-712, rà soát 2026-09-13)

#### 7.5.1 Rà soát cho ra gì

Kiểm cả năm tầng — lược đồ (`0075` · `0175` · `0199` · `0200`), API quản lý, API công khai, màn
dolphin, khối hiển thị trên web — thì **không tầng nào hỏng**:

* Cổng `testimonialPublishBlockers` **có được gọi thật** ở đường publish (`routes.ts`), không phải
  logic chết chỉ sống trong test. Nội dung mẫu được miễn cổng consent, và miễn có lý do ghi rõ:
  không có người thật nào để hỏi.
* Trang công khai gắn nhãn trung thực: một băng cảnh báo cho cả khối, cộng huy hiệu *"Ví dụ minh
  hoạ"* trên **từng thẻ**. Đã kiểm trên bản đang chạy: `nemo12.com/cho-bo-me` hiện 7 thẻ và đủ 7
  huy hiệu.
* Đường rơi khi API lỗi cũng an toàn: cả 6 mục trong mảng cứng `media.ts` đều mang `status:
  "sample"`, nên không có kịch bản nào trang hiện lời hư cấu mà **không** có nhãn.

Nhưng con số thì nói một chuyện khác: **12 lời đang hiển thị công khai, cả 12 là nội dung mẫu, 0
lời thật.** Cỗ máy chạy tốt và chưa từng được dùng đúng việc của nó.

#### 7.5.2 Nút thắt nằm ở khâu nhập, và nó có hình dạng cụ thể

Cách duy nhất để đưa một lời vào hệ là mở màn Testimonials rồi **gõ lại**. Trong khi đó lời của
phụ huynh đã nằm sẵn ở `family_notes`: mentor gặp xong về viết lại, và từ SRC-691 mỗi ghi chú như
vậy còn mang theo ngày gặp thật lẫn kênh tiếp xúc.

Bắt gõ lại một thứ đã viết là chỗ mọi quy trình đứt. Không phải vì ai lười, mà vì với người ghi
chép, đó là **việc thứ hai cho cùng một nội dung** — và việc thứ hai luôn là việc bị bỏ.

#### 7.5.3 Một thao tác, và thứ nó mang theo

`POST /v1/showcase/testimonials/from-note` rút một ghi chú thành bản nháp. Nút nằm ngay trên ghi
chú trong Family Workspace, cùng hàng với ⓘ ↩ ✎.

Thứ đáng giá không phải là đỡ gõ, mà là **lai lịch**. Bản nháp sinh ra biết nó trích từ ghi chú
nào, ngày nào, ai ghi lại — một bản ghi có tác giả, có ngày, được viết **trước khi** có ý định đem
đi đăng. Đó đúng là loại bằng chứng mà cột `verified` đòi hỏi, và trước nay người biên tập phải tự
đi tìm bên ngoài hệ thống.

Bốn quyết định trong `fromNote.ts`, mỗi cái chặn một cách sai:

1. **Từ chối ghi chú chưa gắn với ai.** Đoán "chắc là bố mẹ" sẽ cho ra một lời ký tên sai người —
   sai kiểu tệ nhất ở đây, vì nó chỉ lộ ra khi chính người đó đọc trang.
2. **`said_on` hạ từ ngày chính xác xuống tháng.** Ta có ngày, nhưng in đúng ngày một cuộc trò
   chuyện riêng lên trang công khai là chi tiết không ai cần mà lại chỉ thẳng vào một gia đình.
3. **Cắt lời quá dài thì phải nói ra.** Cắt im lặng thì một câu mất nửa sau vẫn trông như câu
   hoàn chỉnh.
4. **`internal_note` viết thẳng "chưa hỏi ý kiến người nói".** Bản nháp tiện không được đọc nhầm
   thành bản đã xin phép.

Bản nháp ra đời với `consent=0`, `verified=0`, `draft`, `private`. **Tiện ở khâu nhập, không nới
khâu duyệt**: người nói vẫn chưa hề được hỏi.

Chỉ mục UNIQUE một phần trên `source_note_id` (`0216`) khiến một ghi chú sinh ra nhiều nhất một
cảm nhận: hai mentor cùng chăm một nhà, cùng đọc một ghi chú hay, cùng bấm — không có ràng buộc
thì trang công khai hiện hai lần một câu.

#### 7.5.4 Đo được thì mới sửa được

Màn Testimonials thêm một dòng đếm: *đang hiển thị công khai bao nhiêu · trong đó bao nhiêu là lời
thật · bao nhiêu lời thật còn ở bản nháp*. Số "lời thật đang hiển thị" tô đỏ khi bằng 0.

Trước đó tình trạng "toàn bộ là hàng mẫu" **không nhìn ra được** từ màn quản lý — một danh sách
dài toàn thẻ giống nhau thì không ai ngồi đếm. Một con số là thứ duy nhất thật sự đo được tiến độ
của cả tính năng này.

#### 7.5.5 Việc còn lại thuộc về con người

Gỡ 12 lời mẫu là **quyết định của chủ dự án, không phải của hệ thống** — chúng được đặt vào có chủ
đích (SRC-676) để duyệt bố cục, và gỡ sớm thì trang trống. Điều nên xảy ra là thay dần: mỗi lời
thật vào thì một lời mẫu ra.

### 7.6 Khu khám phá tương tác cho SAT và AP (SRC-713 · 714 · 715 · 716, chỉ đạo 2026-09-14)

> **Khu AP đã GỠ KHỎI SITE ngày 2026-09-15 (SRC-740).** Chủ dự án chốt: Nemo12 chưa cung cấp dịch
> vụ đào tạo AP, nên không trang nào được nói như thể đã có. Gỡ nghĩa là **thôi khai route và thôi
> khai họ trang** — bảy route `/ap/learner*` rời `routes.ts`, họ `/ap` rời `FAMILIES`, `/ap` rời
> `CONTENT_PREFIXES`, nhánh AP rời `App.tsx`, mục AP rời nav và rời hero Students. Prerender vì thế
> không sinh file tĩnh nào cho `/ap`, và `worker/index.ts` trả 404 thật. Hai bí danh `/ap/journey`
> và `/ap/subjects` gỡ theo: bí danh chỉ có nghĩa khi đích còn tồn tại, còn trỏ chúng sang một
> trang khác chủ đề thì tệ hơn 404 vì người bấm vào tưởng Nemo12 có lớp AP.
>
> **Nội dung KHÔNG bị xoá**: `src/site/ApLearner.tsx` và `content/seo/ap/` vẫn nằm trong git. Bật
> lại khu AP là khai lại route và import lại họ trang, không phải viết lại. Phần còn lại của mục
> 7.6 dưới đây giữ nguyên vì nó là lý do THIẾT KẾ của khu ấy — thứ cần đọc lại đúng vào ngày bật.

Ba họ trang kỳ thi ra đời cùng ngày và theo ba nhịp khác nhau, nên ghi lại ở đây vì sao chúng
không giống nhau.

**SRC-713 — phần chữ.** `/ielts/journey` và `/ielts/journey/exam-day`, cộng hai họ mới `/sat` và
`/ap`. Trang thủ tục thi cố ý KHÔNG ghi lệ phí và ngày thi cụ thể: những con số ấy đổi theo năm và
theo đơn vị tổ chức, và một bản chép lại sẽ sai âm thầm nhiều tháng mà không ai phát hiện. Cùng
ràng buộc đã áp cho `content/seo/ielts/questions.ts`.

**SRC-714 — SAT Learner.** Tách `/sat` (evergreen, factual) khỏi `/sat/learner` (trải nghiệm khám
phá). Sáu trang theo Jobs To Be Done: Decide → Target → Understand → Diagnose → Plan, cộng một
trang về cách học. Nguyên tắc dẫn đường: **mỗi trang trả lời MỘT câu hỏi và đặt ra câu hỏi kế
tiếp**, nên mỗi trang kết thúc bằng đúng một lối đi tiếp chứ không phải một rừng link.

Hai ràng buộc về sự thật, ghi ở đầu `apps/web/src/site/SatLearner.tsx`: không ghim bảng điểm trường nào
trong code (learner nhập khoảng điểm công bố của chính trường mình nhắm), và mọi câu hỏi là câu mô
phỏng do Nemo12 soạn, nói rõ ngay cạnh câu hỏi — đề của College Board có bản quyền.

**SRC-715 — AP Learner.** Cùng khuôn nhưng **đảo trục**: câu hỏi đầu tiên là *chọn môn nào*, không
phải *có nên thi không*, vì AP là hơn bốn mươi môn độc lập chứ không phải một bài thi. Hệ quả thứ
hai: AP không có một mức năng lực chung, nên trang check-up bắt chọn MÔN trước và trả về hồ sơ sẵn
sàng theo từng mảng kiến thức nền của đúng môn đó.

**SRC-716 — gộp.** Ba trang chữ (`/sat/journey`, `/ap/journey`, `/ap/subjects`) trả lời đúng những
câu hỏi mà ba trang tương tác đã trả lời. Giữ cả hai bản thì người đọc phải đoán nên vào trang nào
và Google phải đoán trang nào là bản chính, nên chúng gộp vào trang tương tác tương ứng. Phần chữ
đáng giữ chuyển theo, vừa hiện trên màn hình vừa nằm trong `paras` của route để bot đọc được. Ba
đường dẫn cũ giữ làm **bí danh** (§4), không xoá.

**Điểm kỹ thuật đáng nhớ:** route của trang tương tác vẫn phải khai trong `apps/web/src/site/routes.ts` dù
thân bài là React thật — `apps/web/scripts/prerender.mjs` đọc bảng đó để sinh file tĩnh, và không có file
tĩnh thì `apps/web/worker/index.ts` trả 404 cho người mở thẳng URL. Nhánh `/sat/learner` và `/ap/learner`
trong `apps/web/src/App.tsx` phải đứng TRƯỚC nhánh nội dung vì `/sat` và `/ap` là tiền tố nội dung.
