---
url: https://docs.nemo12.com/architecture/sdd-029-public-site-ia/seo-operations.md
description: >-
  Bản đồ từ khoá phụ huynh, các đợt sửa theo Search Console, giảm bounce rate,
  checklist HTTPS, header, llms.txt.
---

# SDD-029 · Từ khoá, Search Console, bounce rate và checklist chất lượng site

Một phần của [SDD-029](./index.md). Bản đồ từ khoá phụ huynh, các đợt sửa theo Search Console, giảm bounce rate, checklist HTTPS, header, llms.txt.

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

### Bản đồ từ khoá phụ huynh và họ trang Cambridge, Oxford (SRC-1296, 08.10.2026)

Chủ dự án 08.10.2026: "Làm SEO cho xong xuôi." Đã làm:

| Phần | Chốt |
| --- | --- |
| Nguồn | `apps/web/content/keywords.yaml`: 200 nhóm ý định tìm kiếm x 5 cụm = 1000 cụm phụ huynh gõ (IELTS 50 nhóm, SAT 35, luyện nói 30, ngữ pháp 35, Cambridge 30, Oxford 20). Mỗi nhóm khai `topic`, `intent`, `stage`, `priority` (ƯỚC LƯỢNG, không phải lượt tìm kiếm: repo không có nguồn số đó) và đúng một `target` |
| Chống ăn thịt nhau | Không hai nhóm chung URL, không một cụm ở hai nhóm (so sau khi bỏ dấu); cổng chặn |
| Bảng cho người đọc | [ops/seo-keywords.md](../../ops/seo-keywords.md), sinh bằng `node apps/web/scripts/keyword-coverage.mjs --docs` |
| Cổng | `apps/web/scripts/keyword-coverage.mjs --gate` chạy cuối `npm run build` của web: URL có trong `dist/` và sitemap (hoặc là bài đã viết trong `plan.yaml`), mọi từ của cụm chính trong title hoặc H1, mọi từ của bốn cụm còn lại trong chữ trang, mô tả hoặc FAQ. Lúc gộp: 1000/1000 cụm khớp, 57% xuất hiện nguyên văn (lượt hai: 100%) |
| Trang mới | Họ trang nội dung `/cambridge` (30 trang: Starters, Movers, Flyers, KET, PET, FCE, CAE/CPE, thang điểm, CEFR, đăng ký, kết quả, xét tuyển lớp 6, chương trình Cambridge, IGCSE, A Level, Checkpoint, sách) và `/oxford` (20 trang: Oxford Test of English, Oxford Placement Test, sách Oxford, từ điển, Oxford hay Cambridge). Thế giới `parents`, tĩnh như mọi họ trang (§6.1), không thư viện mới |
| Sự thật | Nemo12 không tổ chức hai kỳ thi ấy và không bán sách. Không trang nào ghi lệ phí, lịch, tên trung tâm hay cách một trường Việt Nam cụ thể dùng chứng chỉ; không in band IELTS quy đổi. Nguồn và phần chưa đối chiếu được ghi ở chú thích đầu mỗi file trong `content/seo/cambridge/` và `content/seo/oxford/`, đã đối chiếu ở lượt hai (dòng dưới) |
| Trang cũ | Tiêu đề mang từ khoá chính: trang app qua `KEYWORD_TITLES` trong `routes.ts` (áp SAU `withCanonicals`), trang họ nội dung sửa `title`, bài D1 sửa frontmatter. Câu phụ huynh gõ mà trang chưa trả lời: khối "Phụ huynh hay hỏi" (`SearchFaq`, dữ liệu `searchFaqData.ts`) cho trang app, `content/seo/keywordFaq.ts` cho họ trang nội dung, mục `## Câu hỏi thường gặp` cho bài D1 (34 bài, phải nạp lại 4 file seed sau deploy) |
| Link nội bộ | `ArticleHubLinks` (chân mọi trang và cuối trang chủ) thêm Cambridge, Oxford; hub mỗi họ link mọi trang con; `/site-map` tự gồm mọi trang nội dung |
| Skill | `/seo-keywords` mới; `/seo-page` và `/article-plan` tra bản đồ trước khi chọn từ khoá |
| Đối chiếu sự thật (lượt hai, 08.10.2026) | Mọi khẳng định trong `content/seo/cambridge/*.ts` và `content/seo/oxford/*.ts` đối chiếu với trang chính thức; chú thích đầu file nay ghi URL nguồn và ngày kiểm thay cho ghi chú "viết theo trí nhớ". Sửa: KET Writing (email hoặc ghi chú 25 từ và truyện theo ba tranh 35 từ, cambridgeenglish.org/exams-and-tests/key/exam-format); KET Listening 30 phút đã gồm 6 phút chép đáp án; PET Speaking 10 tới 12 phút (trước ghi 12 tới 14, .../preliminary/exam-format); Oxford Placement Test bỏ "khoảng một giờ", nay 40 tới 45 câu, giờ do trung tâm đặt; Young Learners Placement Test bỏ thời lượng (không trang OUP nào công bố); Oxford Reading Tree bỏ "dùng rộng rãi". Khớp nguồn, giữ nguyên: thời lượng FCE, CAE, CPE, mọi khoảng điểm Cambridge English Scale và ranh grade A/B/C, chứng chỉ không hết hạn, khiên YLE, IGCSE/A Level/Checkpoint, các mô-đun, thang 0 tới 140, thời hạn kết quả của Oxford Test of English. Không ghi mức CEFR cho Oxford Discover vì OUP không công bố một khoảng chung |
| Cụm nguyên văn | Cổng `keyword-coverage --gate` nay đỏ khi dưới 90% cụm xuất hiện nguyên văn (`EXACT_MIN`), in tỉ lệ theo chủ đề, `--exact` liệt kê cụm còn thiếu. Từ 571/1000 lên 1000/1000: câu hỏi thêm trong `searchFaqData.ts`, `keywordFaq.ts` và mục FAQ của 82 bài D1 (sinh lại 4 file `scripts/seed-articles-*.sql`, nạp qua `/seed-data` sau deploy). Đổi một cụm: "ngữ pháp cho ielts sat" thành "ngữ pháp cho ielts và sat" |
| Tranh hero trong bản tĩnh | Đo 08.10.2026 (Lighthouse mobile): `/ielts/band` 72, `/cambridge/starters` 73, LCP 9,5 s, 86% là chờ tải. Khung tĩnh của họ trang nội dung (`staticShell` trong `prerender.mjs`) không có ô hero mà `ContentPage` vẽ (`PagePhoto`), nên tranh chỉ bắt đầu tải sau JS và lượt gọi API ảnh. Nay `prerender.mjs` vẽ ô ấy bằng CHÍNH component (`renderPagePhoto` trong `src/ssrApp.tsx`), cùng khối `n12-boot` "kho ảnh rỗng" như trang app (SRC-1295), nên tranh nằm sẵn trong HTML với `fetchpriority="high"` và React không đổi gì khi thay vào. Cổng: `seo-gate.mjs` chặn trang nội dung thiếu ô hero, hoặc có ô mà thiếu `<img>` khi bản dựng biết kho ảnh rỗng. Đo local (vite preview, không CDN): `/ielts/band` 84, `/cambridge/starters` 85, ngang `/grammar` 86 trên cùng máy; CLS 0 |

