Soát công cụ chấm điểm SEO theo tài liệu Google, và lỗi tính điểm âm thầm

Truy từng quy tắc của một công cụ chấm điểm SEO mã nguồn mở về hướng dẫn của Google, cái gì đổi khi thiếu nguồn, và lỗi tính điểm không hề báo lỗi.

· Đăng ngày · 9 phút đọc

Một công cụ SEO chấm trang thành điểm 0–100 trông rất khách quan, nhưng mỗi con số đứng sau điểm đó đều là một lựa chọn: mật độ từ khoá 1%, 600 từ, đúng một thẻ H1. Bài này đi qua việc đối chiếu từng quy tắc của một công cụ như vậy với tài liệu của chính Google, những gì phải đổi khi một quy tắc không có nguồn, và các lỗi tính điểm không hề báo lỗi nhưng âm thầm làm lệch điểm của mọi trang.

Công cụ là seo-analyzer, một thư viện mã nguồn mở mình đang duy trì. Nó là bản fork của package yoastseo của Yoast: phần xử lý ngôn ngữ của Yoast (tách gốc từ, tách câu, từ nối cho hơn 20 ngôn ngữ) cộng với bộ tiêu chí chấm điểm mình viết cho trang thương mại điện tử. Trước bản phát hành npm đầu tiên vào tháng 10/2026, mình soát lại toàn bộ các tiêu chí. Danh sách đầy đủ kèm nguồn nằm trong checklist của repo.

Điểm được tính thế nào

Mỗi tiêu chí trả về good, improve hoặc bad, tương ứng 100%, 50% hoặc 0% trọng số của nó. Trọng số lấy theo mức ưu tiên:

export const POINT = 100;
export const priorityRate = { high: 1, medium: 0.75, low: 0.5, info: 0 };
export const statusRate = { good: 1, improve: 0.5, bad: 0 };

// điểm của một tiêu chí
POINT * priorityRate[priority] * statusRate[status]

Điểm tổng là số điểm đạt được chia cho số điểm tối đa của các tiêu chí áp dụng cho trang, tính theo phần trăm. Từ đó có hai hệ quả, và phần còn lại của bài cứ quay về hai điều này:

  • Mức ưu tiên là một khẳng định về tầm quan trọng. Tiêu chí high kéo điểm gấp đôi tiêu chí low. Nếu quy tắc đằng sau chỉ là phỏng đoán, điểm số sẽ khuếch đại phỏng đoán đó.
  • Mọi tiêu chí cộng vào cùng một tổng. Một tiêu chí trả về giá trị hỏng (NaN, quên gán điểm) làm sai điểm tổng của mọi trang, không chỉ dòng của riêng nó.

Phần lớn các con số SEO phổ biến không có nguồn từ Google

Lượt soát đầu tiên rất đơn giản: với mỗi ngưỡng, tìm trang tài liệu Google nói ra con số đó. Với hầu hết các tiêu chí nội dung, không có trang nào cả. Hướng dẫn SEO cơ bản của Google nói thẳng rằng độ dài nội dung tự nó không ảnh hưởng xếp hạng ("the length of the content alone doesn't matter for ranking purposes"), rằng không có số lượng heading lý tưởng, thứ tự heading không quan trọng, và từ khoá trong tên miền hay đường dẫn URL "alone have hardly any effect". Hướng dẫn về nội dung hữu ích còn coi việc viết cho đủ số từ là dấu hiệu của nội dung viết cho công cụ tìm kiếm trước, người đọc sau.

Khi Google không đưa ra con số, công cụ chấm điểm vẫn phải chọn một con số. Nguyên tắc mình chốt: dùng mặc định hiện tại của Yoast cho ngưỡng, và hạ mức ưu tiên, để một con số không có nguồn không chi phối được điểm tổng. Những thay đổi:

Tiêu chí Trước Sau Lý do
Mật độ từ khoá 1–1,5%, high 0,5–3%, trên 3% là bad, medium Không có mật độ lý tưởng; thứ bị cấm là nhồi từ khoá, nên ngưỡng trên mới quan trọng
Độ dài bài 600 từ và 2.500 ký tự, high 300 từ (improve từ 200), medium Không có số từ ưu tiên; chỉ cảnh báo trang quá mỏng
Một thẻ H1 Thiếu hoặc nhiều H1 là bad, high improve, medium Số lượng và thứ tự heading không ảnh hưởng xếp hạng; một tiêu đề chính vẫn giúp trình đọc màn hình và tiêu đề hiển thị trên Google
Meta description 150–160 ký tự 120–160 Không giới hạn độ dài; bị cắt theo bề rộng màn hình
Từ khoá trong URL medium low "Hardly any effect"
Link ra ngoài không có là bad, high không có là improve, low Không phải yếu tố xếp hạng; dẫn nguồn giúp người đọc kiểm chứng
Số trong tiêu đề không có số là bad bỏ Google không có hướng dẫn nào, và tiêu chí này đẩy người viết về phía tiêu đề giật tít

