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. 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>.jsoncủ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
testimonialPublishBlockerscó đượ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-mehiệ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 mangstatus: "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:
- 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.
said_onhạ 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.- 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.
internal_noteviế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ờiroutes.ts, họ/aprờiFAMILIES,/aprờiCONTENT_PREFIXES, nhánh AP rờiApp.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.tstrả 404 thật. Hai bí danh/ap/journeyvà/ap/subjectsgỡ 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.tsxvà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.