hreflang cho website tĩnh 13 ngôn ngữ: dựng lại từ các file thực sự tồn tại thay vì sửa tay
Xin chào, mình là Ayumu Kitabayashi, lập trình viên làm một mình ở Nhật. Mình đang làm minulo, một app iPhone ẩn Reels và Shorts. Bài này không nói về app mà nói về website giới thiệu app, minulo.app: một site HTML tĩnh trên GitHub Pages, không có bước build, chạy 13 ngôn ngữ, trong đó có tiếng Việt.
Nếu bạn cũng đang làm một site đa ngôn ngữ nhỏ và tự viết thẻ hreflang bằng tay, có lẽ bạn sẽ gặp đúng vấn đề mình gặp.
Cấu trúc thư mục
Tiếng Nhật nằm ở thư mục gốc, 12 ngôn ngữ còn lại mỗi thứ một thư mục:
/ tiếng Nhật (ja)
/en/ /ko/ /de/ /fr/ /es/ /it/ /zh-Hant/ /id/ /th/ /vi/ /ru/
/pt/ tiếng Bồ Đào Nha Brazil (hreflang là pt-BR)
Vấn đề là số trang của mỗi ngôn ngữ chênh nhau rất nhiều. Tối ngày 24/10/2026, đếm file HTML ngay trong mỗi thư mục thì được:
| Ngôn ngữ | Số trang |
|---|---|
| Tiếng Anh | 67 |
| Hàn, Đức, Pháp, Tây Ban Nha, Bồ Đào Nha | 64 mỗi thứ |
| Tiếng Nhật (thư mục gốc) | 62 |
| Indonesia | 15 |
| Ý | 14 |
| Thái, Việt | 13 mỗi thứ |
| Phồn thể, Nga | 12 mỗi thứ |
Thư mục /vi/ chỉ có 13 trang. Có trang như tiktok-dms-only.html có đủ 13 bản ngôn ngữ, nhưng cũng có trang chỉ có tiếng Anh, Nhật và Hàn. Tức là mỗi trang có một bộ hreflang khác nhau, không thể dùng chung một khối thẻ cho cả site.
Sửa tay thì hỏng ở đâu
Ban đầu mình sinh HTML từ các file JSON bằng script Python, hreflang cũng lấy từ danh sách "ngôn ngữ nào đã dịch" trong JSON. Sau khi đăng, mình sửa tay HTML khá nhiều: rút ngắn tiêu đề để không bị cắt trên trang kết quả tìm kiếm, thêm FAQ, gắn tham số đo lường vào nút. Dần dần JSON và HTML lệch nhau. Đến giờ, trong tài liệu của mình ghi rõ: một số script sinh trang "không được chạy", vì chạy lại sẽ trả về tiêu đề cũ và hreflang cũ.
Mỗi lần thêm một ngôn ngữ cho một trang, mình phải:
- viết hreflang cho tất cả ngôn ngữ trong
<head>của trang mới, - sửa luôn
<head>và footer của các bản ngôn ngữ đã có, vì nếu A trỏ sang B thì B cũng phải trỏ về A, - thêm
xhtml:linkvào mọi<url>của trang đó trong sitemap.xml.
Ngày 23/10, đợt kiểm tra kỹ thuật tìm ra một trang tiếng Hàn không có trang nào trỏ tới, 4 link trong trang tiếng Hàn trỏ sang bản tiếng Anh dù đã có bản tiếng Hàn, và 250 mục lastmod trong sitemap cũ hơn ngày sửa thật.
Ngày 24/10, mình thêm một lần 88 trang có ở tiếng Anh mà chưa có ở Tây Ban Nha, Bồ Đào Nha, Đức, Pháp, Hàn (20, 20, 19, 18, 11). Commit đó đụng tới 255 file. Phần lớn không phải trang mới mà là trang cũ cần sửa hreflang và footer. Tới đây mình quyết định không sửa tay nữa.
Ý tưởng: chỉ tin vào việc file có tồn tại hay không
Lỗi nặng nhất với hreflang là trỏ tới trang không tồn tại, và link một chiều. Cả hai đều tránh được nếu chỉ hỏi một câu: "file của slug này có nằm trong thư mục ngôn ngữ đó không?"
- JSON hay bảng danh sách sẽ bị quên cập nhật khi sửa tay. File thì chính là thứ đang được public.
- Nếu ghi lại toàn bộ các bản ngôn ngữ cùng một lúc với cùng một bộ, link một chiều không thể xảy ra.
- Xoá một trang thì lần chạy sau nó tự rơi khỏi bộ.
Script
tools/sync_langs.py dài 37 dòng. Truyền slug vào, nó dựng lại hreflang, link ngôn ngữ ở footer và alternate trong sitemap cho mọi bản ngôn ngữ của trang đó.
python3 tools/sync_langs.py why-reels-are-addictive.html study-focus.html
Bảng ngôn ngữ là bộ ba (giá trị hreflang, thư mục, tên hiển thị ở footer). Tiếng Nhật có thư mục là chuỗi rỗng:
L=[('ja','','日本語'),('en','en','English'),('ko','ko','한국어'),
('de','de','Deutsch'),('fr','fr','Français'),('es','es','Español'),
('pt-BR','pt','Português'),('it','it','Italiano'),('zh-Hant','zh-Hant','繁體中文'),
('id','id','Bahasa Indonesia'),('th','th','ไทย'),('vi','vi','Tiếng Việt'),('ru','ru','Русский')]
Thư mục tên pt nhưng nội dung là tiếng Bồ Đào Nha Brazil, nên hreflang là pt-BR. Mình tách thư mục và hreflang thành hai cột chính là vì trường hợp này.
Phần quan trọng nhất chỉ có một dòng:
have=[(h,d,n) for h,d,n in L if os.path.exists(path(d,s))]
Sau đó script xoá hết thẻ alternate cũ trong từng bản ngôn ngữ rồi chèn khối mới ngay sau thẻ canonical:
t=re.sub(r'<link rel="alternate" hreflang="[^"]+" href="[^"]+">\n?','',t)
t=re.sub(r'(<link rel="canonical"[^>]*>\n)',lambda m:m.group(1)+block+'\n',t,count=1)
Mình xoá rồi ghi lại chứ không thêm vào. Bộ cũ là gì cũng không quan trọng, kết quả chỉ phụ thuộc vào file nào đang có.
Trang có tên file khác giữa các ngôn ngữ
Có đúng một trang mà bản tiếng Nhật mang tên khác: bản tiếng Anh là turn-off-youtube-autoplay-recommendations.html, còn bản tiếng Nhật là youtube-autoplay-recommendations-off.html, vì bản tiếng Nhật được đăng trước và mình không muốn đổi URL. Mình xử lý bằng một bảng ánh xạ:
JA={'turn-off-youtube-autoplay-recommendations.html':'youtube-autoplay-recommendations-off.html'}
def js(d,s): return JA.get(s,s) if not d else s
Kiểm tra file tồn tại, tạo URL hay tạo đường dẫn tương đối ở footer đều phải đi qua js(). Sau này có thêm trang như vậy thì chỉ cần thêm một dòng vào bảng.
x-default
Nếu có bản tiếng Anh thì x-default trỏ về bản tiếng Anh, không thì trỏ về ngôn ngữ đầu tiên đang có:
xdef = url('en',s) if any(d=='en' for _,d,_ in have) else url(have[0][1],s)
Như vậy một trang chỉ có tiếng Nhật và tiếng Hàn sẽ không trỏ x-default sang một bản tiếng Anh không tồn tại.
Footer và sitemap dùng chung một danh sách
Footer <div class="langs"> cũng được dựng từ have. Ngôn ngữ hiện tại để trong <b>, các ngôn ngữ khác là link tương đối có thuộc tính hreflang và lang. Vì <head> và footer lấy từ cùng một danh sách, sẽ không còn chuyện <head> có 7 ngôn ngữ mà footer chỉ có 6.
sitemap.xml khai báo namespace xhtml. Script xoá các <url> của slug đó rồi thêm lại ở cuối, mỗi <url> có xhtml:link cho mọi bản đang có cộng với x-default:
<url><loc>https://minulo.app/en/why-reels-are-addictive.html</loc><lastmod>2026-10-24</lastmod>
<xhtml:link rel="alternate" hreflang="ja" href="https://minulo.app/why-reels-are-addictive.html"/>
<xhtml:link rel="alternate" hreflang="en" href="https://minulo.app/en/why-reels-are-addictive.html"/>
…
<xhtml:link rel="alternate" hreflang="x-default" href="https://minulo.app/en/why-reels-are-addictive.html"/>
</url>
Tối 24/10, sitemap có 612 URL.
Nói thật một điểm chưa ổn: bản hiện tại ghi thẳng một ngày cố định vào lastmod, nên nó là "ngày chạy script" chứ không phải "ngày sửa nội dung". Script lấy lastmod từ lịch sử git thì mình mới lên kế hoạch, chưa làm.
llms.txt vẫn viết tay
Ở thư mục gốc có llms.txt, file tóm tắt nội dung site cho các công cụ AI. Trong mục "## Pages", mỗi trang một dòng: tiêu đề tiếng Anh, một ghi chú trong ngoặc về việc trang trả lời câu hỏi gì, rồi URL của từng bản ngôn ngữ. Phần này mình không đưa vào script, vì ghi chú trong ngoặc cần người đã đọc trang viết ra. Mỗi khi thêm trang hoặc thêm ngôn ngữ, mình sửa dòng đó bằng tay.
Đăng xong thì báo bằng IndexNow
Sau khi push và kiểm tra trang đã lên, mình gửi danh sách URL qua IndexNow. Ở thư mục gốc có một file text chứa key, và mình POST key cùng danh sách URL:
curl -s -o /dev/null -w "%{http_code}\n" -X POST https://api.indexnow.org/indexnow \
-H 'Content-Type: application/json; charset=utf-8' \
-d '{"host":"minulo.app","key":"<KEY>","keyLocation":"https://minulo.app/<KEY>.txt","urlList":["https://minulo.app/vi/<slug>.html"]}'
Cùng nội dung đó mình gửi thêm tới https://www.bing.com/indexnow và https://searchadvisor.naver.com/indexnow. Trả về 200 là đã nhận. Bing Webmaster chỉ cho gửi tay 100 URL mỗi ngày, nên khi cần báo nhiều URL một lúc thì mình dùng IndexNow. Ngày 24/10 mình đã gửi 398 URL theo cách này.
Cái gì đã biết, cái gì chưa
Phần kỹ thuật đã gọn hơn, nhưng như vậy chưa có nghĩa là trang sẽ được index. Ngày 23/10, Bing Webmaster cho thấy sitemap đã đưa 243 URL tới Bing, nhưng chỉ 3 URL được index và Bing chỉ đếm được 1 domain trỏ link về. Đợt kiểm tra cùng ngày không thấy lỗi kỹ thuật nào chặn crawler. Mình đoán nguyên nhân chính là domain còn mới và gần như chưa có link từ bên ngoài.
hreflang chỉ là phần nền để khi trang đã vào index, người đọc ở mỗi ngôn ngữ được đưa tới đúng bản của họ. Hiệu quả sau đó thì mình chưa đo được.
Tóm lại
- Nếu số trang mỗi ngôn ngữ khác nhau, mỗi trang sẽ có một bộ hreflang riêng.
- Lấy việc file có tồn tại làm chuẩn, không lấy JSON hay danh sách.
- Xoá hết rồi ghi lại cho mọi bản ngôn ngữ cùng lúc, thay vì thêm từng thẻ.
- Trang có tên file khác thì gom vào một bảng ánh xạ, và mọi bước đều đi qua bảng đó.
- x-default chỉ trỏ tới trang có thật.
<head>, footer và sitemap dựng từ cùng một danh sách.- Phần tóm tắt cần người viết (llms.txt) thì không cố tự động hoá.
Mình là người làm minulo. Website ở đây: https://minulo.app/vi/?ref=viblo-vi-hreflang-1025
All rights reserved