Tiêu chí "số trong tiêu đề" là cái mình thấy sai rõ nhất khi nhìn lại. Nó đến từ lời khuyên kiểu bài liệt kê ("10 cách để…"), chấm bad mọi tiêu đề không có chữ số, lại ở mức ưu tiên cao. Đặt nó cạnh câu hỏi "nguồn ở đâu" mới thấy nó đo một phong cách viết, không đo chất lượng.

Chấm điều trang làm được, không chấm điều người dùng gõ vào

Có một tiêu chí cho điểm tối đa chỉ vì đã nhập từ khoá chính. Với mức ưu tiên cao, đó là 100 điểm cho việc gõ chữ vào một ô, trên mọi trang, bất kể nội dung. Nó có ích như một lời nhắc, vì hầu hết các tiêu chí khác cần từ khoá, nhưng nó chẳng nói gì về trang.

Cách sửa là thêm mức ưu tiên thứ tư, info, trọng số 0: tiêu chí vẫn hiện trong danh sách, chỉ là không tính điểm. Câu hỏi kiểm tra cho bất kỳ tiêu chí nào: kết quả này có đổi khi trang tốt lên không? Nếu không, nó là gợi ý, không phải điểm.

Biến "càng nhiều càng tốt" thành ngưỡng trên

Nhiều tiêu chí thưởng điểm khi từ khoá xuất hiện, mà không hỏi xuất hiện bao nhiêu lần. Như vậy là đẩy người dùng về đúng thứ Google gọi là spam. Ở chỗ nào Google mô tả kiểu lỗi đó, tiêu chí giờ kiểm tra chính kiểu lỗi đó:

  • Từ khoá trong tiêu đề: có một lần là good, lặp lại là improve. Hướng dẫn về title link của Google nói không có lý do gì để cùng một từ hay cụm từ xuất hiện nhiều lần.
  • Alt text: thiếu alt vẫn là bad, nhưng alt lặp từ khoá giờ là improve. Google cảnh báo nhồi từ khoá vào thuộc tính alt có thể bị coi là spam.
  • Mật độ từ khoá: trên 3% là bad, và đây giờ là đầu có ý nghĩa của khoảng.

Thêm tiêu chí ở chỗ Google nói cụ thể

Lượt soát cũng tìm ra những hướng dẫn thật sự cụ thể và kiểm tra được, nên chúng thành tiêu chí mới:

  • Chữ của link (linkText): hướng dẫn về link của Google yêu cầu anchor text "descriptive, reasonably concise, and relevant", và đưa "Click here", "Read more" làm ví dụ xấu. Tiêu chí cảnh báo các cụm đó và link rỗng; danh sách cụm từ chỉnh được.
  • Năm cũ trong tiêu đề (titleYear): hướng dẫn title link nói Google có thể phát hiện năm đã cũ trong <title> và lấy ngày trên trang để hiển thị thay. Trang "Tốt nhất … 2025" đã cập nhật thì nên sửa cả năm trong tiêu đề.
  • Structured data cho sản phẩm (productSchema): merchant listing bắt buộc có name, image và một Offer có price, priceCurrency, và khác với product snippet, nó "require a price greater than zero". Thiếu thuộc tính bắt buộc là bad; thiếu thuộc tính khuyến nghị (thương hiệu, GTIN, tình trạng hàng, đánh giá) là improve.
  • Tên file ảnh chung chung (imageFileNames, low): IMG_1234.jpg, image1.png, chuỗi hash. Tín hiệu nhỏ, nên trọng số nhỏ.

Lỗi tính điểm không ném exception

Soát lại ngưỡng là việc đã lên kế hoạch. Các lỗi thì không: chúng lộ ra khi mình cài bản đóng gói của thư viện vào một app Vite + React mới tinh và dùng đúng như README hướng dẫn. Không lỗi nào báo ra gì cả. Mỗi lỗi chỉ cho ra một con số hơi sai.

Bộ đếm từ đếm khoảng trống. Tiêu chí độ dài bài tách văn bản bằng mẫu từ thay vì tìm các từ khớp mẫu:

'one two three'.split(/[\p{L}\p{N}]+(?:-[\p{L}\p{N}]+)*/gu)
// ['', ' ', ' ', ''] → 2 phần không rỗng

Thứ còn lại sau khi tách là các khoảng giữa các từ, nên văn bản thường luôn thiếu một từ, còn văn bản có dấu câu thì đúng sai tuỳ dấu phẩy rơi vào đâu. Cách sửa là dùng phép đếm từ mà tầng ngôn ngữ đã có sẵn, vốn cũng đếm tiếng Nhật theo ký tự.

Điểm không bao giờ được gán. Trong tiêu chí độ dài URL:

assessmentResult.setStatus(calculatedResult.status);
assessmentResult.setStatus(calculatedResult.status); // định viết: setScore(...)

