add stuff
This commit is contained in:
+506
-42
@@ -14,7 +14,17 @@ Features:
|
||||
bounded retries with rollback + backoff)
|
||||
- Smart priority queue: prefers never-crawled URLs, new domains and
|
||||
new hosts, with a spread bonus for little-explored territory
|
||||
- Per-host crawl delay
|
||||
- Host-aware scheduling: a worker never waits on a busy host - when
|
||||
the current domain is rate-limited or blocked, the worker
|
||||
automatically picks another domain and returns once the host is
|
||||
crawlable again
|
||||
- Per-host crawl delay (robots.txt) and per-host block backoff:
|
||||
HTTP 429/5xx/403 and network errors pause the host with an
|
||||
exponential backoff, Retry-After is honored
|
||||
- Transient failures are requeued (bounded by JONASSEARCH_MAX_ATTEMPTS)
|
||||
instead of being lost
|
||||
- Non-crawlable targets (localhost, raw IPs, non-real domains) are
|
||||
rejected before they reach the queue
|
||||
- robots.txt support with in-memory cache
|
||||
- Graceful shutdown
|
||||
- Basic crawler statistics
|
||||
@@ -52,10 +62,12 @@ import signal
|
||||
import time
|
||||
import logging
|
||||
import argparse
|
||||
import ipaddress
|
||||
import subprocess
|
||||
import threading
|
||||
import multiprocessing as mp
|
||||
from collections import defaultdict
|
||||
from email.utils import parsedate_to_datetime
|
||||
from html import unescape
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qsl, urljoin, urlparse, urldefrag
|
||||
@@ -138,7 +150,7 @@ USER_AGENT = os.getenv(
|
||||
# change; completely independent from the deployment generation (the
|
||||
# generation is a global deployment counter in PostgreSQL, the version
|
||||
# identifies the crawler code).
|
||||
CRAWLER_VERSION = int(os.getenv("JONASSEARCH_CRAWLER_VERSION", "1"))
|
||||
CRAWLER_VERSION = int(os.getenv("JONASSEARCH_CRAWLER_VERSION", "3"))
|
||||
|
||||
# Exit codes. 42 is reserved exclusively for "I must be updated" so
|
||||
# the launcher can distinguish update, crash and normal exit.
|
||||
@@ -173,6 +185,16 @@ LAUNCHER_RESTART_DELAY = float(
|
||||
# Only crawl normal HTTP(S) pages.
|
||||
ALLOWED_SCHEMES = {"http", "https"}
|
||||
|
||||
# Host suffixes reserved for loopback/private networks (never delegated
|
||||
# in public DNS). Together with the localhost/IP checks in
|
||||
# is_non_crawlable_host() they keep non-real domains out of the queue.
|
||||
NON_CRAWLABLE_HOST_SUFFIXES = (
|
||||
".internal",
|
||||
".local",
|
||||
".localdomain",
|
||||
".localhost",
|
||||
)
|
||||
|
||||
# Ignore obvious non-HTML resources.
|
||||
BLOCKED_EXTENSIONS = {
|
||||
".7z", ".avi", ".bin", ".bmp", ".bz2", ".class", ".csv", ".doc",
|
||||
@@ -244,6 +266,69 @@ QUEUE_INSERT_PAGE_SIZE = 500
|
||||
QUEUE_INSERT_MAX_ATTEMPTS = 3
|
||||
QUEUE_INSERT_RETRY_BASE_DELAY = 0.5
|
||||
|
||||
# ============================================================
|
||||
# HOST SCHEDULING / BLOCK HANDLING CONFIG
|
||||
# ============================================================
|
||||
# The queue claim examines candidates in priority order and picks the
|
||||
# first URL whose host is currently crawlable (not rate-limited, not
|
||||
# blocked). Hosts that would have to wait are skipped, so a worker
|
||||
# automatically crawls another domain while the blocked one cools
|
||||
# down - no worker sits idle in front of a 429.
|
||||
|
||||
# Pending URLs examined per claim round (FOR UPDATE SKIP LOCKED, so
|
||||
# several crawler processes stay safe on one shared queue).
|
||||
QUEUE_CLAIM_BATCH = int(os.getenv("JONASSEARCH_CLAIM_BATCH", "25"))
|
||||
|
||||
# Maximum number of candidates examined per claim (batch * rounds).
|
||||
# Keeps the common case (a ready host in the first batch) cheap while
|
||||
# still finding crawlable hosts behind a wall of blocked ones.
|
||||
QUEUE_CLAIM_SCAN_LIMIT = int(
|
||||
os.getenv("JONASSEARCH_CLAIM_SCAN_LIMIT", "200")
|
||||
)
|
||||
|
||||
# Cap for the "nothing is crawlable right now" sleep, so a host blocked
|
||||
# for a long time cannot park the whole worker; after the cap the queue
|
||||
# is simply re-scanned.
|
||||
QUEUE_MAX_IDLE_SLEEP = float(os.getenv("JONASSEARCH_MAX_IDLE_SLEEP", "30"))
|
||||
|
||||
# A URL may be attempted this many times (transient errors are
|
||||
# requeued) before it is marked failed for good, so permanently broken
|
||||
# hosts cannot spin forever.
|
||||
QUEUE_MAX_ATTEMPTS = int(os.getenv("JONASSEARCH_MAX_ATTEMPTS", "5"))
|
||||
|
||||
# Rate waits longer than this are NOT slept inline: the item is put
|
||||
# back and the worker immediately looks for another domain instead of
|
||||
# blocking on a slow host (e.g. a large robots.txt crawl-delay).
|
||||
MAX_INLINE_RATE_WAIT = float(os.getenv("JONASSEARCH_MAX_INLINE_WAIT", "10"))
|
||||
|
||||
# Backoff for blocked hosts, per HTTP status: (base seconds, cap
|
||||
# seconds). The pause doubles with every consecutive block of the same
|
||||
# host, so persistent blocks fade out of the schedule while a single
|
||||
# hiccup costs only the base delay.
|
||||
HOST_BLOCK_RULES = {
|
||||
401: (600.0, 7200.0), # auth required: probably permanent, long pause
|
||||
403: (600.0, 7200.0), # forbidden
|
||||
408: (30.0, 600.0), # request timeout
|
||||
425: (30.0, 600.0), # too early
|
||||
429: (60.0, 1800.0), # rate limited (Retry-After is honored on top)
|
||||
500: (30.0, 600.0),
|
||||
502: (30.0, 600.0),
|
||||
503: (30.0, 900.0),
|
||||
504: (30.0, 600.0),
|
||||
}
|
||||
|
||||
# Network-level failures (DNS, timeout, connection reset) get a short
|
||||
# backoff as well - a dead host must not be retried in a tight loop.
|
||||
HOST_BLOCK_NETWORK_BASE = 30.0
|
||||
HOST_BLOCK_NETWORK_MAX = 300.0
|
||||
|
||||
# Absolute upper bound for one host pause (also caps Retry-After).
|
||||
HOST_BLOCK_HARD_CAP = 7200.0
|
||||
|
||||
# HTTP statuses whose queue items are requeued (retried later) instead
|
||||
# of being failed terminally.
|
||||
TRANSIENT_HTTP_STATUSES = {408, 425, 429, 500, 502, 503, 504}
|
||||
|
||||
# ============================================================
|
||||
# URL PRIORITY CONFIG
|
||||
# ============================================================
|
||||
@@ -646,35 +731,135 @@ def write_stats(conn, crawled, queued, failed):
|
||||
""", (crawled, queued, failed))
|
||||
|
||||
|
||||
def get_next_queue_item(conn):
|
||||
def claim_next_queue_item(conn, scheduler, robots):
|
||||
"""
|
||||
Atomically claim one pending URL.
|
||||
Atomically claim the best currently-crawlable pending URL.
|
||||
|
||||
SKIP LOCKED is the important part: several crawler processes can
|
||||
consume the same PostgreSQL queue without waiting for each other.
|
||||
Candidates are read in priority order (same ORDER BY as before) in
|
||||
small batches with FOR UPDATE SKIP LOCKED, so several crawler
|
||||
processes can still share one queue without waiting for each
|
||||
other. For every candidate the HostScheduler decides whether its
|
||||
host may be contacted right now (per-host crawl delay, active
|
||||
block backoff). The first ready candidate is claimed; hosts that
|
||||
would have to wait are skipped, so the worker automatically works
|
||||
on another domain while a rate-limited or blocked host cools down.
|
||||
|
||||
Returns (item, wait_hint):
|
||||
item (id, url, source_url) of the claimed URL, or None
|
||||
wait_hint seconds until the soonest examined host becomes
|
||||
crawlable again (None when the queue is empty)
|
||||
|
||||
Writes only - the commit happens in safe_commit().
|
||||
"""
|
||||
now = time.monotonic()
|
||||
seen_ids = []
|
||||
scanned = 0
|
||||
wait_hint = None
|
||||
item = None
|
||||
|
||||
while item is None and scanned < QUEUE_CLAIM_SCAN_LIMIT:
|
||||
with conn.cursor() as cur:
|
||||
if seen_ids:
|
||||
cur.execute("""
|
||||
SELECT id, url, source_url
|
||||
FROM crawl_queue
|
||||
WHERE status = 'pending'
|
||||
AND NOT (id = ANY(%s))
|
||||
ORDER BY priority DESC, discovered_at ASC, id ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT %s
|
||||
""", (seen_ids, QUEUE_CLAIM_BATCH))
|
||||
else:
|
||||
cur.execute("""
|
||||
SELECT id, url, source_url
|
||||
FROM crawl_queue
|
||||
WHERE status = 'pending'
|
||||
ORDER BY priority DESC, discovered_at ASC, id ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT %s
|
||||
""", (QUEUE_CLAIM_BATCH,))
|
||||
candidates = cur.fetchall()
|
||||
|
||||
if not candidates:
|
||||
break
|
||||
|
||||
for queue_id, url, source_url in candidates:
|
||||
seen_ids.append(queue_id)
|
||||
scanned += 1
|
||||
|
||||
wait_for = scheduler.wait_time(url, robots.delay(url), now)
|
||||
if wait_for > 0.0:
|
||||
if wait_hint is None or wait_for < wait_hint:
|
||||
wait_hint = wait_for
|
||||
continue
|
||||
|
||||
# Host is crawlable: claim it. The row is already locked by
|
||||
# this transaction (FOR UPDATE SKIP LOCKED), so the
|
||||
# status='pending' guard is just belt and braces against
|
||||
# races with other claim paths.
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
UPDATE crawl_queue
|
||||
SET status = 'processing',
|
||||
attempts = attempts + 1,
|
||||
last_attempt_at = now()
|
||||
WHERE id = %s AND status = 'pending'
|
||||
RETURNING id, url, source_url
|
||||
""", (queue_id,))
|
||||
row = cur.fetchone()
|
||||
|
||||
if row:
|
||||
item = row
|
||||
break
|
||||
|
||||
if len(candidates) < QUEUE_CLAIM_BATCH:
|
||||
break
|
||||
|
||||
return item, wait_hint
|
||||
|
||||
|
||||
def defer_queue_item(conn, queue_id):
|
||||
"""
|
||||
Put a claimed item back WITHOUT burning an attempt: the host turned
|
||||
out not to be crawlable at fetch time (e.g. a robots.txt crawl-delay
|
||||
larger than MAX_INLINE_RATE_WAIT was discovered only after
|
||||
claiming). The item stays pending and is retried when the host's
|
||||
schedule allows it - the worker crawls other domains until then.
|
||||
|
||||
Writes only - the commit happens in safe_commit().
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
WITH next_item AS (
|
||||
SELECT id
|
||||
FROM crawl_queue
|
||||
WHERE status = 'pending'
|
||||
ORDER BY priority DESC, discovered_at ASC, id ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE crawl_queue q
|
||||
SET status = 'processing',
|
||||
attempts = attempts + 1,
|
||||
last_attempt_at = now()
|
||||
FROM next_item
|
||||
WHERE q.id = next_item.id
|
||||
RETURNING q.id, q.url, q.source_url
|
||||
""")
|
||||
UPDATE crawl_queue
|
||||
SET status = 'pending',
|
||||
attempts = GREATEST(attempts - 1, 0)
|
||||
WHERE id = %s
|
||||
""", (queue_id,))
|
||||
|
||||
|
||||
def requeue_queue_item(conn, queue_id):
|
||||
"""
|
||||
Requeue an item after a transient failure (HTTP 429/5xx, network
|
||||
errors): the host got a backoff, the URL is retried afterwards.
|
||||
After QUEUE_MAX_ATTEMPTS attempts the item is marked failed for
|
||||
good, so a permanently broken host cannot spin forever.
|
||||
|
||||
Returns the new status ('pending' or 'failed'), or None.
|
||||
|
||||
Writes only - the commit happens in safe_commit().
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
UPDATE crawl_queue
|
||||
SET status = CASE
|
||||
WHEN attempts >= %s THEN 'failed'
|
||||
ELSE 'pending'
|
||||
END
|
||||
WHERE id = %s
|
||||
RETURNING status
|
||||
""", (QUEUE_MAX_ATTEMPTS, queue_id))
|
||||
row = cur.fetchone()
|
||||
return row
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def finish_queue_item(conn, queue_id, success=True):
|
||||
@@ -1290,6 +1475,54 @@ def clean_text_list(values):
|
||||
# URL NORMALIZATION
|
||||
# ============================================================
|
||||
|
||||
def is_non_crawlable_host(hostname):
|
||||
"""
|
||||
True for hosts the crawler must never touch: loopback names,
|
||||
private-use suffixes, raw IP literals (IPv4/IPv6) and other
|
||||
non-resolvable (non-real) host names. A public web crawler can only
|
||||
follow real, publicly resolving domains - everything else is a LAN
|
||||
target, a loopback service or a literal address.
|
||||
|
||||
Applied centrally in normalize_url(), so neither the seed URL nor
|
||||
any discovered link ever reaches the queue.
|
||||
"""
|
||||
if not hostname:
|
||||
return True
|
||||
host = hostname.strip(".").lower()
|
||||
if not host:
|
||||
return True
|
||||
if host == "localhost" or host == "localhost.localdomain":
|
||||
return True
|
||||
if host.endswith(NON_CRAWLABLE_HOST_SUFFIXES):
|
||||
return True
|
||||
# Raw IP literals (IPv4 and IPv6) are not real domains. Note that
|
||||
# urlparse already strips the brackets of IPv6 URLs, so "::1" is
|
||||
# compared directly.
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
# Single-label names (no dot) can never be public domains.
|
||||
if "." not in host:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_non_crawlable_url(url):
|
||||
"""
|
||||
Convenience wrapper for queue entries: True when the URL points at
|
||||
a non-crawlable host (localhost, raw IP, non-real domain) or cannot
|
||||
be parsed at all. Unparseable URLs are treated as non-crawlable so
|
||||
one malformed entry can never crash the worker.
|
||||
"""
|
||||
try:
|
||||
hostname = (urlparse(url).hostname or "").lower()
|
||||
except ValueError:
|
||||
return True
|
||||
return is_non_crawlable_host(hostname)
|
||||
|
||||
|
||||
def normalize_url(url, base_url=None):
|
||||
if not url:
|
||||
return None
|
||||
@@ -1319,6 +1552,11 @@ def normalize_url(url, base_url=None):
|
||||
if not hostname:
|
||||
return None
|
||||
|
||||
# Never queue loopback, LAN or raw-IP targets (localhost, IPs,
|
||||
# non-real domains).
|
||||
if is_non_crawlable_host(hostname):
|
||||
return None
|
||||
|
||||
# Drop default ports.
|
||||
port = parsed.port
|
||||
if (scheme == "http" and port == 80) or (scheme == "https" and port == 443):
|
||||
@@ -1423,20 +1661,159 @@ class RobotsCache:
|
||||
# HTTP
|
||||
# ============================================================
|
||||
|
||||
class RateLimiter:
|
||||
class HostScheduler:
|
||||
"""
|
||||
Per-process host availability tracker - the heart of the automatic
|
||||
domain switching.
|
||||
|
||||
The scheduler keeps, per host:
|
||||
last_request: monotonic time of the last request start, so
|
||||
per-host delays (DEFAULT_DELAY / robots.txt
|
||||
crawl-delay) are honored.
|
||||
blocked_until: monotonic time until which the host is considered
|
||||
blocked (HTTP 429/5xx/403, network errors,
|
||||
Retry-After).
|
||||
block_count: consecutive blocks of the host; the pause doubles
|
||||
with every consecutive block and resets on a
|
||||
successful fetch.
|
||||
|
||||
claim_next_queue_item() asks wait_time() for every queue candidate:
|
||||
a host that would have to wait is skipped, so the worker crawls
|
||||
another domain instead and returns to the blocked host when its
|
||||
backoff has expired. No worker ever sleeps in front of a
|
||||
rate-limited host while other domains are crawlable.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.last_request = defaultdict(float)
|
||||
self.last_request = {}
|
||||
self.blocked_until = {}
|
||||
self.block_count = {}
|
||||
|
||||
@staticmethod
|
||||
def _host(url):
|
||||
try:
|
||||
return (urlparse(url).netloc or "").lower()
|
||||
except ValueError:
|
||||
return ""
|
||||
|
||||
def wait_time(self, url, delay, now=None):
|
||||
"""Seconds until url's host may be contacted again (0.0 = now)."""
|
||||
if now is None:
|
||||
now = time.monotonic()
|
||||
host = self._host(url)
|
||||
if not host:
|
||||
return 0.0
|
||||
next_allowed = max(
|
||||
self.last_request.get(host, 0.0) + delay,
|
||||
self.blocked_until.get(host, 0.0),
|
||||
)
|
||||
return max(0.0, next_allowed - now)
|
||||
|
||||
def is_ready(self, url, delay, now=None):
|
||||
return self.wait_time(url, delay, now) <= 0.0
|
||||
|
||||
def wait(self, url, delay):
|
||||
host = urlparse(url).netloc.lower()
|
||||
now = time.monotonic()
|
||||
wait_for = delay - (now - self.last_request[host])
|
||||
"""
|
||||
Enforce the per-host delay directly before a request.
|
||||
|
||||
Returns True when the request may start now (the wait was slept
|
||||
inline). Returns False when the wait would exceed
|
||||
MAX_INLINE_RATE_WAIT (e.g. a large robots.txt crawl-delay that
|
||||
claim_next_queue_item could not know yet, because robots.txt is
|
||||
only loaded during the first fetch): the caller then defers the
|
||||
item instead of parking the worker, and the host's schedule
|
||||
stays untouched, so the item is retried exactly when the delay
|
||||
has passed.
|
||||
"""
|
||||
wait_for = self.wait_time(url, delay)
|
||||
if wait_for > MAX_INLINE_RATE_WAIT:
|
||||
return False
|
||||
if wait_for > 0:
|
||||
logging.info("[RATE] %s: %.1fs", host, wait_for)
|
||||
if wait_for >= 1.0:
|
||||
logging.info("[RATE ] %s: %.1fs", self._host(url), wait_for)
|
||||
time.sleep(wait_for)
|
||||
self.last_request[self._host(url)] = time.monotonic()
|
||||
return True
|
||||
|
||||
self.last_request[host] = time.monotonic()
|
||||
def _register_block(self, host, seconds):
|
||||
count = self.block_count.get(host, 0) + 1
|
||||
self.block_count[host] = count
|
||||
blocked = min(seconds, HOST_BLOCK_HARD_CAP)
|
||||
self.blocked_until[host] = time.monotonic() + blocked
|
||||
return count, blocked
|
||||
|
||||
def note_success(self, url):
|
||||
"""Successful fetch: the host's consecutive-block counter resets."""
|
||||
self.block_count.pop(self._host(url), None)
|
||||
|
||||
def note_http_error(self, url, status, retry_after=None):
|
||||
"""
|
||||
Pause the host after a block-ish HTTP status (429, 5xx, 403,
|
||||
...). An exponential backoff applies; a Retry-After header is
|
||||
honored on top of it.
|
||||
"""
|
||||
rule = HOST_BLOCK_RULES.get(status)
|
||||
if rule is None:
|
||||
return
|
||||
host = self._host(url)
|
||||
base, cap = rule
|
||||
seconds = min(cap, base * (2 ** self.block_count.get(host, 0)))
|
||||
if retry_after is not None:
|
||||
seconds = max(seconds, retry_after)
|
||||
count, blocked = self._register_block(host, seconds)
|
||||
logging.info(
|
||||
"[BLOCK] %s: HTTP %d -> host paused for %.0fs (block #%d)",
|
||||
host, status, blocked, count,
|
||||
)
|
||||
|
||||
def note_network_error(self, url):
|
||||
"""Short backoff after DNS/timeout/connection failures."""
|
||||
host = self._host(url)
|
||||
seconds = min(
|
||||
HOST_BLOCK_NETWORK_MAX,
|
||||
HOST_BLOCK_NETWORK_BASE * (2 ** self.block_count.get(host, 0)),
|
||||
)
|
||||
count, blocked = self._register_block(host, seconds)
|
||||
logging.info(
|
||||
"[BLOCK] %s: network error -> host paused for %.0fs (block #%d)",
|
||||
host, blocked, count,
|
||||
)
|
||||
|
||||
|
||||
def _retry_after_seconds(headers):
|
||||
"""
|
||||
Parse a Retry-After header (delta-seconds or HTTP date). Returns
|
||||
the number of seconds, or None when absent/unparseable.
|
||||
"""
|
||||
try:
|
||||
value = headers.get("Retry-After") if headers is not None else None
|
||||
except Exception:
|
||||
return None
|
||||
if not value:
|
||||
return None
|
||||
value = value.strip()
|
||||
if value.isdigit():
|
||||
return float(value)
|
||||
try:
|
||||
retry_at = parsedate_to_datetime(value)
|
||||
return max(0.0, retry_at.timestamp() - time.time())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def is_transient_error(error):
|
||||
"""
|
||||
True for fetch errors worth retrying later (the item is requeued
|
||||
and the host gets a backoff): network-level failures and the HTTP
|
||||
statuses in TRANSIENT_HTTP_STATUSES. Everything else (404, robots,
|
||||
too large, not HTML, ...) stays a terminal failure.
|
||||
"""
|
||||
if not error:
|
||||
return False
|
||||
if error.startswith("network:"):
|
||||
return True
|
||||
match = re.match(r"^http_(\d{3})$", error)
|
||||
return bool(match) and int(match.group(1)) in TRANSIENT_HTTP_STATUSES
|
||||
|
||||
|
||||
def is_skipped_url(url):
|
||||
@@ -1448,14 +1825,20 @@ def is_skipped_url(url):
|
||||
return any(pattern.match(url) for pattern in SKIP_URL_PATTERNS)
|
||||
|
||||
|
||||
def fetch(url, limiter, robots):
|
||||
def fetch(url, scheduler, robots):
|
||||
parsed = urlparse(url)
|
||||
|
||||
if not robots.allowed(url):
|
||||
return None, None, "robots"
|
||||
|
||||
delay = robots.delay(url)
|
||||
limiter.wait(url, delay)
|
||||
if not scheduler.wait(url, delay):
|
||||
# The host needs a longer break than we are willing to sleep
|
||||
# for inline (e.g. a large robots.txt crawl-delay discovered
|
||||
# only now, because robots.txt is loaded on the first fetch):
|
||||
# report it so the worker defers the item and crawls another
|
||||
# domain in the meantime.
|
||||
return None, None, "host_busy"
|
||||
|
||||
req = Request(
|
||||
url,
|
||||
@@ -1501,8 +1884,18 @@ def fetch(url, limiter, robots):
|
||||
return status, text, None
|
||||
|
||||
except HTTPError as e:
|
||||
# Blocked or rate-limited? Put the host on backoff so the next
|
||||
# claims automatically prefer other domains.
|
||||
scheduler.note_http_error(
|
||||
url,
|
||||
e.code,
|
||||
retry_after=_retry_after_seconds(
|
||||
getattr(e, "headers", None)
|
||||
),
|
||||
)
|
||||
return e.code, None, f"http_{e.code}"
|
||||
except (URLError, TimeoutError, OSError) as e:
|
||||
scheduler.note_network_error(url)
|
||||
return None, None, f"network:{e}"
|
||||
except Exception as e:
|
||||
return None, None, f"error:{e}"
|
||||
@@ -1702,10 +2095,11 @@ def worker(worker_id):
|
||||
|
||||
conn = None
|
||||
robots = RobotsCache()
|
||||
limiter = RateLimiter()
|
||||
scheduler = HostScheduler()
|
||||
|
||||
crawled = 0
|
||||
failed = 0
|
||||
retried = 0
|
||||
last_stats = time.monotonic()
|
||||
current_queue_id = None
|
||||
update_requested = False
|
||||
@@ -1742,11 +2136,26 @@ def worker(worker_id):
|
||||
update_requested = True
|
||||
break
|
||||
|
||||
item = safe_commit(conn, lambda: get_next_queue_item(conn))
|
||||
item, wait_hint = safe_commit(
|
||||
conn,
|
||||
lambda: claim_next_queue_item(conn, scheduler, robots),
|
||||
)
|
||||
|
||||
if not item:
|
||||
logging.info("Queue empty. Worker waiting...")
|
||||
time.sleep(5)
|
||||
if wait_hint:
|
||||
# Every candidate host is rate-limited or blocked
|
||||
# right now; sleep until the soonest one becomes
|
||||
# crawlable (capped, then the queue is re-scanned).
|
||||
sleep_for = min(wait_hint, QUEUE_MAX_IDLE_SLEEP)
|
||||
logging.info(
|
||||
"[WAIT ] All candidate hosts busy or blocked; "
|
||||
"worker sleeps %.1fs",
|
||||
sleep_for,
|
||||
)
|
||||
else:
|
||||
sleep_for = 5
|
||||
logging.info("Queue empty. Worker waiting...")
|
||||
time.sleep(sleep_for)
|
||||
continue
|
||||
|
||||
queue_id, url, source_url = item
|
||||
@@ -1770,12 +2179,53 @@ def worker(worker_id):
|
||||
)
|
||||
continue
|
||||
|
||||
# Hosts that may never be crawled (localhost, raw IPs,
|
||||
# non-real domains) can still sit in the queue from before
|
||||
# this rule existed: drop them here so they are not
|
||||
# fetched and not retried endlessly.
|
||||
if is_non_crawlable_url(url):
|
||||
logging.info("[SKIP ] %s (non-crawlable host)", url)
|
||||
safe_commit(
|
||||
conn,
|
||||
lambda: finish_queue_item(
|
||||
conn, queue_id, success=True
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
started = time.monotonic()
|
||||
|
||||
status, html, error = fetch(url, limiter, robots)
|
||||
status, html, error = fetch(url, scheduler, robots)
|
||||
|
||||
if error:
|
||||
logging.info("[HTTP ] %s -> %s", url, error)
|
||||
|
||||
if error == "host_busy":
|
||||
# Host needs a longer break than we sleep for
|
||||
# inline: put the item back (without burning an
|
||||
# attempt) and crawl another domain instead.
|
||||
safe_commit(
|
||||
conn,
|
||||
lambda: defer_queue_item(conn, queue_id),
|
||||
)
|
||||
continue
|
||||
|
||||
if is_transient_error(error):
|
||||
# The host is already on backoff (set in fetch);
|
||||
# requeue the URL for a later attempt.
|
||||
new_status = safe_commit(
|
||||
conn,
|
||||
lambda: requeue_queue_item(conn, queue_id),
|
||||
)
|
||||
retried += 1
|
||||
logging.info(
|
||||
"[RETRY] %s -> %s (max %d attempts)",
|
||||
url,
|
||||
new_status or "requeued",
|
||||
QUEUE_MAX_ATTEMPTS,
|
||||
)
|
||||
continue
|
||||
|
||||
safe_commit(
|
||||
conn,
|
||||
lambda: finish_queue_item(
|
||||
@@ -1786,6 +2236,7 @@ def worker(worker_id):
|
||||
continue
|
||||
|
||||
logging.info("[HTTP ] %s -> HTTP %s", url, status)
|
||||
scheduler.note_success(url)
|
||||
|
||||
# A redirect normally arrives as the final URL in urllib.
|
||||
# The requested URL remains the canonical queue identity.
|
||||
@@ -1941,8 +2392,8 @@ def worker(worker_id):
|
||||
conn.close()
|
||||
|
||||
logging.info(
|
||||
"Stopped. crawled=%d failed=%d",
|
||||
crawled, failed
|
||||
"Stopped. crawled=%d failed=%d retried=%d",
|
||||
crawled, failed, retried
|
||||
)
|
||||
|
||||
if update_requested:
|
||||
@@ -1961,6 +2412,7 @@ def main():
|
||||
print(f"Start: {START_URL}")
|
||||
print(f"Workers: {WORKERS}")
|
||||
print(f"Delay: {DEFAULT_DELAY:.1f} Sekunden")
|
||||
print("Auto-Switch: aktiv (blockierte Domains werden übersprungen)")
|
||||
print(f"Robots-Cache: {ROBOTS_CACHE_SECONDS // 3600} Stunden")
|
||||
print(f"DB: {DB_HOST}:{DB_PORT}/{DB_NAME}")
|
||||
print(f"Version: {CRAWLER_VERSION}")
|
||||
@@ -1975,7 +2427,19 @@ def main():
|
||||
safe_commit(conn, lambda: requeue_stale_items(conn))
|
||||
|
||||
# Seed URL. ON CONFLICT makes restarting harmless.
|
||||
queue_urls(conn, [normalize_url(START_URL)], None)
|
||||
seed_url = normalize_url(START_URL)
|
||||
if seed_url is None:
|
||||
# Either an invalid URL or a non-crawlable host (localhost,
|
||||
# raw IP, non-real domain): crawling can never work, so fail
|
||||
# clearly instead of spinning on an empty queue.
|
||||
conn.close()
|
||||
logging.error(
|
||||
"Start URL %s is not crawlable (invalid or non-public "
|
||||
"host such as localhost or an IP address).",
|
||||
START_URL,
|
||||
)
|
||||
return EXIT_ERROR
|
||||
queue_urls(conn, [seed_url], None)
|
||||
|
||||
conn.close()
|
||||
|
||||
@@ -2043,6 +2507,7 @@ def main():
|
||||
sys.exit(EXIT_ERROR)
|
||||
|
||||
print("Crawler stopped.")
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -2257,8 +2722,7 @@ def main_cli(argv=None):
|
||||
if args.init_deployment:
|
||||
return cmd_init_deployment()
|
||||
|
||||
main()
|
||||
return EXIT_OK
|
||||
return main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user