SEO của www.conan.school
Trang công khai duy nhất của hệ là www.conan.school (React Router v7, SSR, Cloudflare Pages, phục vụ qua worker cầu edge-hotfix/). Mọi thứ dưới đây nói về site đó.
Bốn cửa mà một trang phải qua
- Được phục vụ đúng, 200, không phải 301 hay 302 sang host khác.
- Có ruột trong HTML SSR, công cụ tìm kiếm đọc được nội dung mà không cần chạy JS.
- Tự mô tả được mình,
<title>,meta description, canonical, OG, structured data. - Được khai trong sitemap và có đường dẫn nội bộ trỏ tới.
Bỏ cửa nào cũng làm trang mất giá trị, và không cửa nào tự báo lỗi khi hỏng. Đó là lý do có scripts/check-seo.mjs.
Cửa thứ tư có gác từ 09.09.2026
"Có đường dẫn nội bộ trỏ tới" là cửa duy nhất trong bốn cửa từng không có gác, và nó bị vi phạm ngay trong ngày /jtbd ra đời: trang có đủ nội dung SSR, đủ meta, đủ sitemap, và không một trang nào của www trỏ tới. Không có gì đỏ.
scripts/check-seo.mjs nay đối chiếu mọi route gốc (một đoạn, không tham số) trong app/routes.ts với app/config/navigation.ts. Trang chi tiết không bị xét: chúng được trỏ tới từ trang danh sách của chính chúng.
Đây là mốc chặn, không phải cổng tuyệt đối, sáu họ đã ở tình trạng này từ trước (/activities, /album, /books, /news, /style-guide, /workshop-setup-guide). Danh sách NO_INTERNAL_LINK_BASELINE chỉ được rút ngắn; thêm tên vào đó là ghi nhận một món nợ mới và phải nói rõ lý do trong PR. Gác cũng đỏ khi một họ đã có liên kết mà vẫn còn nằm trong mốc, mốc không được dọn cũng là một dạng sai.
Header
app/entry.server.tsx đặt HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy cho mọi phản hồi HTML.
Không route nào được khai export function headers(). React Router cho headers() của route sâu nhất thay thế header của cha, chứ không gộp. Hai mươi hai route ở www từng khai headers() chỉ để đặt một dòng Cache-Control trùng với root, và mỗi route đó phục vụ trang không có một header bảo mật nào, trang chủ nằm trong số đó. check-seo.mjs cấm hẳn headers() ở route.
Sitemap
/sitemap.xml là sitemap index, trỏ tới sáu sitemap con:
| Sitemap con | Nguồn | Số URL (08.09.2026) |
|---|---|---|
sitemap-pages.xml | danh sách viết tay trong route | 23 |
sitemap-courses.xml | /api/public/courses + /course-units/sitemap/courses | 212 |
sitemap-course-units.xml | /api/public/course-units/sitemap/units | 691 |
sitemap-books.xml | /api/content/ebooks | 176 |
sitemap-book-chapters.xml | /api/public/ebooks/sitemap | 834 |
sitemap-library.xml | playbooks + news + learner stories | ~41 |
sitemap-community.xml | 18 hub cộng đồng, mentors, workshops, events, announcements, challenges, albums, jobs | 87 |
Ba luật của sitemap ở đây:
- Một họ hỏng không được kéo đổ cả sitemap. Trước 08.09.2026 sitemap là một route duy nhất gọi bốn API trong
Promise.allSettledrồi bỏ qua mọi lỗi. Khi/api/public/coursesbắt đầu trả 500, toàn bộ URL khoá học biến mất khỏi sitemap và không có gì đỏ ở đâu cả. - Không đưa URL chuyển hướng vào sitemap.
/activitiestrả 301 và từng nằm trong đó. Chương 2 trở đi của mỗi quyển sách trả 302 sangtokyo.conan.school/login, nênsitemap-book-chapters.xmlchỉ khai chương 1 (PUBLIC_CHAPTER_LIMIT). - Chỉ khai trang có ruột. Trang section vẫn chưa được khai: 89/94 section trong mẫu đo chỉ có tiêu đề và một câu hỏi dẫn, nội dung của chúng chưa được viết. Xem "Hai kho nội dung" bên dưới.
Thêm một sitemap con phải khai hai chỗ: app/routes.ts và mảng CHILDREN trong app/routes/sitemap.ts. Khai thiếu một chỗ là một họ nội dung không bao giờ tới được công cụ tìm kiếm, và không có triệu chứng nào. check-seo.mjs bắt cả hai.
Payload SSR
Loader của React Router được tuần tự hoá nguyên vẹn vào HTML dưới dạng window.__reactRouterContext. Trải một phản hồi API vào giá trị trả về của loader nghĩa là gửi cả phản hồi đó cho trình duyệt.
Trang chủ từng làm đúng thế: return { ...(json.data || {}) } với /api/public/misc/home, một phản hồi 1,96 MB chứa mọi khoá học và mọi ebook của trường, kèm classroom_url, zalo_group_url, authoring_meta_json, để render ba thẻ khoá học. HTML trang chủ là 1,99 MB. Sau khi chiếu xuống đúng các trường được dùng: 37,7 KB, giảm 98%.
Luật: loader chỉ trả về các trường mà JSX đọc, và cắt danh sách ở loader chứ không ở component. Cắt ở component thì phần bị bỏ vẫn đã đi qua dây.
Cache biên cho HTML
Worker cầu conan-www-stale-asset-bridge (www.conan.school/edge-hotfix/) sở hữu route www.conan.school/*. Nó từng ép no-store cho mọi HTML, nên mỗi lượt truy cập là một lượt SSR đầy đủ, đo được 0,69–1,12 s TTFB ở trang chủ.
Nay HTML được cache ở biên 60 giây. Đo trên bản build thật: 0,79 s (MISS) → 0,07 s (HIT).
Cache được ở đây vì một điều kiện phải giữ cho đúng: HTML SSR không phụ thuộc người xem. Không loader nào của www đọc cookie hay header của request, không phản hồi nào đặt cookie, và cùng một URL lấy có cookie và không cookie trả về HTML giống từng byte (đo 08.09.2026). Khi nào một trang bắt đầu render nội dung riêng cho từng người, phải xét lại cache này, worker có hai chốt tự chặn (Set-Cookie và Vary) nhưng chúng chỉ đỡ được trường hợp máy chủ nói ra.
Bản lưu mang TTL, bản trả cho người xem luôn no-store
Bản đầu tiên trả nguyên hit.headers cho người xem, tức trả lại cả Cache-Control đã lưu, mà Cloudflare đã nới thành public, max-age=14400. Mọi người quay lại sẽ giữ HTML trang chủ bốn tiếng trong trình duyệt của chính họ, nơi không bản deploy nào với tới được.
Toàn bộ ý nghĩa của việc cache ở biên là ta bỏ được nó. Một cache nằm trong trình duyệt người dùng biến cửa sổ lệch 60 giây thành bốn tiếng. Cả hai nhánh HIT và MISS đều phải đi qua một hàm duy nhất đặt lại no-store, đừng chép header của bản lưu.
Cửa sổ lệch sau deploy là 60 giây và tự lành. Pages giữ asset băm của các bản dựng cũ nên HTML cũ vẫn tải được, đây không phải sự cố stale-asset đã sinh ra worker này, cái đó đến từ việc ghim một deployment.
Không cache: request không phải GET, request có Authorization, phản hồi khác 200 hoặc không phải text/html, và các đường /auth/, /logout, /onboarding.
Worker này trước đó là thứ duy nhất được triển khai trong repo mà không có workflow deploy, sửa nó nghĩa là wrangler deploy từ máy, điều repo cấm, nên trên thực tế không ai sửa. Đó là lý do một dòng tốn kém rõ ràng nằm đó cả tháng. Nay có deploy-edge-bridge.yml.
Structured data
- Toàn site (
app/root.tsx):EducationalOrganization+WebSite, nối bằng@id. - Trang sách:
Book(bookFormat: EBook,isAccessibleForFreetheois_free) +BreadcrumbList. - Trang chương:
Chapter(cóisPartOftrỏ vềBook) +BreadcrumbListbốn cấp.
Hai kho nội dung, và kho nào có ruột
Trước khi khai một họ URL vào sitemap, phải biết nội dung của nó có thật hay không. Đo 08.09.2026:
| Kho | Số lượng | Có ruột? |
|---|---|---|
course_learning_units | 691 unit / 212 khoá | Có, 691/691 có essential_question_vi + big_idea_vi, 665 có purpose_vi, kèm 2.122 outcome, 4.187 element, 622 chuyện SCQA |
legacy_ebook_chapters | 863 chương / 185 sách | Có, 863/863 có câu hỏi cốt lõi + ý tưởng chính |
legacy_ebook_sections | ~800 section | Không, 89/94 trong mẫu chỉ có tiêu đề |
legacy_courses chi tiết | 202 khoá | Mỏng, tên + một đoạn mô tả |
Hai bảng courses, chỉ trùng 11 slug
legacy_courses (202 đang chạy) là danh mục mà /api/public/courses và trang khoá học của website đọc. courses không tiền tố (217 đang chạy) là nơi 691 unit sống. Chỉ 11 slug trùng nhau, và cột cũng khác: legacy_courses.is_active với courses.status.
Viết truy vấn cho bảng này rồi chạy lên bảng kia thì hoặc là một câu lệnh hợp lệ không tìm thấy gì, hoặc lỗi vì cột không tồn tại. Đã dính đúng lần đầu: WHERE c.is_active = 1 trên bảng không tiền tố.
Hệ quả cho SEO: 201 trong 212 khoá có unit không có dòng nào trong legacy_courses, nên /courses/:slug của chúng trả 404, và trang unit sẽ treo dưới một cha không tồn tại. Trang khoá học vì thế đọc legacy trước, không có thì rơi về khoá học của họ bảng kia.
Tầng xem trước
course_learning_units và các chương từ 2 trở đi đều nằm sau thành viên. Trước 08.09.2026 cách khoá là 302 sang tokyo.conan.school/login, một cách khoá không bao giờ thắng được một mục trong chỉ mục, cho cả người lẫn máy, và không cho người đọc lần đầu thứ gì để đánh giá. Nay chúng là trang xem trước:
| Công khai | Sau tường phí | |
|---|---|---|
| Unit | tên, câu hỏi cốt lõi, ý tưởng chính, mục đích, outcome, nhãn khái niệm, tình huống + nút thắt + câu hỏi của chuyện | bài học, ví dụ đã làm, câu trả lời của chuyện, bài tập, bài kiểm |
| Chương 2+ | tên, câu hỏi của chương, ý tưởng chính, danh sách chương | phần đọc, chuyện, bài tập |
Ranh giới vẽ ở API (src/domains/public/unitPreviews.ts), không ở giao diện: phần trả phí không có mặt trong phản hồi, chứ không phải bị ẩn bằng CSS. Trang khai isAccessibleForFree: false kèm hasPart + cssSelector theo chuẩn nội dung có tường phí của Google, người và máy thấy đúng một thứ, nên đây là xem trước chứ không phải cloaking.
Kết quả: 2.065 URL trong sitemap (trước rà soát: 205).
Một khoá, hai slug
Hai bảng courses dùng chung id (218 dòng) nhưng slug lệch nhau ở 190 khoá: legacy_courses giữ slug tiếng Việt mà website vẫn dẫn (doc-tin-hieu-mua), courses giữ slug tiếng Anh (sales-buying-signals).
190 cặp trang trùng nội dung, và cặp nào cũng chỉ một nửa hiện unit
Cả hai URL đều trả 200, đều tự canonical về chính mình, và sau khi thêm sitemap hợp của hai danh mục thì cả hai cùng được nộp cho công cụ tìm kiếm. Cùng một khoá, hai địa chỉ, và unit chỉ hiện trên một trong hai.
Slug tiếng Việt thắng: nó là thứ /courses vẫn dẫn tới và là thứ một truy vấn tiếng Việt khớp được. Mọi lượt tra nhận cả hai slug và trả về slug chuẩn; trang nào được gọi bằng slug không chuẩn thì 301 về slug chuẩn, nên link cũ vẫn sống mà không sinh ra bản sao. Sau khi khử trùng, sitemap-courses.xml từ 402 xuống 212, con số giảm là con số đúng.
Hub cộng đồng
/communities và /communities/:slug, 18 hub theo ngành và theo nghề, gom 190 khoá và 156 sách đang có community_id.
Việc chính của chúng là đường dẫn nội bộ chứ không chỉ là trang đích: trước đó, đường duy nhất tới phần lớn số khoá và sách đó là file sitemap. Một trang cách trang chủ hai cú bấm được đối xử rất khác một trang chỉ xuất hiện trong XML.
Chủ đề là mục trong hub, không phải URL riêng. 24 trong 85 cặp (cộng đồng, chủ đề) chỉ có một khoá và 33 cặp có hai. Một trang mà toàn bộ nội dung là một đường dẫn là trang cửa ngõ; 85 trang như thế tốn của tên miền nhiều hơn phần chúng mang lại.
Gác
node scripts/check-seo.mjs, chạy trong job guards của CI. Nó bắt bốn thứ, tất cả đều là lỗi đã xảy ra thật: headers() ở route, trang chi tiết thiếu meta description, lời gọi tới đường dẫn /api/public/... không tồn tại, và sitemap con khai thiếu một trong hai chỗ.
Xem thêm known-risks và skill seo-www.
Playbooks: nội dung viết tay, không phải engine
/playbooks là chuyên mục bài SEO long-form, đọc legacy_seo_content_briefs + legacy_seo_content_versions. Không có engine nào ghi vào hai bảng đó.
content-factory/ là hệ khác: nó ghi content_briefs/content_artifacts, nhận brief của một sự kiện có thật (có diễn giả, trích dẫn có xin phép) nên nó viết bài tường thuật; router của nó chưa được mount trong src/index.ts và cả ba bảng có 0 dòng. Các file db/sql/data/seo/articles/*.sql là di sản Supabase (DO $$, ::jsonb), không chạy trên D1.
Nên bài playbook mới đi đường PlaybookPack: JSON trong api.conan.school/content/playbooks/, biên dịch bằng scripts/playbook_pack_to_sql.py ra migration, CI áp.
Hai họ bảng seo_*, site chỉ đọc một
D1 có cả seo_content_briefs và legacy_seo_content_briefs, mỗi bảng 25 dòng giống nhau. getD1Client() gắn tiền tố legacy_, nên d1Client.from('seo_content_briefs') thật ra đọc bảng legacy_. Ghi vào bảng không tiền tố là câu lệnh hợp lệ, chạy xong không lỗi, và site không bao giờ thấy bài mới.
Gác --check chạy trong job coursepack của CI và bắt loại lỗi đếm được: bài dưới 900 từ, meta description ngoài khoảng 70–160 ký tự, thiếu hoặc thừa câu quiz, đáp án trỏ ra ngoài mảng, cả ba đáp án dồn một vị trí, từ khoá không xuất hiện trong bài. Batch đầu dính ba lỗi trong số đó trước khi gác chạy.
Endpoint danh sách chỉ trả các trường trang danh sách đọc; route chi tiết mới lấy *. Trước 08.09.2026 nó trả * cho mọi bài, kèm quiz_data (~850 B/bài), outline, key_takeaway và toàn bộ cột quy trình soạn, tới một trang chỉ hiện tiêu đề, mô tả và ảnh. Ở 37 bài đã là 123 KB, theo đà đó 300 bài ≈ 976 KB; sau khi chiếu trường còn 31 KB (300 bài ≈ 245 KB). Cùng khuôn với lỗi trang chủ 1,99 MB ở đầu trang này.
Chi phí thật: một bài đạt chuẩn ≈ 1.100–1.200 từ tiếng Việt (25 bài đang chạy trung bình 1.168). Đi từ 25 lên 300 là 275 bài ≈ 320.000 từ, nhiều đợt soạn, không phải một lượt chạy. Xem skill playbook-pack.