Trạng thái là good, dòng hiện màu xanh, nhưng tiêu chí đóng góp 0 điểm trong khi trọng số của nó vẫn tính vào điểm tối đa. Mọi trang có URL đều bị điểm thấp hơn một chút. Một tiêu chí khác cũng cùng dạng: gán trạng thái nhưng không gán điểm.

Trạng thái rỗng thành NaN. Tiêu chí H1 bắt đầu bằng let status = '' và chỉ gán giá trị trong vài nhánh; gọi nó mà không truyền tuỳ chọn h1Count trên văn bản có <h1> thì không rơi vào nhánh nào. statusRate[''] là undefined, 100 * 1 * undefined là NaN, và NaN cộng gì cũng ra NaN, nên một tiêu chí biến điểm tổng thành NaN. Đặt status mặc định là một giá trị thật, và trả về 0 khi không có tiêu chí tính điểm nào áp dụng, đóng được cả hai đường.

Thứ tự ưu tiên toán tử trong giá trị dự phòng. Alt text gợi ý được ghép như sau:

shop?.name || 'Shop' + ' | ' + data?.title || 'Title'

+ ưu tiên cao hơn ||, nên khi có tên shop, biểu thức chỉ trả về tên shop. Viết bằng template literal (`${shop?.name || 'Shop'} | ${data?.title || 'Title'}`) thì đúng ý định.

Tiêu chí biến mất trên trình duyệt. Các hàm xử lý link import module url của Node, và bộ parse HTML cần events. Vite không tự polyfill module có sẵn của Node cho code chạy trên trình duyệt, nên tiêu chí link nội bộ và link ra ngoài lỗi lúc chạy và rơi khỏi kết quả. Trên màn hình không có gì báo thiếu tiêu chí; danh sách chỉ ngắn đi. Giờ cả hai polyfill là dependency thật.

Test bất biến, không chỉ test ví dụ

Điểm chung: mỗi lỗi phá vỡ một quan hệ lẽ ra luôn đúng, trong khi từng kết quả riêng lẻ vẫn trông hợp lý. Nên test khẳng định chính các quan hệ đó:

// điểm của mỗi tiêu chí phải suy ra được từ trạng thái và mức ưu tiên
const mismatched = analyze(text, attributes).assessments.filter(
  (a) => a.score !== POINT * priorityRate[a.priority] * statusRate[a.status],
);
expect(mismatched).toEqual([]);

Một khẳng định đó bắt được cả họ lỗi "gán trạng thái, quên gán điểm" trên bất kỳ trang mẫu nào. Xung quanh nó:

  • Điểm tổng là số hữu hạn với mọi trang mẫu (Number.isFinite), chặn các đường ra NaN.
  • Snapshot hồi quy ghi lại trạng thái và điểm của từng tiêu chí trên một bộ trang mẫu. Sau một thay đổi có chủ đích, diff cho thấy chính xác trang nào đổi điểm và đổi bao nhiêu; thay đổi ngoài dự kiến là lỗi hồi quy.
  • Mọi link "Learn more" đều trỏ đúng chỗ: một test đọc tài liệu checklist và báo lỗi nếu anchor trong docUrl của tiêu chí nào đó không có mục tương ứng.
  • Kiểm tra import trong bước lint báo lỗi khi import không tìm thấy file, package chưa khai báo trong dependencies, hoặc module có sẵn của Node không kèm polyfill cho trình duyệt. Nó sẽ bắt được vụ tiêu chí link biến mất trước cả bundler.

Tóm tắt

Bài học Áp dụng
Mỗi ngưỡng cần một nguồn Đặt link hướng dẫn cạnh quy tắc; không có nguồn thì dùng mặc định thận trọng và hạ mức ưu tiên
Chấm trang, không chấm dữ liệu nhập Tiêu chí mà kết quả không đổi khi trang tốt lên thì trọng số 0
Ưu tiên ngưỡng trên Chỗ nào "nhiều hơn" thành spam, cảnh báo phần thừa thay vì thưởng cho việc có mặt
Thêm tiêu chí ở chỗ hướng dẫn cụ thể Chữ của link, năm đã cũ, thuộc tính bắt buộc của sản phẩm
Code tính điểm hỏng trong im lặng Test bất biến (điểm = ưu tiên × trạng thái, tổng hữu hạn), không chỉ test ví dụ
Dùng package của mình như người lạ Cài bản đóng gói vào một app mới; lỗi bundler và lỗi thiếu tuỳ chọn lộ ra ở đó

Điểm số chỉ có ích khi nó chỉ vào thứ người đọc thật sự nhận ra.

Nếu bạn từng truy các ngưỡng của công cụ chấm điểm hay linter của mình về nguồn, hoặc giữ lại vài ngưỡng không có nguồn, mình rất muốn được trao đổi. Email của mình ở trang chủ.