Vì sao sitemap của mình liệt kê 23 URL hai lần: một regex chỉ khớp đúng hai dấu cách
Mình là Ayumu Kitabayashi, người làm minulo, một app cho iPhone. Website của app là minulo.app, một trang tĩnh có 13 ngôn ngữ, chạy trên GitHub Pages. Mỗi lần thêm phiên bản ngôn ngữ mới cho một trang, mình chạy một script Python nhỏ là tools/sync_langs.py để viết lại thẻ hreflang và các mục sitemap của trang đó.
Ngày 2026-10-25, mình phát hiện script này đã để lại 23 URL bị liệt kê hai lần trong sitemap.xml. Nguyên nhân là hai dấu cách viết cứng ở đầu một biểu thức regex.
Script làm gì
Bạn truyền vào một hoặc nhiều slug, ví dụ minulo-vs-stillfeed.html. Với mỗi slug, script sẽ:
- kiểm tra những thư mục ngôn ngữ nào có file đó
- viết lại các thẻ
<link rel="alternate" hreflang>và các link chọn ngôn ngữ ở footer trên từng trang đó - xóa các mục
<url>của trang đó khỏi sitemap.xml rồi thêm mục mới, kèm các<xhtml:link>alternate, vào cuối file
Lỗi nằm ở bước 3. Trước khi sửa, đoạn xóa trông như thế này:
for lc in locs:
sm=re.sub(r' <url><loc>'+re.escape(lc)+r'</loc>.*?</url>\n','',sm,flags=re.S)
Những mục không xóa được
Pattern này chỉ khớp với <url> bắt đầu bằng đúng hai dấu cách. Chính script ghi các mục với hai dấu cách thụt đầu dòng, nên nó luôn xóa được những gì nó tự viết ra.
Nhưng sitemap.xml còn có những mục cũ mình viết tay từ trước khi có script, và những mục đó không thụt đầu dòng chút nào. Ở phiên bản trước khi sửa, en/minulo-vs-stillfeed.html xuất hiện ở dòng 2023 và một lần nữa ở dòng 9603:
<url><loc>https://minulo.app/en/minulo-vs-stillfeed.html</loc><lastmod>2026-10-20</lastmod></url>
<url><loc>https://minulo.app/en/minulo-vs-stillfeed.html</loc><lastmod>2026-10-24</lastmod>
<xhtml:link rel="alternate" hreflang="ja" href="https://minulo.app/minulo-vs-stillfeed.html"/>
<xhtml:link rel="alternate" hreflang="en" href="https://minulo.app/en/minulo-vs-stillfeed.html"/>
...
Mục thứ nhất là mục cũ: không thụt đầu dòng, không có alternate, lastmod là 2026-10-20. Mục thứ hai là mục script thêm vào, có alternate. Mục cũ không bao giờ khớp regex nên vẫn nằm đó, còn mỗi lần chạy script lại thêm một mục mới ở cuối file.
Vì sao trùng lặp là vấn đề
Với cùng một <loc>, sitemap đưa ra hai câu trả lời khác nhau:
- hai giá trị lastmod, 2026-10-20 và 2026-10-24
- một mục có alternate hreflang, một mục không có
Site này khai báo các phiên bản ngôn ngữ bằng <xhtml:link> trong sitemap, ngoài phần head của trang. Công cụ tìm kiếm dùng mục nào trong hai mục là do công cụ tìm kiếm quyết định. Nếu nó lấy mục không có alternate, thông tin về các phiên bản ngôn ngữ của URL đó có thể không đến được.
Mình phát hiện ra bằng cách nào
Mình lấy ra tất cả <loc> rồi dùng uniq -d để tìm những giá trị xuất hiện hơn một lần:
grep -o '<loc>[^<]*</loc>' sitemap.xml | sort | uniq -d
Trước đó, mỗi lần thêm trang mình đều ghi lại số URL trong sitemap. Con số đó không cho thấy trùng lặp: nó chỉ tăng lên, y như khi có thêm trang mới.
Cách sửa
1. Chấp nhận mọi khoảng trắng ở đầu dòng
Hai dấu cách ở đầu được đổi thành [ \t]*, tức là không hoặc nhiều dấu cách hay tab. Giờ pattern khớp cả mục cũ không thụt đầu dòng lẫn mục mới có thụt. Dùng một thư viện XML để parse rồi dựng lại file cũng được. Mình giữ regex và chỉ đổi phần đầu.
2. Dùng ngày chạy script cho lastmod
Trong lúc đọc diff, mình còn thấy các mục được thêm vào có 2026-10-24 viết cứng trong f-string. Chạy script vào ngày khác thì vẫn ghi 2026-10-24. Giờ script dùng datetime.date.today().
-import os,re,sys
+import os,re,sys,datetime
+TODAY=datetime.date.today().isoformat()
@@
- sm=re.sub(r' <url><loc>'+re.escape(lc)+r'</loc>.*?</url>\n','',sm,flags=re.S)
+ sm=re.sub(r'[ \t]*<url><loc>'+re.escape(lc)+r'</loc>.*?</url>\n','',sm,flags=re.S)
@@
- add=''.join(f' <url><loc>{url(d,s)}</loc><lastmod>2026-10-24</lastmod>\n{alts} </url>\n' for _,d,_ in have)
+ add=''.join(f' <url><loc>{url(d,s)}</loc><lastmod>{TODAY}</lastmod>\n{alts} </url>\n' for _,d,_ in have)
3. Xóa một lần các mục trùng đã có trong file
Sửa regex chỉ dọn được một URL vào lần tiếp theo slug của nó được xử lý. Để dọn file hiện tại, mình chạy một script dùng một lần, giữ lại <url> cuối cùng cho mỗi <loc>. Script sync luôn thêm vào cuối file, nên lần xuất hiện cuối cùng là mục mới hơn.
import re, sys
p = sys.argv[1]
sm = open(p).read()
pat = re.compile(r'[ \t]*<url><loc>(.*?)</loc>.*?</url>\n', re.S)
blocks = list(pat.finditer(sm))
last = {m.group(1): i for i, m in enumerate(blocks)}
drop = [m for i, m in enumerate(blocks) if last[m.group(1)] != i]
for m in reversed(drop):
sm = sm[:m.start()] + sm[m.end():]
open(p, 'w').write(sm)
print("removed", len(drop))
Kiểm tra kết quả
$ grep -c '<loc>' sitemap.xml
899
$ grep -o '<loc>[^<]*</loc>' sitemap.xml | sort | uniq -d | wc -l
0
$ python3 -c "import xml.etree.ElementTree as ET; print(len(ET.parse('sitemap.xml').getroot()))"
899
899 URL, 0 mục trùng, và file parse được như XML bình thường.
Rút ra
- Trước khi xóa dòng bằng regex, hãy kiểm tra xem file có dòng nào được viết theo định dạng cũ hơn không.
- Sau khi viết lại file, hãy kiểm tra giá trị
<loc>trùng, đừng chỉ nhìn tổng số. - Đừng viết cứng ngày tháng trong file được sinh tự động.
minulo là app cho iPhone giúp ẩn Reels của Instagram và Shorts của YouTube: https://minulo.app/vi/?ref=viblo-vi-sitemap-1025d
All Rights Reserved