### Search Console 08.10.2026: dữ liệu có cấu trúc, ban biên tập, trang mỏng, IndexNow, độ sâu click (SRC-1295)

Đợt hai sau SRC-1292, cùng số liệu Search Console 08.10.2026 (782 URL "Discovered - currently not
indexed"). SRC-1292 lo đường link; đợt này lo bốn thứ còn lại Google dùng để xếp hàng crawl: trang
nói nó LÀ gì (JSON-LD), AI viết (E-E-A-T), trang nào không đáng index, và báo URL mới cho máy tìm kiếm khác.

* **JSON-LD.** Organization ở mọi trang mang logo PNG (`avatar-1024.png`; Google bỏ qua logo SVG,
  thứ trang bài viết từng khai), cùng `@id` `https://nemo12.com/#organization` ở trang dựng sẵn lẫn
  trang bài viết của worker, nên Google đọc ra MỘT thực thể. Trang chủ thêm `publishingPrinciples`
  trỏ `/about/editorial`. Trang ngoài bảng route (`/mentors`, `/community`, `/privacy`, năm trang
  linh vật) trước đây không có JSON-LD nào, nay có Organization, WebSite, BreadcrumbList, WebPage
  (`pageJsonLdString` trong `apps/web/src/site/jsonld.ts`). Article (trang route `kind: "article"`,
  trang nội dung) và BlogPosting (bài trong D1) mang `image`, `author` là Ban biên tập, và mọi tham
  chiếu `@id` có node tương ứng ngay trong trang. FAQPage giữ nguyên luật cũ: chỉ khai khi trang có
  phần hỏi đáp hiển thị. **Không khai Course `offers`:** giá đọc từ `GET /v1/public/membership-pricing`
  và đổi theo ngày; nhúng lúc build là có thể khai một giá đã hết hạn (cùng lý do SRC-1170 không nạp
  bảng giá vào HTML).
* **Ban biên tập.** `/about/editorial` (HTML tĩnh dựng bằng khuôn trang bài viết, không React, xem
  `editorialPage` trong `apps/web/worker/articles/render.ts`): ai chịu trách nhiệm nội dung, bài được
  làm thế nào (bản thiết kế, danh sách sự kiện có nguồn, soạn với hỗ trợ của AI, kiểm tra tự động,
  trích dẫn đối chiếu Crossref), điều không hứa, ý nghĩa ngày cập nhật, chỗ báo sai. Không bịa người
  hay chức danh: tác giả là thực thể `Organization` "Ban biên tập Nemo12"
  (`https://nemo12.com/about/editorial#team`). Bài viết có dòng tác giả link tới trang ấy, kèm ngày
  đăng (khi khác ngày cập nhật) và ngày cập nhật, dạng DD.MM.YYYY. `/about` link tới trang này.
* **Trang mỏng.** Luật: trang được index (trừ `/`) phải có ít nhất `MIN_STATIC_WORDS` = 60 chữ trong
  HTML tĩnh (thân trang trừ script, style, svg, nav, header, footer: thứ bot đọc khi chưa chạy
  JavaScript). Dưới mức ấy thì `noindex,follow`, bỏ canonical, rời sitemap. Danh sách đo ngày
  08.10.2026 nằm ở `THIN_PATHS` / `THIN_PREFIXES` trong `apps/web/src/site/routes.ts`: `/soon/*`,
  sách của AI Teen, AP, Essay, Math (26 chữ), sáu hub theo cấp học, `/testimonials`; và
  `/specialist-schools` (`LEGACY_META`). `apps/web/scripts/seo-gate.mjs` đỏ build khi một trang index được
  rơi dưới ngưỡng. Bài D1 dưới `MIN_ARTICLE_WORDS` = 300 chữ cũng `noindex` và rời sitemap của cụm.
  Trang trùng nội dung đã có luật canonical riêng (SRC-1150, `withCanonicals`).
* **Kiểm hình dạng JSON-LD trong seo-gate**: mọi trang index (trừ `/`) có BreadcrumbList; Organization
  có logo raster tuyệt đối; Article/BlogPosting có headline, image, author, publisher và tham chiếu
  giải được trong trang; FAQPage có câu hỏi và câu trả lời; Course có name, description, provider.
  Phần bài viết D1 kiểm bằng `apps/web/worker/articles/render.test.ts`.
* **Độ sâu click.** `/site-map` (HTML tĩnh, `directoryPage` trong `render.ts`): mọi trang index xếp
  theo khu, cộng hub và category của bốn cụm bài viết; link từ mọi chân trang và từ cuối trang chủ
  (`ArticleHubLinks`). `apps/web/scripts/click-depth.mjs` (chạy cuối `npm run build`) dựng đồ thị
  link tĩnh của `dist/` cộng các trang bài viết dựng bằng CHÍNH hàm render của worker từ
  `content/articles/**`, rồi đỏ build khi một trang index nằm sâu hơn 3 click từ `/`. Trước khi sửa:
  158 trang sâu hơn 3 click (sâu nhất 6) và 116 trang không có đường link nào từ `/` (gần hết `/math`,
  vì bộ lọc khoá học chỉ chạy bằng JavaScript). Sau: 1.006 trang, sâu nhất 3. Hub bài viết không
  phân trang: category đã liệt kê đủ bài bằng link thường.
* **lastmod.** Không đổi cách tính (ngày commit cuối của file nguồn, SRC-1292; bài D1 dùng
  `updated_at`). Hai trang tĩnh mới vào `sitemap-pages.xml`. Không làm lịch đăng bài dần: 400 bài đã
  công khai và một phần đã được index, rút xuống là mất chỗ đã có.
* **IndexNow (Bing, Yandex).** Khoá công khai theo thiết kế của giao thức: file
  `apps/web/public/<khoá>.txt`. `apps/web/scripts/indexnow.mjs` đọc sitemap ĐANG CHẠY (cả sitemap bài
  viết của worker), lấy URL có `lastmod` trong 2 ngày qua và gửi tới `https://api.indexnow.org/indexnow`;
  bước "IndexNow ping (web)" chạy sau deploy web trong `.github/workflows/deploy-app.yml`. Khoá chưa
  lên production hay API lỗi thì chỉ là `::warning::`, không bao giờ làm đỏ deploy. Bài nạp qua
  `seed-data.yml` (không qua deploy web) được báo ở lượt deploy web kế tiếp trong vòng 2 ngày, hoặc
  chạy tay `npm run indexnow -w @nemo12/web`.
* **Việc tay:** xác minh site trong Bing Webmaster Tools (cách nhanh nhất: nhập từ Google Search
  Console, không cần file); nếu chọn xác minh bằng file thì đặt `BingSiteAuth.xml` do Bing cấp vào
  `apps/web/public/` như file Google đang có. Gửi lại `sitemap.xml` trong Search Console sau deploy.

### Truy vấn gần trang 1: tiêu đề trả lời đúng câu hỏi (SRC-1295, 08.10.2026)

Search Console 90 ngày tới 05.10.2026: 37 click, 152 lượt hiển thị, vị trí trung bình 17.4; ngoài
truy vấn thương hiệu, mọi truy vấn đều 0 click. Truy vấn đứng vị trí 3 tới 10 mà 0 click là lỗi
tiêu đề và đoạn trích, không phải lỗi xếp hạng, nên chúng được sửa trước.

| Truy vấn | Trang | Sửa |
| --- | --- | --- |
| cần mang gì khi đi thi ielts (7.4) | `/ielts/articles/test-day/ielts-exam-day-checklist` | tiêu đề, mô tả, câu trả lời ở hai câu đầu, FAQ |
| sổ ghi lỗi sai (9.8) | `/grammar/articles/writing-accuracy/know-your-error-patterns` | như trên |
| bảng học phí ielts (3.0) | `/ielts/tuition` (`routes.ts`) | tiêu đề, mô tả; vẫn không ghi con số tiền (SRC-1047) |
| xét tuyển đại học bằng sat (6.0) | `/sat/articles/target-score/sat-score-vietnam-university-admission` | tiêu đề, mô tả, câu trả lời đầu bài, FAQ |
| ielts academic vs general (67.7) | `/ielts/articles/target-band/ielts-academic-vs-general-training` | như trên |
| ngữ pháp tiếng anh gồm những gì (17) | `/grammar/articles/foundations/grammar-foundations-checklist` | tiêu đề, mô tả, câu trả lời đầu bài |

* Luật: `<title>` của bài là `title · <brand>` nên phần `title` giữ tối đa khoảng 47 ký tự để cả
  chuỗi không quá 60; truy vấn đứng đầu tiêu đề; mô tả tối đa 155 ký tự.
* Mỗi trang được 2 tới 3 bài cùng cụm link tới, chữ neo chính là truy vấn.
* Bài sống trong D1: sau khi gộp phải chạy `seed-data` với `scripts/seed-articles-{ielts,sat,grammar}.sql`.
* Bản nháp backlink và việc chủ dự án làm tay (Google Business Profile, Bing Webmaster Tools):
  [backlink-drafts-2026-10](../../marketing/backlink-drafts-2026-10.md).

### Search Console 08.10.2026: liên kết nội bộ tới cụm bài viết (SRC-1292)

Số liệu Search Console (property `https://nemo12.com/`): 322 trang đã index, 870 chưa; 782 trong số
đó là "Discovered - currently not indexed" (Google biết URL nhưng chưa crawl), phần lớn là bốn cụm
bài viết (`/{ielts,sat,speak,grammar}/articles`) và cụm `/math`. Crawl ~30 lượt/ngày cho ~1.200 URL;
11 liên kết ngoài từ 4 tên miền; 28 ngày qua 30 click, 115 lượt hiển thị. Ít uy tín thì ít lượt
crawl, và trang không được trang mạnh nào link tới thì xếp cuối hàng.

* Bốn hub bài viết được link từ MỌI chân trang và từ cuối trang chủ (`ArticleHubLinks` trong
  `apps/web/src/site/SiteNav.tsx`). Trước đây trang chủ, `/students`, `/parents` không link tới cụm nào.
* `apps/web/worker/index.ts`: đường dẫn có gạch chéo cuối trả 301 về bản không gạch chéo (Cloudflare
  tự trả 307; Search Console ghi 5% lượt crawl là "Moved temporarily").
* RSS (`/{service}/articles/feed.xml`) mang `X-Robots-Tag: noindex`: bản sao toàn văn của bài, bị
  xếp vào "Crawled - currently not indexed".
* Kệ "Bài viết" theo ngữ cảnh trên mọi trang của một dịch vụ (`/ielts…`, `/sat…`, `/speak…`,
  `/grammar…`, kể cả bản `/parents/...`): năm category và một bài tiêu biểu của mỗi category
  (`apps/web/src/site/ArticleShelf.tsx`; dữ liệu `apps/web/scripts/articles/shelf.mjs` sinh từ bản
  soạn). Khung tĩnh trang nội dung dựng cùng markup (`staticShelf` trong prerender). Trang dịch vụ và
  trang nội dung IELTS/SAT nay có 11 link tới bài viết ngay trong HTML dựng sẵn.
* Sitemap theo khu: khu nào từ 15 trang trở lên có `sitemap-pages-<khu>.xml` riêng
  (`apps/web/scripts/gen-sitemap.mjs`), để Search Console báo tỉ lệ index theo từng khu (riêng `/math`
  99 trang).

### Giảm bounce rate (SRC-1296, 08.10.2026)

Mỗi trang phải cho người đọc một bước thứ hai ngay trong HTML tĩnh (không popup, không xê dịch bố cục):

* **Bài viết D1** (`worker/articles/render.ts`): mục lục "Trong bài này" khi bài có từ 3 mục h2 (h2 tự mang `id`), số phút đọc, link chủ đề ngay dòng tác giả, một khối "Đọc thêm" ở giữa bài, khối "Bài tiếp theo" (hết chủ đề thì quay về trang chủ đề, không ngõ cụt), ba bài cùng chủ đề, rồi CTA của dịch vụ. Link ra ngoài nemo12.com mở tab mới với `noopener`.
* **Trang dịch vụ** (`ArticleShelf` và `staticShelf` của prerender, cùng markup): khối "Bước tiếp theo" cuối trang lấy đúng CTA trong `plan.yaml` của dịch vụ; trang nào chính là đích của CTA thì không hiện.
* **404**: các lối đi lấy từ một danh sách `src/site/notFoundLinks.ts` (khoá học, cụm bài viết, `/site-map`), dùng chung cho `NotFound.tsx` và `404.html`.

**Đo lường.** `public/engage.js` nạp trên mọi trang công khai (React, khung tĩnh, spa-shell, 404, bài viết, `/about/editorial`): tạo `dataLayer`, nạp Google Tag Manager `GTM-K8HHG87P` (container mang sẵn GA4, không cài GA riêng) SAU `load` cộng lúc rảnh, hoặc ở lần tương tác đầu; bỏ qua khi người đọc chọn "Chỉ cần thiết". ID chỉ nằm ở file này. Sự kiện đẩy vào `dataLayer`: `page_view` (đổi route SPA), `engaged_session`, `scroll_50`, `cta_click`, `related_click` (link mang `data-n12="cta"` hoặc `"related"`). Song song là tín hiệu first-party không cookie: tối đa hai `sendBeacon` mỗi lượt tải tới `POST /e` (`worker/engage.ts`), `l` khi vào trang và `e` khi đọc thật (10 giây hiển thị, cuộn 50%, hoặc bấm link/nút). Worker ghi một dòng log `{"event":"engage","kind","path","reason"}`, không IP, không user agent.

**Cách đọc.** Cloudflare, Workers, `nemo12-web`, Observability: lọc `event = "engage"`, nhóm theo `kind` và `path`. Bounce rate của một trang = 1 - số `e` / số `l`; `reason` của dòng `l` là tên miền giới thiệu. Trong GA4, "Engagement rate" và sự kiện `engaged_session` cho cùng thước đo theo định nghĩa của Google.

**Tốc độ.** Lighthouse mobile trên bản build chạy bằng `wrangler dev` (08.10.2026): `/` 98, `/grammar` 95, `/ielts/band` 100, một bài IELTS 100; CLS 0 cả bốn trang.

### Checklist chất lượng site: HTTPS, header bảo mật, icon, llms.txt (SRC-1296, 08.10.2026)

Đo bằng curl trên https://nemo12.com theo website growth checklist; các mục còn đỏ và cách sửa:

| Mục | Sửa |
| --- | --- |
| TECH-07: `http://` trả 200 | `apps/web/worker/security.ts` `httpsRedirect()`: `url.protocol` hoặc `x-forwarded-proto` là `http` thì 301 sang `https://` (gộp luôn `www`), trước mọi bước khác |
| QUALITY-11..17: thiếu header bảo mật | `withSecurityHeaders()` bọc MỌI response của worker (worker chạy trước kho tĩnh nên phủ cả file tĩnh, bài viết D1, 404, redirect): HSTS 1 năm + includeSubDomains, `nosniff`, `strict-origin-when-cross-origin`, `Permissions-Policy` tắt camera, micro, vị trí (nemo12.com không dùng micro; luyện nói ở learn), `X-Frame-Options: SAMEORIGIN` |
| CSP | Ship dạng `Content-Security-Policy-Report-Only` trước: GTM có thể nạp thẻ từ tên miền ngoài danh sách, chặn nhầm là mất đo lường im lặng. Danh sách nguồn ghi ở đầu `worker/security.ts`; script inline (boot loader, theme, JSON-LD) cần `'unsafe-inline'`. Đổi `CSP_ENFORCE = true` khi một tuần không có vi phạm |
| TECH-20 | `public/llms.txt`: mô tả Việt + Anh, các mục chính, bốn hub bài viết, sitemap |
| SQ-10, QUALITY-20 | `apps/web/scripts/icons.mjs` (`npm run icons`) sinh từ `favicon.svg`: `favicon.ico` (16+32), `apple-touch-icon.png` 180 nền trắng, `icon-192/512.png`; `site.webmanifest`. Link nằm trong `index.html` (nên có ở mọi trang prerender, `spa-shell`, `404`) và `worker/articles/render.ts` |
| ONPAGE-05/06, SQ-05 | Trang chủ: H1 hết `sr-only`, hiện thành dòng giới thiệu nhỏ trên bước chọn vai; "Bạn là ai?" và câu hỏi bước 2 thành H2, nên HTML tĩnh có H2. Các trang dịch vụ chính đã có H2 trong bản prerender |

Test: `apps/web/worker/security.test.ts`.

#### Đợt hai: lastmod, một bước chuyển hướng, độ dài tiêu đề, cache, trang Liên hệ (SRC-1296, 08.10.2026)

| Mục | Sửa |
| --- | --- |
| TECH-05: 645 URL cùng `lastmod` = ngày deploy | `apps/web/scripts/gen-sitemap.mjs` vốn đã lấy ngày commit cuối của file nguồn, nhưng `deploy-app.yml` checkout NÔNG nên mọi file mang ngày của commit duy nhất. Nay deploy checkout `fetch-depth: 0`; ngày dự phòng là một ngày CỐ ĐỊNH (2026-09-01), không bao giờ là "hôm nay". Bài D1 vẫn dùng `updated_at` |
| TECH-11: `http://nemo12.com/about/` đi hai bước 301 | `canonicalRedirect()` trong `worker/security.ts` gộp https, bỏ `www`, bỏ gạch chéo cuối và giải bí danh (`aliasFor`) thành MỘT bước 301 |
| ONPAGE-01, SQ-02: tiêu đề 30 tới 60 ký tự | `src/site/titleFit.ts` `brandTitle()`: hậu tố thương hiệu (" · Nemo12", " · NEMO SAT") chỉ giữ khi còn vừa 60, quá thì gỡ dần; tiêu đề ngắn chưa có thương hiệu thì thêm. Gọi ở bảng `ROUTES`, `pageTitle()` của họ trang nội dung, `ContentPage` và `head()` của bài viết. `seo-gate` nay CHẶN build khi trang lập chỉ mục có tiêu đề ngoài 30 tới 60 |
| ONPAGE-03, SQ-03: mô tả 70 tới 160 | `clampDescription()` cắt ở ranh giới câu gần nhất (áp cho `ROUTES` và mọi họ trang nội dung, không sửa file nội dung); `fitDescription()` không còn lùi về ứng viên dưới 70 khi có ứng viên dài cắt được. `seo-gate` chặn build ngoài khung |
| SQ-07: tỉ lệ chữ trên HTML thấp ở `/`, `/ielts` | CSS nhúng sẵn bỏ các biến `--*` không ai đọc (`pruneUnusedVars` trong `prerender.mjs`; CSS đầy đủ vẫn tải sau lần vẽ đầu); bỏ ghi chú HTML của template; trang chủ thêm một câu giới thiệu dưới H1. `/`: 44,3 KB, 0,9% thành 35,9 KB, 1,7%. `/ielts`: 91,0 KB, 2,6% thành 79,9 KB, 3,1% |
| QUALITY-02: `/assets/*` trả `max-age=0` | `withCacheHeaders()`: file có hash trong `/assets/` nhận `public, max-age=31536000, immutable`; HTML giữ nguyên `max-age=0` để luôn kiểm lại |
| CONTENT-08, CRO-13: không có trang Liên hệ | `/contact` tĩnh (`contactPage()` trong `worker/articles/render.ts`, prerender ghi ra `dist/`), có trong sitemap, sơ đồ trang và chân trang (cả React lẫn bài viết). Chỉ dùng kênh đã có: `hello@nemo12.com` và Page Facebook, LinkedIn, YouTube, Substack. Không có số điện thoại, địa chỉ, Zalo OA (đã bỏ 25.08.2026) hay link Messenger riêng trong repo nên không ghi; thêm là việc của chủ dự án |
| CONTENT-06: tác giả | Giữ ký tên Ban biên tập; `/about/editorial` thêm mục "Về ban biên tập" nói rõ từng vai (thiết kế cụm bài, soạn, đối chiếu, sửa). Ký tên một tác giả cá nhân là quyết định của chủ dự án |

Test: `apps/web/worker/security.test.ts` (một bước 301, cache), `apps/web/src/site/titleFit.test.ts`.
