456 lines
16 KiB
Python
456 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Local test suite for the crawler (no database required).
|
|
|
|
Only the pure, DB-free parts are tested: the HostScheduler (per-host
|
|
delays, block backoff, Retry-After), the transient-error classification
|
|
and the queue-claim decision logic (blocked hosts are skipped, another
|
|
domain is picked) against an in-memory queue substitute.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import time
|
|
import unittest
|
|
from email import utils as email_utils
|
|
from unittest import mock
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
|
def _load_env_file():
|
|
"""
|
|
Load .env like the systemd EnvironmentFile does, so the tests also
|
|
run outside systemd. Must happen before crawler.py is imported,
|
|
which reads the JONASSEARCH_* variables at import time.
|
|
"""
|
|
env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
|
|
|
|
try:
|
|
with open(env_path) as handle:
|
|
for line in handle:
|
|
line = line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, _, value = line.partition("=")
|
|
os.environ.setdefault(key.strip(), value.strip())
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
_load_env_file()
|
|
|
|
import crawler # noqa: E402
|
|
|
|
|
|
class StubRobots:
|
|
"""robots.txt stub: constant delay, no network."""
|
|
|
|
def __init__(self, delay=3.0):
|
|
self.delay_value = delay
|
|
|
|
def delay(self, url):
|
|
return self.delay_value
|
|
|
|
|
|
class FakeState:
|
|
"""In-memory crawl_queue substitute for claim/defer/requeue."""
|
|
|
|
def __init__(self, rows=None):
|
|
self.rows = rows if rows is not None else []
|
|
|
|
def add(self, **kwargs):
|
|
row = {
|
|
"id": len(self.rows) + 1,
|
|
"url": "",
|
|
"source": None,
|
|
"status": "pending",
|
|
"priority": 0,
|
|
"attempts": 0,
|
|
}
|
|
row.update(kwargs)
|
|
self.rows.append(row)
|
|
return row["id"]
|
|
|
|
|
|
class FakeCursor:
|
|
"""Just enough SQL surface for the crawler's queue functions."""
|
|
|
|
def __init__(self, state):
|
|
self._state = state
|
|
self._rows = []
|
|
|
|
def execute(self, query, params=None):
|
|
query = " ".join(query.split())
|
|
params = params or ()
|
|
|
|
if "SELECT id, url, source_url" in query:
|
|
batch = params[-1]
|
|
exclude = set(params[0]) if len(params) == 2 else set()
|
|
candidates = [
|
|
row for row in self._state.rows
|
|
if row["status"] == "pending" and row["id"] not in exclude
|
|
]
|
|
candidates.sort(key=lambda row: (-row["priority"], row["id"]))
|
|
self._rows = [
|
|
(row["id"], row["url"], row["source"])
|
|
for row in candidates[:batch]
|
|
]
|
|
elif "SET status = 'processing'" in query:
|
|
(queue_id,) = params
|
|
self._rows = []
|
|
for row in self._state.rows:
|
|
if row["id"] == queue_id and row["status"] == "pending":
|
|
row["status"] = "processing"
|
|
row["attempts"] += 1
|
|
self._rows = [(row["id"], row["url"], row["source"])]
|
|
break
|
|
elif "GREATEST(attempts - 1, 0)" in query:
|
|
(queue_id,) = params
|
|
for row in self._state.rows:
|
|
if row["id"] == queue_id:
|
|
row["status"] = "pending"
|
|
row["attempts"] = max(row["attempts"] - 1, 0)
|
|
elif "WHEN attempts >=" in query:
|
|
max_attempts, queue_id = params
|
|
self._rows = []
|
|
for row in self._state.rows:
|
|
if row["id"] == queue_id:
|
|
row["status"] = (
|
|
"failed" if row["attempts"] >= max_attempts
|
|
else "pending"
|
|
)
|
|
self._rows = [(row["status"],)]
|
|
else:
|
|
raise AssertionError("unexpected query: %s" % query[:120])
|
|
|
|
def fetchone(self):
|
|
return self._rows[0] if self._rows else None
|
|
|
|
def fetchall(self):
|
|
return self._rows
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_exc):
|
|
return False
|
|
|
|
|
|
class FakeConnection:
|
|
def __init__(self, state):
|
|
self.state = state
|
|
|
|
def cursor(self):
|
|
return FakeCursor(self.state)
|
|
|
|
def commit(self):
|
|
pass
|
|
|
|
def rollback(self):
|
|
pass
|
|
|
|
|
|
class HostSchedulerTest(unittest.TestCase):
|
|
|
|
def test_first_request_is_ready(self):
|
|
scheduler = crawler.HostScheduler()
|
|
self.assertEqual(
|
|
scheduler.wait_time("https://a.example/x", 3.0), 0.0
|
|
)
|
|
|
|
def test_delay_is_honored_after_request(self):
|
|
scheduler = crawler.HostScheduler()
|
|
scheduler.wait("https://a.example/x", 0.0)
|
|
remaining = scheduler.wait_time("https://a.example/y", 3.0)
|
|
self.assertGreater(remaining, 2.5)
|
|
self.assertLessEqual(remaining, 3.0)
|
|
|
|
def test_short_wait_is_slept_inline(self):
|
|
scheduler = crawler.HostScheduler()
|
|
scheduler.wait("https://a.example/x", 0.0)
|
|
with mock.patch("time.sleep") as sleep:
|
|
self.assertTrue(scheduler.wait("https://a.example/y", 3.0))
|
|
sleep.assert_called_once()
|
|
|
|
def test_long_wait_is_deferred_without_touching_schedule(self):
|
|
scheduler = crawler.HostScheduler()
|
|
scheduler.wait("https://a.example/x", 0.0)
|
|
recorded = scheduler.last_request["a.example"]
|
|
with mock.patch("time.sleep") as sleep:
|
|
self.assertFalse(scheduler.wait("https://a.example/y", 60.0))
|
|
sleep.assert_not_called()
|
|
self.assertEqual(scheduler.last_request["a.example"], recorded)
|
|
|
|
def test_block_backoff_doubles(self):
|
|
scheduler = crawler.HostScheduler()
|
|
url = "https://a.example/x"
|
|
scheduler.note_http_error(url, 429)
|
|
first = scheduler.wait_time("https://a.example/y", 3.0)
|
|
scheduler.note_http_error(url, 429)
|
|
second = scheduler.wait_time("https://a.example/y", 3.0)
|
|
self.assertGreater(first, crawler.HOST_BLOCK_RULES[429][0] * 0.9)
|
|
self.assertGreater(second, first * 1.5)
|
|
|
|
def test_retry_after_is_honored(self):
|
|
scheduler = crawler.HostScheduler()
|
|
scheduler.note_http_error(
|
|
"https://a.example/x", 429, retry_after=1200
|
|
)
|
|
remaining = scheduler.wait_time("https://a.example/y", 3.0)
|
|
self.assertGreater(remaining, 1000)
|
|
self.assertLessEqual(remaining, crawler.HOST_BLOCK_HARD_CAP)
|
|
|
|
def test_block_never_exceeds_hard_cap(self):
|
|
scheduler = crawler.HostScheduler()
|
|
url = "https://a.example/x"
|
|
for _ in range(40):
|
|
scheduler.note_http_error(url, 429)
|
|
remaining = scheduler.wait_time(url, 3.0)
|
|
self.assertLessEqual(remaining, crawler.HOST_BLOCK_HARD_CAP + 1)
|
|
|
|
def test_success_resets_block_counter(self):
|
|
scheduler = crawler.HostScheduler()
|
|
url = "https://a.example/x"
|
|
scheduler.note_http_error(url, 429)
|
|
scheduler.note_http_error(url, 429)
|
|
scheduler.note_success(url)
|
|
scheduler.note_http_error(url, 429)
|
|
remaining = scheduler.wait_time("https://a.example/y", 3.0)
|
|
self.assertLessEqual(
|
|
remaining, crawler.HOST_BLOCK_RULES[429][0] + 1
|
|
)
|
|
|
|
def test_non_block_status_is_ignored(self):
|
|
scheduler = crawler.HostScheduler()
|
|
scheduler.note_http_error("https://a.example/x", 404)
|
|
self.assertEqual(
|
|
scheduler.wait_time("https://a.example/y", 3.0), 0.0
|
|
)
|
|
|
|
def test_network_error_backs_off(self):
|
|
scheduler = crawler.HostScheduler()
|
|
scheduler.note_network_error("https://a.example/x")
|
|
remaining = scheduler.wait_time("https://a.example/y", 3.0)
|
|
self.assertGreater(remaining, 20)
|
|
|
|
def test_unrelated_host_is_not_blocked(self):
|
|
scheduler = crawler.HostScheduler()
|
|
scheduler.note_http_error("https://a.example/x", 429)
|
|
self.assertEqual(
|
|
scheduler.wait_time("https://b.example/y", 3.0), 0.0
|
|
)
|
|
|
|
|
|
class ErrorClassificationTest(unittest.TestCase):
|
|
|
|
def test_network_errors_are_transient(self):
|
|
self.assertTrue(crawler.is_transient_error("network:timeout"))
|
|
self.assertTrue(crawler.is_transient_error("network:[Errno -2] x"))
|
|
|
|
def test_rate_limit_and_server_errors_are_transient(self):
|
|
for status in (408, 425, 429, 500, 502, 503, 504):
|
|
self.assertTrue(crawler.is_transient_error("http_%d" % status))
|
|
|
|
def test_terminal_errors_are_not_transient(self):
|
|
for error in (
|
|
"http_404", "http_410", "robots", "too_large",
|
|
"not_html", "error:boom", "host_busy", None, "",
|
|
):
|
|
self.assertFalse(crawler.is_transient_error(error))
|
|
|
|
def test_retry_after_seconds_digit(self):
|
|
self.assertEqual(
|
|
crawler._retry_after_seconds({"Retry-After": "120"}), 120.0
|
|
)
|
|
|
|
def test_retry_after_http_date(self):
|
|
headers = {
|
|
"Retry-After": email_utils.formatdate(
|
|
time.time() + 120, usegmt=True
|
|
)
|
|
}
|
|
value = crawler._retry_after_seconds(headers)
|
|
self.assertGreater(value, 100)
|
|
self.assertLess(value, 140)
|
|
|
|
def test_retry_after_missing_or_garbage(self):
|
|
self.assertIsNone(crawler._retry_after_seconds({}))
|
|
self.assertIsNone(
|
|
crawler._retry_after_seconds({"Retry-After": "soon"})
|
|
)
|
|
self.assertIsNone(crawler._retry_after_seconds(None))
|
|
|
|
|
|
class QueueClaimTest(unittest.TestCase):
|
|
|
|
def _state(self):
|
|
state = FakeState()
|
|
state.add(url="https://blocked.example/a", priority=500)
|
|
state.add(url="https://other.example/b", priority=100)
|
|
state.add(url="https://blocked.example/c", priority=400)
|
|
return state
|
|
|
|
def _claim(self, state, scheduler=None):
|
|
scheduler = scheduler or crawler.HostScheduler()
|
|
return crawler.claim_next_queue_item(
|
|
FakeConnection(state), scheduler, StubRobots()
|
|
)
|
|
|
|
def test_highest_priority_ready_host_wins(self):
|
|
item, wait_hint = self._claim(self._state())
|
|
self.assertEqual(item[1], "https://blocked.example/a")
|
|
self.assertIsNone(wait_hint)
|
|
|
|
def test_blocked_host_is_skipped_for_other_domain(self):
|
|
scheduler = crawler.HostScheduler()
|
|
scheduler.blocked_until["blocked.example"] = time.monotonic() + 900
|
|
item, wait_hint = self._claim(self._state(), scheduler)
|
|
self.assertEqual(item[1], "https://other.example/b")
|
|
self.assertGreater(wait_hint, 800)
|
|
|
|
def test_rate_limited_host_is_skipped(self):
|
|
scheduler = crawler.HostScheduler()
|
|
scheduler.wait("https://blocked.example/a", 0.0)
|
|
item, _wait_hint = self._claim(self._state(), scheduler)
|
|
self.assertEqual(item[1], "https://other.example/b")
|
|
|
|
def test_only_blocked_hosts_left_reports_wait_hint(self):
|
|
state = FakeState()
|
|
state.add(url="https://blocked.example/a", priority=500)
|
|
scheduler = crawler.HostScheduler()
|
|
scheduler.blocked_until["blocked.example"] = time.monotonic() + 900
|
|
item, wait_hint = self._claim(state, scheduler)
|
|
self.assertIsNone(item)
|
|
self.assertGreater(wait_hint, 800)
|
|
|
|
def test_empty_queue(self):
|
|
item, wait_hint = self._claim(FakeState())
|
|
self.assertIsNone(item)
|
|
self.assertIsNone(wait_hint)
|
|
|
|
def test_claim_marks_processing_and_counts_attempt(self):
|
|
state = self._state()
|
|
self._claim(state)
|
|
self.assertEqual(state.rows[0]["status"], "processing")
|
|
self.assertEqual(state.rows[0]["attempts"], 1)
|
|
self.assertEqual(state.rows[1]["status"], "pending")
|
|
|
|
def test_claim_finds_ready_host_behind_blocked_wall(self):
|
|
state = FakeState()
|
|
for index in range(30):
|
|
state.add(
|
|
url="https://blocked.example/%d" % index,
|
|
priority=300 - index,
|
|
)
|
|
state.add(url="https://ready.example/x", priority=10)
|
|
scheduler = crawler.HostScheduler()
|
|
scheduler.blocked_until["blocked.example"] = time.monotonic() + 900
|
|
item, _wait_hint = self._claim(state, scheduler)
|
|
self.assertEqual(item[1], "https://ready.example/x")
|
|
|
|
def test_defer_does_not_burn_an_attempt(self):
|
|
state = FakeState()
|
|
queue_id = state.add(
|
|
url="https://slow.example/a",
|
|
status="processing",
|
|
attempts=1,
|
|
)
|
|
crawler.defer_queue_item(FakeConnection(state), queue_id)
|
|
row = state.rows[0]
|
|
self.assertEqual(row["status"], "pending")
|
|
self.assertEqual(row["attempts"], 0)
|
|
|
|
def test_requeue_retries_below_attempt_cap(self):
|
|
state = FakeState()
|
|
queue_id = state.add(
|
|
url="https://x.example/a",
|
|
status="processing",
|
|
attempts=1,
|
|
)
|
|
status = crawler.requeue_queue_item(FakeConnection(state), queue_id)
|
|
self.assertEqual(status, "pending")
|
|
self.assertEqual(state.rows[0]["status"], "pending")
|
|
|
|
def test_requeue_fails_after_attempt_cap(self):
|
|
state = FakeState()
|
|
queue_id = state.add(
|
|
url="https://x.example/a",
|
|
status="processing",
|
|
attempts=crawler.QUEUE_MAX_ATTEMPTS,
|
|
)
|
|
status = crawler.requeue_queue_item(FakeConnection(state), queue_id)
|
|
self.assertEqual(status, "failed")
|
|
self.assertEqual(state.rows[0]["status"], "failed")
|
|
|
|
|
|
class HostFilterTest(unittest.TestCase):
|
|
|
|
def test_localhost_and_private_suffixes_are_blocked(self):
|
|
for host in (
|
|
"localhost", "sub.localhost", "localhost.localdomain",
|
|
"myserver.local", "app.internal",
|
|
):
|
|
self.assertTrue(
|
|
crawler.is_non_crawlable_host(host), host
|
|
)
|
|
|
|
def test_ip_literals_are_blocked(self):
|
|
for host in (
|
|
"127.0.0.1", "10.0.0.5", "192.168.1.10", "172.16.0.1",
|
|
"8.8.8.8", "::1", "fe80::1", "2001:db8::1",
|
|
"::ffff:127.0.0.1",
|
|
):
|
|
self.assertTrue(
|
|
crawler.is_non_crawlable_host(host), host
|
|
)
|
|
|
|
def test_single_label_and_empty_names_are_blocked(self):
|
|
for host in ("mymachine", "", " ", "."):
|
|
self.assertTrue(
|
|
crawler.is_non_crawlable_host(host), repr(host)
|
|
)
|
|
|
|
def test_real_domains_are_allowed(self):
|
|
for host in (
|
|
"example.com", "www.example.com",
|
|
"sub.domain.example.co.uk", "xn--bcher-kva.example",
|
|
"example.com.",
|
|
):
|
|
self.assertFalse(
|
|
crawler.is_non_crawlable_host(host), host
|
|
)
|
|
|
|
def test_normalize_url_rejects_non_public_targets(self):
|
|
for url in (
|
|
"http://localhost/x",
|
|
"http://127.0.0.1/x",
|
|
"http://[::1]:8080/x",
|
|
"http://10.1.2.3/a?b=c",
|
|
"http://intranet.local/x",
|
|
"http://host/x",
|
|
):
|
|
self.assertIsNone(crawler.normalize_url(url), url)
|
|
|
|
def test_normalize_url_keeps_real_domains(self):
|
|
self.assertEqual(
|
|
crawler.normalize_url("http://www.example.com/x?y=1"),
|
|
"http://www.example.com/x?y=1",
|
|
)
|
|
|
|
def test_is_non_crawlable_url_handles_malformed_urls(self):
|
|
self.assertTrue(crawler.is_non_crawlable_url("http://localhost/x"))
|
|
self.assertTrue(crawler.is_non_crawlable_url("http://[::1]/"))
|
|
self.assertFalse(crawler.is_non_crawlable_url("https://example.com/"))
|
|
|
|
def test_malformed_url_is_treated_as_non_crawlable(self):
|
|
# urlparse raises ValueError on malformed IPv6 brackets; the
|
|
# filter must not crash on it.
|
|
self.assertTrue(
|
|
crawler.is_non_crawlable_url("http://[::1")
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|