Kiểm tra ngoại tuyến tệp robots.txt và XML sitemap của trang web theo đúng cách Google đọc chúng. Chọn nhóm trình thu thập dữ liệu (crawler), phân giải quy tắc Allow và Disallow có độ dài khớp lớn nhất (longest-match) kèm ký tự đại diện (wildcards), đồng thời kiểm thử các URL bắt buộc phải cho phép thu thập hoặc bị chặn. Cảnh báo lỗi chặn toàn bộ trang web, các URL trong sitemap bị robots.txt chặn, chặn tệp CSS và JS, các dòng noindex và crawl-delay không được hỗ trợ, cũng như các mục nhập sitemap nằm ngoài trang (off-site), trùng lặp hoặc không hợp lệ. Bao gồm một công cụ kiểm tra bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.
---
name: robots-txt-and-sitemap-checker
description: Kiểm tra ngoại tuyến tệp robots.txt và XML sitemap của trang web theo đúng cách Google đọc chúng. Chọn nhóm trình thu thập dữ liệu (crawler), phân giải quy tắc Allow và Disallow có độ dài khớp lớn nhất (longest-match) kèm ký tự đại diện (wildcards), đồng thời kiểm thử các URL bắt buộc phải cho phép thu thập hoặc bị chặn. Cảnh báo lỗi chặn toàn bộ trang web, các URL trong sitemap bị robots.txt chặn, chặn tệp CSS và JS, các dòng noindex và crawl-delay không được hỗ trợ, cũng như các mục nhập sitemap nằm ngoài trang (off-site), trùng lặp hoặc không hợp lệ. Bao gồm một công cụ kiểm tra bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.
---
# Trình Kiểm tra Robots.txt và Sitemap
Bạn giúp chủ sở hữu trang web, chuyên gia SEO và nhà phát triển web đảm bảo các công cụ tìm kiếm có thể thu thập dữ liệu (crawl) những trang quan trọng và bỏ qua những trang không cần thiết. Từ tệp robots.txt, XML sitemap và danh sách các URL quan trọng, bạn chỉ rõ quy tắc nào quyết định trạng thái của từng URL, tìm ra những mâu thuẫn giữa robots.txt và sitemap, giải thích từng vấn đề bằng ngôn ngữ dễ hiểu, đồng thời đề xuất bản robots.txt đã sửa lỗi cùng các chỉnh sửa cho sitemap.
## Các tệp trong kỹ năng này
- `scripts/crawl_check.py` - phân tích cú pháp robots.txt và một sitemap, quyết định trạng thái cho phép (allowed) hay chặn (blocked) trên từng URL giống hệt Googlebot, đồng thời in ra bảng URL cùng các phát hiện lỗi (chỉ dùng thư viện chuẩn của Python 3, không dùng mạng)
- `references/robots-txt-rules.md` - các nhóm (groups), quy tắc khớp dài nhất (longest-match) và ký tự đại diện (wildcard) kèm ví dụ, các chỉ thị được hỗ trợ, những sai lầm phổ biến và cách kiểm thử
- `references/sitemap-rules-and-fixes.md` - định dạng và giới hạn của sitemap, những gì nên liệt kê, thẻ lastmod, và cách khắc phục cho từng phát hiện lỗi
- `templates/crawl-audit-report.md` - mẫu báo cáo kiểm toán thu thập dữ liệu kèm bản đề xuất robots.txt để bàn giao cho nhà phát triển
- `examples/outdoor-shop-crawl-check.json` - một cửa hàng đồ dã ngoại sau khi ra mắt lại giao diện: phần blog và tệp JS vô tình bị chặn, có một dòng noindex, đường dẫn quản trị (admin) bị bỏ ngỏ, cùng một sitemap chứa các URL bị chặn, nằm ngoài tên miền (off-site), bị trùng lặp và ghi sai định dạng ngày tháng
## Quy trình làm việc
### 1. Thu thập các tệp
Hãy yêu cầu tệp robots.txt đang chạy thực tế (`https://<host>/robots.txt`), tệp sitemap hoặc sitemap index, địa chỉ gốc chuẩn tắc (canonical origin gồm giao thức scheme và tên miền host), cùng hai danh sách ngắn: các URL bắt buộc phải thu thập được (trang chủ, danh mục, sản phẩm hoặc bài viết hàng đầu, các gói CSS/JS) và các URL cần phải chặn (giỏ hàng, thanh toán, tìm kiếm nội bộ, bộ lọc, trang quản trị admin). Hỏi xem trình thu thập dữ liệu (crawler) nào là quan trọng nhất đối với họ (mặc định là Googlebot).
### 2. Chạy công cụ kiểm tra
```
python3 scripts/crawl_check.py examples/outdoor-shop-crawl-check.json --today 2026-10-23
python3 scripts/crawl_check.py --robots robots.txt --sitemap sitemap.xml --site https://www.example.com --allow /product/x --block /cart/
python3 scripts/crawl_check.py --self-test
```
Một tệp JSON đóng gói sẽ chứa thông tin trang web, tên crawler, các dòng trong robots.txt và sitemap cùng các danh sách URL. Mã thoát (exit code) bằng 1 nghĩa là có phát hiện lỗi mức CRITICAL hoặc HIGH, bằng 2 nghĩa là dữ liệu đầu vào không hợp lệ.
### 3. Giải thích và khắc phục
Bắt đầu với các lỗi mức CRITICAL và HIGH: chặn toàn bộ trang web, chặn các trang quan trọng, các URL có trong sitemap nhưng bị robots.txt chặn, các mục nhập trong sitemap trỏ ra ngoài tên miền (off-site). Với mỗi lỗi, hãy nêu rõ dòng quy tắc quyết định và đưa ra quy tắc đã sửa lại cho đúng. Giải thích rằng robots.txt không xóa các trang khỏi kết quả tìm kiếm và cũng không bảo vệ các trang riêng tư (`references/robots-txt-rules.md`), đồng thời chỉ dẫn tới bảng cách khắc phục trong `references/sitemap-rules-and-fixes.md`.
### 4. Bàn giao kết quả
Điền vào mẫu `templates/crawl-audit-report.md` với bảng kiểm tra URL, các phát hiện lỗi, toàn bộ nội dung tệp robots.txt hoàn chỉnh đã sửa và các thay đổi cần thiết cho bộ tạo sitemap (sitemap generator), sau đó chạy lại công cụ kiểm tra trên các tệp đã sửa để xác nhận.
## Ví dụ kết quả
Đối với tệp `examples/outdoor-shop-crawl-check.json`, trạng thái đánh giá là HIGH: chỉ thị `Disallow: /blog/` (dòng 9) đã chặn đường dẫn /blog/trail-running-shoes-review và trang /blog/spring-sale-recap đang được liệt kê trong sitemap, `Disallow: /assets/js/` chặn gói bundle của ứng dụng, và đường dẫn http://trailgear.example/backpacks/ trong sitemap bị tính là khác nguồn (off-site). Dòng `Noindex: /search/` bị Google bỏ qua, vì vậy /search/ và /admin/ vẫn có thể bị thu thập dữ liệu; ngoài ra sitemap còn có một đường dẫn /tents/ bị trùng lặp, một thẻ lastmod ghi năm 2027 và một thẻ ghi theo định dạng `18.10.2026`.
## Quy tắc
- Luôn nêu chính xác số dòng trong tệp robots.txt quyết định trạng thái của một URL, cùng với tên crawler đã được dùng để đánh giá.
- Tuyệt đối không bao giờ khuyên dùng robots.txt để ẩn các trang riêng tư hoặc nhạy cảm; hãy yêu cầu dùng cơ chế xác thực đăng nhập (authentication), và dùng thẻ `noindex` cho những trang cần loại khỏi kết quả tìm kiếm.
- Luôn cung cấp một tệp robots.txt hoàn chỉnh đã sửa lỗi chứ không chỉ đưa ra các đoạn trích rời rạc, đồng thời giữ nguyên các quy tắc mà chủ sở hữu trang web đã chủ đích thêm vào (ví dụ như chặn AhrefsBot).
- Công cụ kiểm tra không trực tiếp tải các URL qua mạng; hãy nói rõ rằng việc kiểm tra mã trạng thái HTTP (status codes), chuyển hướng (redirects), thẻ canonical và thẻ noindex cần phải thực hiện bằng trình thu thập thực tế hoặc qua Search Console.
FILE:references/robots-txt-rules.md
# Các quy tắc robots.txt (RFC 9309 và Google)
## Vị trí và bản chất
- Nằm ở thư mục gốc của từng tên miền (host) và giao thức (protocol): `https://www.example.com/robots.txt` chỉ có hiệu lực cho đúng tên miền và giao thức đó. Các biến thể `example.com`, `shop.example.com` và `http://` đều cần có tệp riêng của mình.
- Định dạng văn bản thuần túy (plain text), bảng mã UTF-8. Google chỉ đọc 500 KiB đầu tiên. Mã lỗi 404 đồng nghĩa với "cho phép thu thập mọi thứ"; mã lỗi máy chủ 5xx kéo dài có thể khiến Google ngừng thu thập dữ liệu toàn bộ trang web.
- Tệp robots.txt kiểm soát việc **thu thập dữ liệu (crawling)**, chứ không kiểm soát việc **lập chỉ mục (indexing)**. Một URL bị chặn vẫn có thể xuất hiện trên kết quả tìm kiếm (nhưng không có đoạn trích mô tả snippet) nếu có các trang khác đặt liên kết trỏ tới nó. Để giữ một trang không xuất hiện trên kết quả tìm kiếm, hãy cho phép thu thập dữ liệu và trả về chỉ thị `noindex`; để giữ trang ở chế độ riêng tư, hãy yêu cầu đăng nhập.
## Các nhóm (Groups)
```
User-agent: Googlebot
User-agent: Bingbot
Disallow: /tmp/
User-agent: *
Disallow: /cart/
```
- Các dòng `User-agent` nằm liền kề nhau sẽ dùng chung một nhóm. Các quy tắc bên dưới thuộc về nhóm ngay phía trên chúng.
- Một trình thu thập dữ liệu chỉ sử dụng **duy nhất một** nhóm: nhóm có tên khớp cụ thể nhất (`googlebot-image` được ưu tiên hơn `googlebot`), nếu không có mới dùng nhóm `*`. Các quy tắc từ nhóm `*` không được cộng gộp vào nhóm có tên cụ thể, vì vậy một lỗi rất phổ biến là tạo nhóm `Googlebot` riêng nhưng lại quên lặp lại các quy tắc chặn cơ bản đã viết trong nhóm `*`.
- Các nhóm có cùng tên sẽ được gộp lại với nhau.
## Cơ chế so khớp (Matching)
- Đường dẫn có phân biệt chữ hoa chữ thường (case-sensitive) và được so khớp tính từ đầu đường dẫn (bao gồm cả chuỗi truy vấn query string).
- Ký tự `*` khớp với bất kỳ chuỗi ký tự nào; ký tự `$` neo cố định điểm kết thúc của URL.
- Quy tắc khớp có **độ dài lớn nhất** (longest match) sẽ chiến thắng; nếu có độ dài bằng nhau thì chỉ thị `Allow` sẽ chiến thắng. Nếu không có quy tắc nào khớp thì mặc định là được phép thu thập (allowed).
- Chỉ thị `Disallow:` để trống giá trị đồng nghĩa với việc cho phép tất cả.
| Quy tắc | Chặn | Không chặn |
|---|---|---|
| `Disallow: /shop` | /shop, /shop/, /shopping | /Shop |
| `Disallow: /shop/` | /shop/shoes | /shop |
| `Disallow: /*?sort=` | /tents/?sort=price | /tents/?color=red&sort=price (cần dùng `/*sort=`) |
| `Disallow: /*.pdf$` | /docs/a.pdf | /docs/a.pdf?v=2 |
| `Disallow: /blog/` + `Allow: /blog/guides/` | /blog/news | /blog/guides/x |
## Các chỉ thị (Directives)
| Chỉ thị | Google hỗ trợ | Ghi chú |
|---|---|---|
| user-agent, allow, disallow | có | các quy tắc cốt lõi |
| sitemap | có | URL tuyệt đối, không giới hạn số lượng, đặt bên ngoài các nhóm |
| crawl-delay | không | Bing và Yandex có tuân thủ; đối với Google, hãy giảm tốc độ thu thập thông qua mã phản hồi từ máy chủ (429/503) |
| noindex, nofollow | không (đã ngừng hỗ trợ từ 2019) | hãy dùng thẻ `<meta name="robots" content="noindex">` hoặc tiêu đề HTTP `X-Robots-Tag` |
| host, clean-param | không | chỉ dành cho Yandex |
## Những sai lầm phổ biến
- Dòng `Disallow: /` còn sót lại từ môi trường thử nghiệm (staging) sau khi trang web chính thức ra mắt (khiến toàn bộ trang web biến mất khỏi luồng thu thập dữ liệu).
- Chặn các thư mục chứa CSS, JS hoặc hình ảnh: Google cần dựng hình (render) trang web và bắt buộc phải tải các tệp này; việc chặn tài nguyên có thể khiến trang web trông như bị lỗi giao diện hoặc bị đánh giá là không thân thiện với thiết bị di động.
- Chặn một trang đang gắn thẻ `noindex`: trình thu thập dữ liệu không bao giờ vào được trang để đọc thấy thẻ noindex, dẫn đến việc URL đó vẫn tiếp tục nằm lại trong chỉ mục tìm kiếm.
- Dùng robots.txt để giấu trang quản trị (admin) hoặc các trang riêng tư: tệp robots.txt hoàn toàn công khai và vô tình liệt kê chính xác vị trí của các trang nhạy cảm đó cho mọi người thấy.
- Để ngỏ các URL phân mảnh theo bộ lọc (sắp xếp, lọc thuộc tính, mã phiên session id), gây lãng phí ngân sách thu thập dữ liệu (crawl budget) vào vô số tổ hợp URL trùng lặp.
- Lỗi chính tả (ví dụ `Disalow`) sẽ bị lặng lẽ bỏ qua mà không có cảnh báo.
## Kiểm thử
- Google Search Console: báo cáo robots.txt (kiểm tra trạng thái tải tệp và các quy tắc đã phân tích) cùng công cụ Kiểm tra URL (URL Inspection - xem có bị chặn hay không).
- Trên máy cục bộ: `python3 scripts/crawl_check.py --robots robots.txt --site https://www.example.com --allow /product/x --block /cart/`.
FILE:references/sitemap-rules-and-fixes.md
# Các quy tắc và cách khắc phục lỗi XML Sitemap
## Định dạng
```xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://www.example.com/tents/ultralight-2p</loc>
<lastmod>2026-10-18T09:30:00+03:00</lastmod>
</url>
</urlset>
```
- Tối đa **50,000 URLs** và dung lượng khi chưa nén tối đa **50 MB** trên mỗi tệp. Các trang web lớn hơn cần sử dụng tệp chỉ mục sơ đồ trang web (sitemap index dùng thẻ `<sitemapindex>` chứa các mục `<sitemap><loc>`), cho phép quản lý tối đa 50,000 tệp sitemap con.
- Bảng mã UTF-8, bắt buộc mã hóa thực thể (entity-escape) cho các ký tự `&`, `<`, `>`, `'`, `"` nằm bên trong thẻ `<loc>`.
- Giá trị trong `<loc>` phải là đường dẫn tuyệt đối, sử dụng cùng giao thức (protocol) và tên miền (host) với chính tệp sitemap đó (trừ khi được gửi chéo thông qua Search Console cho một tên miền đã xác minh quyền sở hữu).
## Những gì cần đưa vào danh sách
- Chỉ liệt kê các URL chuẩn tắc (canonical), có thể lập chỉ mục và trả về mã 200: không đưa vào các đường dẫn chuyển hướng (redirects), lỗi 404, các trang gắn `noindex`, các URL bị chặn bởi robots.txt, hoặc các URL trùng lặp do tham số.
- Phải khớp chính xác dạng thức chuẩn tắc: cùng giao thức (https), cùng tên miền (có www hoặc không có www), cùng quy ước dấu gạch chéo cuối câu (trailing-slash) giống hệt như trong thẻ `rel=canonical`.
- Mỗi URL chỉ xuất hiện duy nhất một lần.
## Thẻ lastmod
- Định dạng ngày giờ theo chuẩn W3C datetime: `2026-10-18`, `2026-10-18T09:30+03:00` hoặc `2026-10-18T06:30:00Z`. Các định dạng như `18.10.2026` hay `10/18/2026` đều không hợp lệ.
- Hãy đặt giá trị này theo thời điểm có thay đổi nội dung **thực sự có ý nghĩa** lần cuối cùng, chứ không phải thời điểm chạy tiến trình xuất bản (build time). Google chỉ sử dụng `lastmod` khi thấy giá trị này chính xác một cách nhất quán; việc để ngày tháng ở tương lai hoặc cập nhật lại ngày mới cho mọi URL mỗi ngày sẽ khiến Google học cách phớt lờ hoàn toàn trường thông tin này.
- Hai thẻ `<changefreq>` và `<priority>` bị Google bỏ qua hoàn toàn; chúng không gây hại gì nhưng không đáng tốn công duy trì.
## Cách thông báo sitemap cho các trình thu thập dữ liệu
- Khai báo dòng `Sitemap: https://www.example.com/sitemap.xml` trong tệp robots.txt (dùng URL tuyệt đối).
- Gửi trực tiếp trong Google Search Console và Bing Webmaster Tools, sau đó theo dõi số lượng trang "Đã phát hiện" (Discovered) và "Đã lập chỉ mục" (Indexed) cùng các thông báo lỗi.
## Cách khắc phục theo từng mã phát hiện lỗi
| Mã phát hiện | Cách khắc phục |
|---|---|
| SITEMAP_URL_BLOCKED | Hãy quyết định rõ: nếu trang đó cần được xếp hạng tìm kiếm, hãy gỡ bỏ dòng Disallow; nếu không cần, hãy xóa nó khỏi tệp sitemap |
| SITEMAP_OFF_SITE | Viết lại giá trị `<loc>` theo đúng giao thức và tên miền chuẩn tắc; kiểm tra lại thiết lập URL gốc (base URL) trong công cụ tạo sitemap |
| SITEMAP_DUPLICATE | Khử trùng lặp ngay trong công cụ tạo sitemap (thường do cùng một trang bị xuất ra ở cả danh sách danh mục và danh sách thẻ tag) |
| LASTMOD_INVALID / LASTMOD_FUTURE | Xuất định dạng chuẩn ISO 8601 lấy từ trường thời gian cập nhật thực tế của nội dung; kiểm tra lại múi giờ và đồng hồ của máy chủ |
| SITEMAP_TOO_BIG | Chia nhỏ theo chuyên mục hoặc theo mốc thời gian thành nhiều tệp con nằm dưới một tệp sitemap index duy nhất |
| SITEMAP_PARSE_ERROR | Mã hóa ký tự `&` trong các URL, xóa ký tự BOM ở đầu tệp hoặc khắc phục lỗi máy chủ trả về trang báo lỗi HTML thay vì tệp XML, kiểm tra lại khai báo namespace |
## Những yếu tố nằm ngoài phạm vi công cụ này
Tập lệnh hoạt động ngoại tuyến trên các tệp bạn cung cấp. Bạn cần kiểm tra thêm bằng trình thu thập dữ liệu thực tế hoặc Search Console đối với: mã trạng thái HTTP thực tế của các URL trong danh sách, chuỗi chuyển hướng (redirects), thẻ canonical, thẻ noindex và các liên kết ngôn ngữ thay thế hreflang.
FILE:templates/crawl-audit-report.md
# Báo cáo Kiểm toán Thu thập Dữ liệu (Crawl Audit) - <trang web> - <ngày>
**Đã kiểm tra:** robots.txt (<tải ngày / phiên bản>), <URL của sitemap> **Trình thu thập:** <Googlebot> **Người đánh giá:** <tên>
## Tóm tắt
- Trạng thái: <CRITICAL / HIGH / OK>
- robots.txt: <n> nhóm, <n> quy tắc áp dụng cho <crawler>
- Sitemap: <n> URL, <n> bị chặn, <n> khác tên miền (off-site), <n> bị trùng lặp
## Các URL quan trọng
| URL | Trạng thái mong muốn | Kết quả thực tế | Quy tắc quyết định | Cách khắc phục |
|---|---|---|---|---|
| | crawlable | | | |
| | blocked | | | |
## Các phát hiện lỗi và cách khắc phục
| Mức độ nghiêm trọng | Phát hiện | Chi tiết | Cách khắc phục | Người phụ trách |
|---|---|---|---|---|
| | | | | |
## Đề xuất tệp robots.txt mới
```
User-agent: *
Disallow: ...
Allow: ...
Sitemap: https://...
```
## Các bước theo dõi tiếp theo
- Triển khai tệp robots.txt và sitemap mới vào ngày <ngày>; kiểm thử lại trong Search Console (báo cáo robots.txt, công cụ Kiểm tra URL)
- Kiểm tra lại độ phủ chỉ mục (coverage) và thống kê thu thập dữ liệu (crawl stats) vào ngày <ngày>
FILE:examples/outdoor-shop-crawl-check.json
{
"site": "https://www.trailgear.example",
"user_agent": "Googlebot",
"robots_txt": [
"# robots.txt for www.trailgear.example (after the October relaunch)",
"User-agent: *",
"Disallow: /cart/",
"Disallow: /checkout/",
"Disallow: /account/",
"Disallow: /*?sort=",
"Disallow: /*?sessionid=",
"Disallow: /assets/js/",
"Disallow: /blog/",
"Allow: /blog/guides/",
"Crawl-delay: 10",
"Noindex: /search/",
"",
"User-agent: AhrefsBot",
"Disallow: /",
"",
"Sitemap: https://www.trailgear.example/sitemap.xml"
],
"sitemap_xml": [
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
"<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">",
" <url><loc>https://www.trailgear.example/</loc><lastmod>2026-10-20</lastmod></url>",
" <url><loc>https://www.trailgear.example/tents/</loc><lastmod>2026-10-18</lastmod></url>",
" <url><loc>https://www.trailgear.example/tents/ultralight-2p</loc><lastmod>2026-10-18T09:30:00+03:00</lastmod></url>",
" <url><loc>https://www.trailgear.example/tents/</loc><lastmod>2026-10-18</lastmod></url>",
" <url><loc>https://www.trailgear.example/blog/spring-sale-recap</loc><lastmod>2026-04-02</lastmod></url>",
" <url><loc>https://www.trailgear.example/blog/guides/layering-for-hiking</loc><lastmod>2026-09-12</lastmod></url>",
" <url><loc>http://trailgear.example/backpacks/</loc><lastmod>2026-10-01</lastmod></url>",
" <url><loc>https://www.trailgear.example/sleeping-bags/</loc><lastmod>18.10.2026</lastmod></url>",
" <url><loc>https://www.trailgear.example/stoves/</loc><lastmod>2027-03-01</lastmod></url>",
"</urlset>"
],
"must_crawl": [
"/tents/ultralight-2p",
"/blog/trail-running-shoes-review",
"/blog/guides/layering-for-hiking",
"/assets/js/app.js",
"/tents/?color=green"
],
"must_block": [
"/checkout/payment",
"/tents/?sort=price",
"/search/?q=stove",
"/admin/"
]
}
FILE:scripts/crawl_check.py
#!/usr/bin/env python3
"""robots.txt and XML sitemap checker (Python 3 standard library only, works offline).
Input, either:
a JSON bundle {"site": "https://www.example.com", "user_agent": "Googlebot",
"robots_txt": [lines] or "text", "sitemap_xml": [lines] or "text",
"must_crawl": [urls or paths], "must_block": [urls or paths]}
or files --robots robots.txt [--sitemap sitemap.xml] --site https://... [--allow URL] [--block URL]
Matching follows RFC 9309 / Google: the group whose user-agent token matches the crawler name
(case-insensitive, most specific wins, else "*"), then the longest matching Allow/Disallow path
(* = any characters, $ = end of URL); on a tie Allow wins; no match = allowed.
Findings (severity):
CRITICAL BLOCK_ALL "Disallow: /" applies to the crawler with no Allow rules
HIGH MUST_CRAWL_BLOCKED a URL that must be crawlable is disallowed
HIGH SITEMAP_URL_BLOCKED a URL listed in the sitemap is disallowed
HIGH SITEMAP_PARSE_ERROR sitemap is not well-formed XML or not urlset/sitemapindex
HIGH SITEMAP_TOO_BIG more than 50,000 URLs in one sitemap
HIGH SITEMAP_OFF_SITE a sitemap URL is on another host or scheme than the site
MEDIUM MUST_BLOCK_CRAWLABLE a URL that should be blocked is crawlable (robots.txt is not access control)
MEDIUM ASSETS_BLOCKED CSS, JS or image paths disallowed (pages cannot render for the crawler)
MEDIUM UNSUPPORTED_RULE noindex / nofollow / host lines (ignored by Google)
MEDIUM SITEMAP_DUPLICATE the same <loc> appears more than once
MEDIUM LASTMOD_FUTURE <lastmod> after --today
LOW CRAWL_DELAY_IGNORED crawl-delay (Googlebot ignores it; Bing and Yandex honour it)
LOW RULE_OUTSIDE_GROUP allow/disallow before any user-agent line
LOW UNKNOWN_DIRECTIVE unknown line or missing colon
LOW SITEMAP_RELATIVE Sitemap: line is not an absolute URL
LOW NO_SITEMAP_LINE robots.txt has no Sitemap: line
LOW LASTMOD_INVALID <lastmod> is not W3C datetime (YYYY-MM-DD[Thh:mm[:ss]TZD])
Usage:
python3 crawl_check.py bundle.json [--today 2026-10-23] [--json]
python3 crawl_check.py --robots robots.txt --sitemap sitemap.xml --site https://www.example.com --allow /shop/
python3 crawl_check.py --self-test
Exit code: 0 no CRITICAL/HIGH, 1 CRITICAL or HIGH findings, 2 bad input.
"""
import argparse
import datetime as dt
import json
import re
import sys
import urllib.parse
import xml.etree.ElementTree as ET
SEV_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
KNOWN = {"user-agent", "allow", "disallow", "sitemap", "crawl-delay", "noindex", "nofollow", "host", "clean-param"}
ASSET_RE = re.compile(r"(\.css|\.js|\.png|\.jpe?g|\.webp|\.svg|\.gif|/static/|/assets/|/_next/|/wp-includes/|/css/|/js/)", re.I)
LASTMOD_RE = re.compile(r"^\d{4}(-\d{2}(-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2}))?)?)?$")
NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
class DataError(ValueError):
pass
def text_of(v):
if v is None:
return None
return "\n".join(v) if isinstance(v, list) else str(v)
def parse_robots(text):
"""-> (groups [{agents, rules[(kind, path, line)]}], sitemaps [(url, line)], issues [(sev, code, msg)])"""
groups, sitemaps, issues = [], [], []
cur, last_was_agent = None, False
for n, raw in enumerate(text.splitlines(), start=1):
line = raw.split("#", 1)[0].strip()
if not line:
continue
if ":" not in line:
issues.append(("LOW", "UNKNOWN_DIRECTIVE", f"line {n}: no colon in {line!r}"))
continue
key, val = (x.strip() for x in line.split(":", 1))
key = key.lower()
if key == "user-agent":
if not last_was_agent or cur is None:
cur = {"agents": [], "rules": []}
groups.append(cur)
cur["agents"].append(val.lower())
last_was_agent = True
continue
last_was_agent = False
if key in ("allow", "disallow"):
if cur is None:
issues.append(("LOW", "RULE_OUTSIDE_GROUP", f"line {n}: {key} before any user-agent line is ignored"))
elif val:
cur["rules"].append((key, val, n))
elif key == "sitemap":
sitemaps.append((val, n))
if not re.match(r"^https?://", val, re.I):
issues.append(("LOW", "SITEMAP_RELATIVE", f"line {n}: Sitemap must be an absolute URL, got {val!r}"))
elif key == "crawl-delay":
issues.append(("LOW", "CRAWL_DELAY_IGNORED", f"line {n}: crawl-delay {val} is ignored by Googlebot"))
elif key in ("noindex", "nofollow", "host", "clean-param"):
issues.append(("MEDIUM", "UNSUPPORTED_RULE", f"line {n}: '{key}' in robots.txt is not supported by Google; use meta robots / X-Robots-Tag or redirects"))
else:
issues.append(("LOW", "UNKNOWN_DIRECTIVE", f"line {n}: unknown directive {key!r}"))
return groups, sitemaps, issues
def pick_group(groups, agent):
"""Rules for the crawler: merge all groups with the longest matching agent token, else '*'."""
a = agent.lower()
best, rules = -1, []
for g in groups:
for tok in g["agents"]:
if tok != "*" and tok and a.startswith(tok) and len(tok) > best:
best, rules = len(tok), list(g["rules"])
elif tok != "*" and tok and a.startswith(tok) and len(tok) == best:
rules += g["rules"]
if best >= 0:
return rules
return [r for g in groups if "*" in g["agents"] for r in g["rules"]]
def pattern_to_re(path):
end = path.endswith("$")
body = path[:-1] if end else path
return re.compile("^" + ".*".join(re.escape(p) for p in body.split("*")) + ("$" if end else ""))
def path_of(url, site):
u = urllib.parse.urlsplit(urllib.parse.urljoin(site + "/", url))
return (u.path or "/") + (("?" + u.query) if u.query else "")
def decide(rules, path):
"""-> (allowed, matching rule or None)"""
best = None
for kind, pat, n in rules:
if pattern_to_re(pat).match(path):
key = (len(pat), kind == "allow")
if best is None or key > best[0]:
best = (key, (kind, pat, n))
if best is None:
return True, None
return best[1][0] == "allow", best[1]
def parse_sitemap(text):
try:
root = ET.fromstring(text.strip())
except ET.ParseError as e:
raise DataError(f"sitemap XML error: {e}")
tag = root.tag.replace(NS, "")
if tag not in ("urlset", "sitemapindex"):
raise DataError(f"sitemap root must be urlset or sitemapindex, got {tag!r}")
child = "url" if tag == "urlset" else "sitemap"
items = []
for el in root.findall(NS + child) or root.findall(child):
loc = el.findtext(NS + "loc") or el.findtext("loc") or ""
lm = el.findtext(NS + "lastmod") or el.findtext("lastmod")
items.append((loc.strip(), lm.strip() if lm else None))
return tag, items
def check(bundle, today=None):
site = (bundle.get("site") or "").rstrip("/")
if not re.match(r"^https?://[^/]+$", site):
raise DataError("site must be an origin like https://www.example.com")
robots = text_of(bundle.get("robots_txt"))
if robots is None:
raise DataError("robots_txt is required")
agent = bundle.get("user_agent") or "Googlebot"
today = today or dt.date.today()
groups, sitemaps, issues = parse_robots(robots)
rules = pick_group(groups, agent)
findings = [{"severity": s, "code": c, "message": m} for s, c, m in issues]
def f(sev, code, msg):
findings.append({"severity": sev, "code": code, "message": msg})
if any(k == "disallow" and p == "/" for k, p, _ in rules) and not any(k == "allow" for k, _, _ in rules):
f("CRITICAL", "BLOCK_ALL", f"'Disallow: /' blocks the whole site for {agent}")
for k, p, n in rules:
if k == "disallow" and ASSET_RE.search(p):
f("MEDIUM", "ASSETS_BLOCKED", f"line {n}: Disallow {p} blocks CSS/JS/images needed to render pages")
if not sitemaps:
f("LOW", "NO_SITEMAP_LINE", "robots.txt has no Sitemap: line")
url_checks = []
for kind, key in (("crawl", "must_crawl"), ("block", "must_block")):
for u in bundle.get(key) or []:
p = path_of(u, site)
allowed, rule = decide(rules, p)
url_checks.append({"url": u, "path": p, "expect": kind, "allowed": allowed,
"rule": f"line {rule[2]}: {rule[0].title()}: {rule[1]}" if rule else "no rule"})
if kind == "crawl" and not allowed:
f("HIGH", "MUST_CRAWL_BLOCKED", f"{p} is blocked by line {rule[2]} ({rule[0].title()}: {rule[1]})")
if kind == "block" and allowed:
f("MEDIUM", "MUST_BLOCK_CRAWLABLE", f"{p} is crawlable ({'no rule' if not rule else 'line %d Allow: %s' % (rule[2], rule[1])}); protect private pages with auth or noindex, not robots.txt")
sm_info = None
sm_text = text_of(bundle.get("sitemap_xml"))
if sm_text is not None:
try:
kind, items = parse_sitemap(sm_text)
except DataError as e:
f("HIGH", "SITEMAP_PARSE_ERROR", str(e))
kind, items = None, []
host = urllib.parse.urlsplit(site)
seen, blocked, off = set(), 0, 0
for loc, lm in items:
u = urllib.parse.urlsplit(loc)
if (u.scheme, u.netloc) != (host.scheme, host.netloc):
off += 1
f("HIGH", "SITEMAP_OFF_SITE", f"{loc} is not on {site}")
if loc in seen:
f("MEDIUM", "SITEMAP_DUPLICATE", f"{loc} listed more than once")
seen.add(loc)
if kind == "urlset":
allowed, rule = decide(rules, path_of(loc, site))
if not allowed:
blocked += 1
f("HIGH", "SITEMAP_URL_BLOCKED", f"{loc} is in the sitemap but blocked by line {rule[2]} ({rule[0].title()}: {rule[1]})")
if lm is not None:
if not LASTMOD_RE.match(lm):
f("LOW", "LASTMOD_INVALID", f"{loc}: lastmod {lm!r} is not W3C datetime")
elif dt.date.fromisoformat(lm[:10] if len(lm) >= 10 else lm[:4] + "-01-01") > today:
f("MEDIUM", "LASTMOD_FUTURE", f"{loc}: lastmod {lm} is after {today}")
if len(items) > 50000:
f("HIGH", "SITEMAP_TOO_BIG", f"{len(items)} URLs; split into files of at most 50,000 URLs and use a sitemap index")
sm_info = {"type": kind, "urls": len(items), "blocked": blocked, "off_site": off}
findings.sort(key=lambda x: SEV_ORDER[x["severity"]])
status = findings[0]["severity"] if findings and findings[0]["severity"] in ("CRITICAL", "HIGH") else "OK"
return {"site": site, "user_agent": agent, "groups": len(groups), "rules_for_agent": len(rules),
"sitemaps_declared": [s for s, _ in sitemaps], "sitemap": sm_info, "url_checks": url_checks,
"findings": findings, "status": status}
def report(r):
L = [f"# Crawl check for {r['site']} as {r['user_agent']}", "",
f"robots.txt: {r['groups']} groups, {r['rules_for_agent']} rules apply to {r['user_agent']}; "
f"Sitemap lines: {', '.join(r['sitemaps_declared']) or 'none'}"]
if r["sitemap"]:
s = r["sitemap"]
L.append(f"Sitemap ({s['type']}): {s['urls']} URLs, {s['blocked']} blocked by robots.txt, {s['off_site']} off-site")
if r["url_checks"]:
L += ["", "| URL | Expected | Result | Deciding rule |", "|---|---|---|---|"]
for c in r["url_checks"]:
res = "allowed" if c["allowed"] else "blocked"
good = (c["expect"] == "crawl") == c["allowed"]
L.append(f"| {c['path']} | {c['expect']} | {res}{'' if good else ' (WRONG)'} | {c['rule']} |")
L += ["", f"## Findings (status {r['status']})"]
L += [f"- {x['severity']} {x['code']}: {x['message']}" for x in r["findings"]] or ["- none"]
return "\n".join(L) + "\n"
def self_test():
cases = []
def ok(name, cond):
cases.append(bool(cond))
print(("PASS " if cond else "FAIL ") + name)
def codes(r):
return {x["code"] for x in r["findings"]}
R = lambda t: pick_group(parse_robots(t)[0], "Googlebot")
rules = R("User-agent: *\nDisallow: /shop/\nAllow: /shop/products/\n")
ok("longest match wins (allow deeper path)", decide(rules, "/shop/products/a")[0] and not decide(rules, "/shop/cart")[0])
ok("tie goes to allow", decide(R("User-agent: *\nDisallow: /p\nAllow: /p\n"), "/p")[0])
ok("no rule = allowed", decide(rules, "/blog/")[0])
wild = R("User-agent: *\nDisallow: /*?sort=\nDisallow: /*.pdf$\n")
ok("* wildcard matches query", not decide(wild, "/shop/shoes?sort=price")[0])
ok("$ anchors end", not decide(wild, "/files/a.pdf")[0] and decide(wild, "/files/a.pdf?x=1")[0])
ok("empty Disallow allows everything", decide(R("User-agent: *\nDisallow:\n"), "/x")[0])
spec = parse_robots("User-agent: *\nDisallow: /\n\nUser-agent: Googlebot\nDisallow: /private/\n")[0]
ok("specific group beats *", decide(pick_group(spec, "Googlebot"), "/shop")[0] and not decide(pick_group(spec, "Bingbot"), "/shop")[0])
ok("googlebot-image uses longest token", pick_group(parse_robots("User-agent: googlebot\nDisallow: /a\nUser-agent: googlebot-image\nDisallow: /b\n")[0], "Googlebot-Image")[0][1] == "/b")
multi = parse_robots("User-agent: a\nUser-agent: b\nDisallow: /x\n")[0]
ok("consecutive user-agents share a group", len(multi) == 1 and multi[0]["agents"] == ["a", "b"])
ok("comments stripped", R("User-agent: * # all\nDisallow: /tmp/ # temp\n")[0][1] == "/tmp/")
base = {"site": "https://www.shop.test", "robots_txt": "User-agent: *\nDisallow: /\n"}
r = check(base, dt.date(2026, 10, 23))
ok("block all is CRITICAL", "BLOCK_ALL" in codes(r) and r["status"] == "CRITICAL")
r = check({**base, "robots_txt": ["User-agent: *", "Disallow: /checkout/", "Sitemap: https://www.shop.test/sitemap.xml"],
"must_crawl": ["/checkout/thanks"], "must_block": ["/admin/"]}, dt.date(2026, 10, 23))
ok("must_crawl blocked is HIGH", "MUST_CRAWL_BLOCKED" in codes(r) and r["status"] == "HIGH")
ok("must_block crawlable is MEDIUM", "MUST_BLOCK_CRAWLABLE" in codes(r))
ok("list of lines accepted, sitemap line read", r["sitemaps_declared"] == ["https://www.shop.test/sitemap.xml"])
r = check({**base, "robots_txt": "Disallow: /x\nUser-agent: *\nNoindex: /tmp/\nCrawl-delay: 5\nDisalow: /y\nSitemap: /sitemap.xml\nDisallow: /assets/\n"}, dt.date(2026, 10, 23))
for c in ("RULE_OUTSIDE_GROUP", "UNSUPPORTED_RULE", "CRAWL_DELAY_IGNORED", "UNKNOWN_DIRECTIVE", "SITEMAP_RELATIVE", "ASSETS_BLOCKED"):
ok(c + " flagged", c in codes(r))
ok("missing sitemap line flagged", "NO_SITEMAP_LINE" in codes(check({**base, "robots_txt": "User-agent: *\nDisallow:\n"})))
sm = ('<?xml version="1.0"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
"<url><loc>https://www.shop.test/a</loc><lastmod>2026-10-01</lastmod></url>"
"<url><loc>https://www.shop.test/a</loc></url>"
"<url><loc>http://www.shop.test/b</loc><lastmod>01/10/2026</lastmod></url>"
"<url><loc>https://www.shop.test/cart/x</loc><lastmod>2030-01-01T00:00:00+00:00</lastmod></url></urlset>")
r = check({**base, "robots_txt": "User-agent: *\nDisallow: /cart/\nSitemap: https://www.shop.test/sitemap.xml\n", "sitemap_xml": sm}, dt.date(2026, 10, 23))
for c in ("SITEMAP_DUPLICATE", "SITEMAP_OFF_SITE", "LASTMOD_INVALID", "LASTMOD_FUTURE", "SITEMAP_URL_BLOCKED"):
ok(c + " flagged", c in codes(r))
ok("sitemap counts", r["sitemap"] == {"type": "urlset", "urls": 4, "blocked": 1, "off_site": 1})
ok("bad XML is HIGH SITEMAP_PARSE_ERROR", "SITEMAP_PARSE_ERROR" in codes(check({**base, "robots_txt": "", "sitemap_xml": "<urlset><url>"})))
idx = '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><sitemap><loc>https://www.shop.test/s1.xml</loc></sitemap></sitemapindex>'
ok("sitemap index read", check({**base, "robots_txt": "", "sitemap_xml": idx})["sitemap"]["type"] == "sitemapindex")
for bad, label in (({"robots_txt": ""}, "missing site"), ({"site": "https://x.test/path", "robots_txt": ""}, "site with path"),
({"site": "https://x.test"}, "missing robots_txt")):
try:
check(bad)
ok(label + " rejected", False)
except DataError:
ok(label + " rejected", True)
print(f"{sum(cases)}/{len(cases)} passed")
return 0 if all(cases) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Check robots.txt rules and an XML sitemap offline.")
ap.add_argument("bundle", nargs="?", help="JSON bundle (see module docstring)")
ap.add_argument("--robots", help="robots.txt file (instead of a bundle)")
ap.add_argument("--sitemap", help="sitemap.xml file")
ap.add_argument("--site", help="site origin, e.g. https://www.example.com")
ap.add_argument("--agent", help="crawler name (default Googlebot)")
ap.add_argument("--allow", action="append", default=[], help="URL or path that must be crawlable (repeatable)")
ap.add_argument("--block", action="append", default=[], help="URL or path that should be blocked (repeatable)")
ap.add_argument("--today", help="date for the future-lastmod check (default today)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
try:
if a.bundle:
b = json.load(open(a.bundle, encoding="utf-8"))
elif a.robots:
b = {"site": a.site, "robots_txt": open(a.robots, encoding="utf-8").read(),
"sitemap_xml": open(a.sitemap, encoding="utf-8").read() if a.sitemap else None,
"must_crawl": a.allow, "must_block": a.block}
else:
ap.error("give a JSON bundle, --robots FILE or --self-test")
if a.agent:
b["user_agent"] = a.agent
today = dt.date.fromisoformat(a.today) if a.today else None
r = check(b, today)
except (OSError, ValueError, DataError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(json.dumps(r, indent=2) if a.json else report(r), end="\n" if a.json else "")
return 1 if r["status"] in ("CRITICAL", "HIGH") else 0
if __name__ == "__main__":
sys.exit(main())