Files
ai-job-search/tools/robots_check.py
T
9bc71de5f8 fix(robots-check): decode curl output as UTF-8 so non-ASCII responses stop reading as UNCONFIRMED on Windows (#506)
_fetch() ran curl with text=True and no encoding, so Python decoded the
response with the Windows ANSI code page. On a byte that code page leaves
undefined (0x81 in cp1252 and cp1254, 0x9e in cp1254) subprocess's pipe
reader thread died, stdout came back None, rpartition() raised
AttributeError, and the gate printed "UNCONFIRMED (AttributeError)". That
fails closed, but it blocked the browser-header retry 09-web-research.md
permits on hosts such as tr.indeed.com (a UTF-8 CJK Disallow rule) and
kap.org.tr (a UTF-8 Turkish 404 page).

Pin encoding='utf-8', errors='replace' on the call (RFC 9309: robots.txt
is UTF-8), as verify_pdf.py and verify_layout.py already do. Decision
rules and the fail-closed handling of real read failures are unchanged.

The regression tests swap curl for a child process that prints the real
bytes, so the actual subprocess decode path runs with _fetch's own
kwargs, and pin cp1254 when no encoding is passed so a UTF-8 host cannot
pass the bug by luck. Both fail on master.

Co-authored-by: Can Mugan <217335573+canmugan@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:18:48 +02:00

146 lines
6.0 KiB
Python

#!/usr/bin/env python3
"""Decide whether the browser-header curl retry in 09-web-research.md may run.
The retry exists to get past bot-filtering firewalls on sites whose robots.txt
permits access. It is never used to override a site that has said no.
WebFetch identifies itself as Claude-User and honors robots.txt, so a 403 has
two very different causes: a WAF default on a site whose published policy
allows access, or a site that has actually declined. This tells them apart.
Rules implemented (RFC 9309), deliberately on the cautious side:
* longest-match wins; on equal specificity Disallow wins
* a Disallow for either "*" or "Claude-User" blocks the retry
* blank lines inside a record do not end it (Python's robotparser drops
rules in that case, which fails open - see tests)
* 404 means no published policy, which is permission
* any other failure to read robots.txt leaves permission unconfirmed,
and the retry does not happen
Usage: python3 tools/robots_check.py <url>
Exit 0 = the retry may proceed. Exit 1 = do not retry; go to escalation step 3.
"""
import re, subprocess, sys
from urllib.parse import urlsplit, unquote
BROWSER = ('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36')
def _fetch(url, ua):
"""curl, not urllib: some hosts (jobup.ch) hang urllib indefinitely while
answering curl in under a second, and --max-time is a hard ceiling."""
# "--" terminates option parsing, so a URL beginning with a dash can never
# be read by curl as a flag. gate() rebuilds the target as
# scheme://host/robots.txt before calling here, so this is hardening for
# direct callers rather than a hole in the gate path itself.
r = subprocess.run(
['curl', '-sS', '-L', '--max-redirs', '5', '--max-time', '12', '-A', ua,
'-H', 'Accept: text/plain,*/*', '-w', '\n%{http_code}', '--', url],
# robots.txt is UTF-8 (RFC 9309). Unpinned, Turkish Windows decoded it as
# cp1254, where bytes like 0x81 are undefined: the pipe reader thread died,
# stdout came back None, and tr.indeed.com read as UNCONFIRMED.
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=20)
if r.returncode != 0:
raise RuntimeError('curl exit %d' % r.returncode)
body, _, code = r.stdout.rpartition('\n')
return body, int(code or 0)
def is_robots_body(text):
"""Does this actually look like a robots.txt?
A misconfigured host can answer /robots.txt with 200 and an HTML error page.
That body parses to zero rules, and zero rules read as "allowed" - so a
soft-200 granted permission that was never given. An empty or whitespace-only
body IS a valid allow-all under RFC 9309 and stays allowed; a non-empty body
with no recognised directive is treated as unreadable.
"""
if not text.strip():
return True
for raw in text.splitlines():
line = raw.split('#', 1)[0].strip().lower()
if ':' in line and line.split(':', 1)[0].strip() in (
'user-agent', 'allow', 'disallow', 'sitemap', 'crawl-delay', 'host',
):
return True
return False
def _groups(text):
"""user-agent -> [(is_allow, pattern)], tolerating blank lines inside a record."""
out, agents, expect = {}, [], True
for raw in text.splitlines():
line = raw.split('#', 1)[0].strip()
if not line or ':' not in line:
continue
field, _, value = line.partition(':')
field, value = field.strip().lower(), value.strip()
if field == 'user-agent':
if not expect:
agents, expect = [], True
agents.append(value.lower())
out.setdefault(value.lower(), [])
elif field in ('allow', 'disallow') and agents:
expect = False
for a in agents:
out[a].append((field == 'allow', value))
return out
def _match(pattern, path):
"""RFC 9309 wildcard match; returns match length or -1.
The pattern is percent-decoded to match the already-decoded path. Without
this, "Disallow: /foo%20bar" never matched "/foo bar" and the rule was
silently skipped - a fail-open on any site that encodes its own rules.
"""
if pattern == '':
return -1
pattern = unquote(pattern)
rx = '^' + ''.join('.*' if c == '*' else ('$' if c == '$' else re.escape(c)) for c in pattern)
return len(pattern) if re.match(rx, path) else -1
def allowed(text, agent, path):
g = _groups(text)
rules = g.get(agent.lower()) or g.get('*') or []
best_len, best_allow = -1, True
for is_allow, pat in rules:
n = _match(pat, path)
if n > best_len or (n == best_len and n >= 0 and not is_allow):
best_len, best_allow = n, is_allow # ties -> Disallow wins (cautious)
return True if best_len < 0 else best_allow
def gate(url):
parts = urlsplit(url)
path = unquote(parts.path) or '/'
if parts.query:
path += '?' + parts.query
robots = f'{parts.scheme}://{parts.netloc}/robots.txt'
body, last = None, 'no attempt'
for ua in ('Claude-User', BROWSER):
try:
text, code = _fetch(robots, ua)
except Exception as e:
last = type(e).__name__; continue
if code == 404:
return 0, 'ALLOWED - no robots.txt published'
if code == 200:
if not is_robots_body(text):
last = 'HTTP 200 but the body is not a robots.txt'
continue
body = text; break
last = 'HTTP %d' % code
if body is None:
return 1, 'UNCONFIRMED (%s) - do not retry, go to step 3' % last
for a in ('Claude-User', '*'):
if not allowed(body, a, path):
return 1, f'DISALLOWED for {a} - do not retry, go to step 3'
return 0, 'ALLOWED - robots.txt permits this path'
if __name__ == '__main__':
if len(sys.argv) != 2:
print('usage: python3 tools/robots_check.py <url>', file=sys.stderr)
sys.exit(2)
rc, msg = gate(sys.argv[1])
print(msg)
sys.exit(rc)