mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 16:46:24 +00:00
108 lines
4.3 KiB
Python
108 lines
4.3 KiB
Python
"""Offline tests for tools/robots_check.py.
|
|||
|
|
|
||
|
|
No network: every case exercises the parser against literal robots.txt bodies,
|
||
|
|
matching the repo's CI policy of making no live portal requests.
|
||
|
|
|
||
|
|
The cases marked FAIL-OPEN REGRESSION are the ones Python's own
|
||
|
|
urllib.robotparser gets wrong. They are pinned here because getting them wrong
|
||
|
|
means the browser-header retry runs against a site that said no, which is the
|
||
|
|
exact boundary this tool exists to hold.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
import unittest
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||
|
|
sys.path.insert(0, str(REPO_ROOT / "tools"))
|
||
|
|
|
||
|
|
from robots_check import allowed # noqa: E402
|
||
|
|
|
||
|
|
|
||
|
|
# Real body served by privatebank.barclays.com: blank lines sit between the
|
||
|
|
# User-agent line and its rules. Python's robotparser treats those as record
|
||
|
|
# separators and drops every rule, so /cs/ reads as allowed.
|
||
|
|
BARCLAYS = "User-agent: *\n\n\nAllow: /\n\nDisallow: /cs/\n\nSitemap: https://x/sitemap.xml\n"
|
||
|
|
|
||
|
|
# jobup.ch: the case a community fork was asked to ship opt-in.
|
||
|
|
JOBUP = "User-agent: *\nDisallow: /api/\n"
|
||
|
|
|
||
|
|
|
||
|
|
class TestPathRules(unittest.TestCase):
|
||
|
|
def test_blank_lines_inside_record_do_not_end_it(self):
|
||
|
|
"""FAIL-OPEN REGRESSION: /cs/ is disallowed despite the blank lines."""
|
||
|
|
self.assertFalse(allowed(BARCLAYS, "*", "/cs/"))
|
||
|
|
|
||
|
|
def test_allowed_path_on_same_site_still_allowed(self):
|
||
|
|
self.assertTrue(allowed(BARCLAYS, "*", "/careers/"))
|
||
|
|
|
||
|
|
def test_longest_match_wins_over_rule_order(self):
|
||
|
|
"""FAIL-OPEN REGRESSION: 'Allow: /' precedes 'Disallow: /cs/' in the
|
||
|
|
file; specificity must win, not position."""
|
||
|
|
body = "User-agent: *\nAllow: /\nDisallow: /cs/\n"
|
||
|
|
self.assertFalse(allowed(body, "*", "/cs/deep/page"))
|
||
|
|
|
||
|
|
def test_longest_match_can_unblock(self):
|
||
|
|
body = "User-agent: *\nDisallow: /\nAllow: /jobs/\n"
|
||
|
|
self.assertTrue(allowed(body, "*", "/jobs/x"))
|
||
|
|
self.assertFalse(allowed(body, "*", "/other"))
|
||
|
|
|
||
|
|
def test_equal_specificity_tie_goes_to_disallow(self):
|
||
|
|
"""Cautious tie-break: Google resolves ties to Allow, we do not."""
|
||
|
|
self.assertFalse(allowed("User-agent: *\nDisallow: /a\nAllow: /a\n", "*", "/a"))
|
||
|
|
|
||
|
|
def test_api_block_and_sibling_path(self):
|
||
|
|
self.assertFalse(allowed(JOBUP, "*", "/api/v1/public/search"))
|
||
|
|
self.assertTrue(allowed(JOBUP, "*", "/en/jobs/"))
|
||
|
|
|
||
|
|
def test_wildcard_and_end_anchor(self):
|
||
|
|
body = "User-agent: *\nDisallow: /*.pdf$\n"
|
||
|
|
self.assertFalse(allowed(body, "*", "/files/cv.pdf"))
|
||
|
|
self.assertTrue(allowed(body, "*", "/files/cv.pdf.html"))
|
||
|
|
|
||
|
|
def test_empty_disallow_means_allow_everything(self):
|
||
|
|
self.assertTrue(allowed("User-agent: *\nDisallow:\n", "*", "/anything"))
|
||
|
|
|
||
|
|
def test_empty_or_ruleless_robots_allows(self):
|
||
|
|
self.assertTrue(allowed("", "*", "/x"))
|
||
|
|
self.assertTrue(allowed("# just a comment\n", "*", "/x"))
|
||
|
|
|
||
|
|
def test_comments_are_stripped(self):
|
||
|
|
self.assertFalse(allowed("User-agent: *\nDisallow: /x # nope\n", "*", "/x"))
|
||
|
|
|
||
|
|
|
||
|
|
class TestAgentSelection(unittest.TestCase):
|
||
|
|
def test_named_claude_user_opt_out_is_honored(self):
|
||
|
|
body = "User-agent: Claude-User\nDisallow: /\n\nUser-agent: *\nAllow: /\n"
|
||
|
|
self.assertFalse(allowed(body, "Claude-User", "/a"))
|
||
|
|
self.assertTrue(allowed(body, "*", "/a"))
|
||
|
|
|
||
|
|
def test_agent_match_is_case_insensitive(self):
|
||
|
|
body = "User-agent: CLAUDE-USER\nDisallow: /x\n"
|
||
|
|
self.assertFalse(allowed(body, "claude-user", "/x"))
|
||
|
|
|
||
|
|
def test_falls_back_to_star_when_agent_absent(self):
|
||
|
|
self.assertFalse(allowed(JOBUP, "Claude-User", "/api/v1"))
|
||
|
|
|
||
|
|
def test_multiple_agents_share_one_ruleset(self):
|
||
|
|
body = "User-agent: A\nUser-agent: Claude-User\nDisallow: /z\n"
|
||
|
|
self.assertFalse(allowed(body, "Claude-User", "/z"))
|
||
|
|
self.assertFalse(allowed(body, "A", "/z"))
|
||
|
|
|
||
|
|
|
||
|
|
class TestCli(unittest.TestCase):
|
||
|
|
def test_module_is_importable_and_cli_exists(self):
|
||
|
|
"""The doc calls this by path; make sure that entry point stays valid."""
|
||
|
|
script = REPO_ROOT / "tools" / "robots_check.py"
|
||
|
|
self.assertTrue(script.is_file())
|
||
|
|
out = subprocess.run(
|
||
|
|
[sys.executable, str(script)], capture_output=True, text=True, timeout=30
|
||
|
|
)
|
||
|
|
# No URL argument: must fail loudly rather than defaulting to "allowed".
|
||
|
|
self.assertNotEqual(out.returncode, 0)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|