Add Crawler and env files
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
# ============================================================
|
||||||
|
# JonasSearch Crawler Gamma 0.1
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# PostgreSQL
|
||||||
|
JONASSEARCH_DB_HOST=127.0.0.1
|
||||||
|
JONASSEARCH_DB_PORT=25432
|
||||||
|
JONASSEARCH_DB_NAME=jonassearch
|
||||||
|
JONASSEARCH_DB_USER=jonassearch
|
||||||
|
JONASSEARCH_DB_PASSWORD=MeinStarkesPasswort123!
|
||||||
|
|
||||||
|
# Crawler
|
||||||
|
JONASSEARCH_START_URL=https://reddit.com
|
||||||
|
JONASSEARCH_WORKERS=4
|
||||||
|
|
||||||
|
# Mindestabstand zwischen Requests zur gleichen Domain
|
||||||
|
JONASSEARCH_DELAY=3.0
|
||||||
|
|
||||||
|
# robots.txt Cache
|
||||||
|
JONASSEARCH_ROBOTS_CACHE_SECONDS=86400
|
||||||
|
|
||||||
|
# HTTP
|
||||||
|
JONASSEARCH_TIMEOUT=20
|
||||||
|
JONASSEARCH_MAX_CONTENT_LENGTH=5242880
|
||||||
|
|
||||||
|
# User-Agent
|
||||||
|
JONASSEARCH_USER_AGENT=JonasSearchBot/0.1 (+https://jonassearch.local/bot)
|
||||||
+859
@@ -0,0 +1,859 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
JonasSearch Crawler Gamma 0.1
|
||||||
|
|
||||||
|
PostgreSQL-based parallel crawler.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- PostgreSQL queue with row-level locking (FOR UPDATE SKIP LOCKED)
|
||||||
|
- Multiple crawler workers can safely share one queue
|
||||||
|
- Stores title, headings, cleaned text and links
|
||||||
|
- Search vector is maintained by the PostgreSQL trigger
|
||||||
|
- URL deduplication via UNIQUE(url)
|
||||||
|
- Priority queue
|
||||||
|
- Per-host crawl delay
|
||||||
|
- robots.txt support with in-memory cache
|
||||||
|
- Graceful shutdown
|
||||||
|
- Basic crawler statistics
|
||||||
|
|
||||||
|
Expected PostgreSQL schema:
|
||||||
|
pages
|
||||||
|
crawl_queue
|
||||||
|
crawler_stats
|
||||||
|
|
||||||
|
Connection defaults can be overridden with environment variables:
|
||||||
|
JONASSEARCH_DB_HOST
|
||||||
|
JONASSEARCH_DB_PORT
|
||||||
|
JONASSEARCH_DB_NAME
|
||||||
|
JONASSEARCH_DB_USER
|
||||||
|
JONASSEARCH_DB_PASSWORD
|
||||||
|
|
||||||
|
Example:
|
||||||
|
python3 crawler.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import multiprocessing as mp
|
||||||
|
from collections import defaultdict
|
||||||
|
from html import unescape
|
||||||
|
from urllib.parse import urljoin, urlparse, urldefrag
|
||||||
|
from urllib.robotparser import RobotFileParser
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import execute_values
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
|
||||||
|
try:
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
except ImportError:
|
||||||
|
BeautifulSoup = None
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# CONFIG
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
DB_HOST = os.getenv("JONASSEARCH_DB_HOST", "127.0.0.1")
|
||||||
|
DB_PORT = int(os.getenv("JONASSEARCH_DB_PORT", "25432"))
|
||||||
|
DB_NAME = os.getenv("JONASSEARCH_DB_NAME", "jonassearch")
|
||||||
|
DB_USER = os.getenv("JONASSEARCH_DB_USER", "jonassearch")
|
||||||
|
DB_PASSWORD = os.getenv("JONASSEARCH_DB_PASSWORD", "")
|
||||||
|
|
||||||
|
START_URL = os.getenv("JONASSEARCH_START_URL", "https://github.com/")
|
||||||
|
|
||||||
|
WORKERS = int(os.getenv("JONASSEARCH_WORKERS", "3"))
|
||||||
|
|
||||||
|
# Minimum delay between requests to the same host, per worker.
|
||||||
|
# robots.txt can specify a larger crawl delay.
|
||||||
|
DEFAULT_DELAY = float(os.getenv("JONASSEARCH_DELAY", "3.0"))
|
||||||
|
|
||||||
|
ROBOTS_CACHE_SECONDS = int(
|
||||||
|
os.getenv("JONASSEARCH_ROBOTS_CACHE_SECONDS", str(24 * 60 * 60))
|
||||||
|
)
|
||||||
|
|
||||||
|
REQUEST_TIMEOUT = float(os.getenv("JONASSEARCH_TIMEOUT", "20"))
|
||||||
|
|
||||||
|
MAX_CONTENT_LENGTH = int(
|
||||||
|
os.getenv("JONASSEARCH_MAX_CONTENT_LENGTH", str(5 * 1024 * 1024))
|
||||||
|
)
|
||||||
|
|
||||||
|
USER_AGENT = os.getenv(
|
||||||
|
"JONASSEARCH_USER_AGENT",
|
||||||
|
"JonasSearchBot/0.1 (+https://jonassearch.local/bot)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only crawl normal HTTP(S) pages.
|
||||||
|
ALLOWED_SCHEMES = {"http", "https"}
|
||||||
|
|
||||||
|
# Ignore obvious non-HTML resources.
|
||||||
|
BLOCKED_EXTENSIONS = {
|
||||||
|
".7z", ".avi", ".bin", ".bmp", ".bz2", ".class", ".csv", ".doc",
|
||||||
|
".docx", ".gif", ".gz", ".ico", ".iso", ".jpeg", ".jpg", ".m4a",
|
||||||
|
".mkv", ".mov", ".mp3", ".mp4", ".mpeg", ".pdf", ".png", ".ppt",
|
||||||
|
".pptx", ".rar", ".svg", ".tar", ".tgz", ".tif", ".tiff", ".webm",
|
||||||
|
".webp", ".xls", ".xlsx", ".xml", ".zip"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Do not follow these URL schemes.
|
||||||
|
BLOCKED_PREFIXES = (
|
||||||
|
"mailto:",
|
||||||
|
"javascript:",
|
||||||
|
"tel:",
|
||||||
|
"data:",
|
||||||
|
"ftp:",
|
||||||
|
"file:",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Keep generated pages reasonably useful for search.
|
||||||
|
MAX_TITLE_LENGTH = 1000
|
||||||
|
MAX_HEADINGS = 200
|
||||||
|
MAX_HEADING_LENGTH = 1000
|
||||||
|
MAX_TEXT_LENGTH = 2_000_000
|
||||||
|
MAX_LINKS = 10_000
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# LOGGING / SHUTDOWN
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(processName)s] %(levelname)s %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
|
STOP = mp.Event()
|
||||||
|
|
||||||
|
|
||||||
|
def handle_signal(signum, _frame):
|
||||||
|
logging.info("Shutdown signal received.")
|
||||||
|
STOP.set()
|
||||||
|
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, handle_signal)
|
||||||
|
signal.signal(signal.SIGTERM, handle_signal)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# DATABASE
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def db_connect():
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=DB_HOST,
|
||||||
|
port=DB_PORT,
|
||||||
|
dbname=DB_NAME,
|
||||||
|
user=DB_USER,
|
||||||
|
password=DB_PASSWORD,
|
||||||
|
connect_timeout=10,
|
||||||
|
application_name="jonassearch-crawler",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_stats_table(conn):
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS crawler_stats (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
crawled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
queued INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failed INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def write_stats(conn, crawled, queued, failed):
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO crawler_stats (crawled, queued, failed)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
""", (crawled, queued, failed))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def get_next_queue_item(conn):
|
||||||
|
"""
|
||||||
|
Atomically claim one pending URL.
|
||||||
|
|
||||||
|
SKIP LOCKED is the important part: several crawler processes can
|
||||||
|
consume the same PostgreSQL queue without waiting for each other.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
""")
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def finish_queue_item(conn, queue_id, success=True):
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE crawl_queue
|
||||||
|
SET status = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""", ("done" if success else "failed", queue_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def requeue_stale_items(conn, older_than_seconds=3600):
|
||||||
|
"""
|
||||||
|
Recover URLs left in 'processing' after a crash.
|
||||||
|
"""
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE crawl_queue
|
||||||
|
SET status = 'pending'
|
||||||
|
WHERE status = 'processing'
|
||||||
|
AND last_attempt_at < now() - (%s * interval '1 second')
|
||||||
|
""", (older_than_seconds,))
|
||||||
|
changed = cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
if changed:
|
||||||
|
logging.info("Requeued %d stale processing URLs.", changed)
|
||||||
|
|
||||||
|
|
||||||
|
def queue_urls(conn, links, source_url):
|
||||||
|
if not links:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Link URLs also come from the web and can carry NUL bytes.
|
||||||
|
safe_links = clean_text_list(links)
|
||||||
|
safe_source = clean_text(source_url)
|
||||||
|
|
||||||
|
rows = [(url, safe_source, 0, "pending") for url in safe_links]
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
execute_values(
|
||||||
|
cur,
|
||||||
|
"""
|
||||||
|
INSERT INTO crawl_queue (url, source_url, priority, status)
|
||||||
|
VALUES %s
|
||||||
|
ON CONFLICT (url) DO NOTHING
|
||||||
|
""",
|
||||||
|
rows,
|
||||||
|
template="(%s, %s, %s, %s)",
|
||||||
|
page_size=500,
|
||||||
|
)
|
||||||
|
inserted = cur.rowcount
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# TEXT SANITIZATION
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def clean_text(value):
|
||||||
|
"""
|
||||||
|
Remove NUL characters from a string.
|
||||||
|
|
||||||
|
Web content can contain a literal "\x00" anywhere in the extracted
|
||||||
|
text. PostgreSQL rejects NUL bytes inside ordinary text columns, so a
|
||||||
|
single dirty string would abort the INSERT and crash the worker.
|
||||||
|
Sanitizing centrally before every DB write keeps the crawl running.
|
||||||
|
"""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.replace("\x00", "")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def clean_text_list(values):
|
||||||
|
"""Sanitize every string of a list (headings, links, ...)."""
|
||||||
|
return [clean_text(v) for v in values]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# URL NORMALIZATION
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def normalize_url(url, base_url=None):
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
url = unescape(url).strip()
|
||||||
|
|
||||||
|
if base_url:
|
||||||
|
url = urljoin(base_url, url)
|
||||||
|
|
||||||
|
if any(url.lower().startswith(prefix) for prefix in BLOCKED_PREFIXES):
|
||||||
|
return None
|
||||||
|
|
||||||
|
url, _fragment = urldefrag(url)
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
|
||||||
|
if parsed.scheme.lower() not in ALLOWED_SCHEMES:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not parsed.netloc:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Lowercase scheme and hostname.
|
||||||
|
scheme = parsed.scheme.lower()
|
||||||
|
hostname = (parsed.hostname or "").lower()
|
||||||
|
|
||||||
|
if not hostname:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Drop default ports.
|
||||||
|
port = parsed.port
|
||||||
|
if (scheme == "http" and port == 80) or (scheme == "https" and port == 443):
|
||||||
|
port = None
|
||||||
|
|
||||||
|
if port:
|
||||||
|
netloc = f"{hostname}:{port}"
|
||||||
|
else:
|
||||||
|
netloc = hostname
|
||||||
|
|
||||||
|
# Preserve path/query, but remove empty query marker.
|
||||||
|
path = parsed.path or "/"
|
||||||
|
query = parsed.query
|
||||||
|
|
||||||
|
normalized = f"{scheme}://{netloc}{path}"
|
||||||
|
if query:
|
||||||
|
normalized += "?" + query
|
||||||
|
|
||||||
|
# Avoid obvious files.
|
||||||
|
path_lower = path.lower()
|
||||||
|
for ext in BLOCKED_EXTENSIONS:
|
||||||
|
if path_lower.endswith(ext):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ROBOTS
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class RobotsCache:
|
||||||
|
def __init__(self):
|
||||||
|
self.cache = {}
|
||||||
|
self.last_checked = {}
|
||||||
|
|
||||||
|
def allowed(self, url):
|
||||||
|
parsed = urlparse(url)
|
||||||
|
host = parsed.netloc.lower()
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
|
||||||
|
if (
|
||||||
|
host not in self.cache
|
||||||
|
or now - self.last_checked.get(host, 0) > ROBOTS_CACHE_SECONDS
|
||||||
|
):
|
||||||
|
self._load(host, parsed.scheme)
|
||||||
|
|
||||||
|
rp = self.cache[host]
|
||||||
|
try:
|
||||||
|
return rp.can_fetch(USER_AGENT, url)
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delay(self, url):
|
||||||
|
parsed = urlparse(url)
|
||||||
|
host = parsed.netloc.lower()
|
||||||
|
|
||||||
|
rp = self.cache.get(host)
|
||||||
|
if not rp:
|
||||||
|
return DEFAULT_DELAY
|
||||||
|
|
||||||
|
# RobotFileParser does not expose Crawl-delay consistently across
|
||||||
|
# all Python versions, so use it when available.
|
||||||
|
try:
|
||||||
|
delay = rp.crawl_delay(USER_AGENT)
|
||||||
|
if delay is None:
|
||||||
|
delay = rp.crawl_delay("*")
|
||||||
|
if delay is not None:
|
||||||
|
return max(DEFAULT_DELAY, float(delay))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return DEFAULT_DELAY
|
||||||
|
|
||||||
|
def _load(self, host, scheme):
|
||||||
|
robots_url = f"{scheme}://{host}/robots.txt"
|
||||||
|
|
||||||
|
rp = RobotFileParser()
|
||||||
|
rp.set_url(robots_url)
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = Request(
|
||||||
|
robots_url,
|
||||||
|
headers={"User-Agent": USER_AGENT}
|
||||||
|
)
|
||||||
|
with urlopen(req, timeout=REQUEST_TIMEOUT) as response:
|
||||||
|
data = response.read(512 * 1024).decode(
|
||||||
|
"utf-8", errors="replace"
|
||||||
|
)
|
||||||
|
rp.parse(data.splitlines())
|
||||||
|
except Exception:
|
||||||
|
# If robots.txt cannot be retrieved, default to allowing the URL.
|
||||||
|
# This is deliberately conservative with respect to availability.
|
||||||
|
rp.parse([])
|
||||||
|
|
||||||
|
self.cache[host] = rp
|
||||||
|
self.last_checked[host] = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# HTTP
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class RateLimiter:
|
||||||
|
def __init__(self):
|
||||||
|
self.last_request = defaultdict(float)
|
||||||
|
|
||||||
|
def wait(self, url, delay):
|
||||||
|
host = urlparse(url).netloc.lower()
|
||||||
|
now = time.monotonic()
|
||||||
|
wait_for = delay - (now - self.last_request[host])
|
||||||
|
|
||||||
|
if wait_for > 0:
|
||||||
|
logging.info("[RATE] %s: %.1fs", host, wait_for)
|
||||||
|
time.sleep(wait_for)
|
||||||
|
|
||||||
|
self.last_request[host] = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(url, limiter, robots):
|
||||||
|
parsed = urlparse(url)
|
||||||
|
|
||||||
|
if not robots.allowed(url):
|
||||||
|
return None, None, "robots"
|
||||||
|
|
||||||
|
delay = robots.delay(url)
|
||||||
|
limiter.wait(url, delay)
|
||||||
|
|
||||||
|
req = Request(
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
"User-Agent": USER_AGENT,
|
||||||
|
"Accept": (
|
||||||
|
"text/html,application/xhtml+xml,"
|
||||||
|
"application/xml;q=0.9,*/*;q=0.1"
|
||||||
|
),
|
||||||
|
"Accept-Language": "en,de;q=0.8",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urlopen(req, timeout=REQUEST_TIMEOUT) as response:
|
||||||
|
status = getattr(response, "status", 200)
|
||||||
|
content_type = response.headers.get("Content-Type", "")
|
||||||
|
content_length = response.headers.get("Content-Length")
|
||||||
|
|
||||||
|
if content_length:
|
||||||
|
try:
|
||||||
|
if int(content_length) > MAX_CONTENT_LENGTH:
|
||||||
|
return status, None, "too_large"
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# We only parse HTML/XHTML.
|
||||||
|
ctype = content_type.lower()
|
||||||
|
if ctype and not (
|
||||||
|
"text/html" in ctype
|
||||||
|
or "application/xhtml+xml" in ctype
|
||||||
|
):
|
||||||
|
return status, None, "not_html"
|
||||||
|
|
||||||
|
data = response.read(MAX_CONTENT_LENGTH + 1)
|
||||||
|
|
||||||
|
if len(data) > MAX_CONTENT_LENGTH:
|
||||||
|
return status, None, "too_large"
|
||||||
|
|
||||||
|
charset = response.headers.get_content_charset() or "utf-8"
|
||||||
|
text = data.decode(charset, errors="replace")
|
||||||
|
|
||||||
|
return status, text, None
|
||||||
|
|
||||||
|
except HTTPError as e:
|
||||||
|
return e.code, None, f"http_{e.code}"
|
||||||
|
except (URLError, TimeoutError, OSError) as e:
|
||||||
|
return None, None, f"network:{e}"
|
||||||
|
except Exception as e:
|
||||||
|
return None, None, f"error:{e}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# HTML PARSING
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def clean_text_bs4(soup):
|
||||||
|
for tag in soup([
|
||||||
|
"script", "style", "noscript", "template",
|
||||||
|
"svg", "canvas", "iframe", "object",
|
||||||
|
"embed", "form"
|
||||||
|
]):
|
||||||
|
tag.decompose()
|
||||||
|
|
||||||
|
# Remove navigation/boilerplate-ish elements where possible.
|
||||||
|
for tag in soup.find_all([
|
||||||
|
"nav", "footer", "header", "aside"
|
||||||
|
]):
|
||||||
|
tag.decompose()
|
||||||
|
|
||||||
|
text = soup.get_text(" ", strip=True)
|
||||||
|
|
||||||
|
# Normalize whitespace.
|
||||||
|
text = re.sub(r"\s+", " ", text)
|
||||||
|
return text[:MAX_TEXT_LENGTH]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_html(html, base_url):
|
||||||
|
if BeautifulSoup is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"BeautifulSoup fehlt. Installiere python3-bs4."
|
||||||
|
)
|
||||||
|
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
|
||||||
|
title = ""
|
||||||
|
if soup.title:
|
||||||
|
title = soup.title.get_text(" ", strip=True)
|
||||||
|
|
||||||
|
title = re.sub(r"\s+", " ", title)[:MAX_TITLE_LENGTH]
|
||||||
|
|
||||||
|
headings = []
|
||||||
|
for tag in soup.find_all(re.compile(r"^h[1-6]$")):
|
||||||
|
value = re.sub(r"\s+", " ", tag.get_text(" ", strip=True))
|
||||||
|
if value:
|
||||||
|
headings.append(value[:MAX_HEADING_LENGTH])
|
||||||
|
if len(headings) >= MAX_HEADINGS:
|
||||||
|
break
|
||||||
|
|
||||||
|
links = []
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
for tag in soup.find_all("a", href=True):
|
||||||
|
normalized = normalize_url(tag.get("href"), base_url)
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if normalized in seen:
|
||||||
|
continue
|
||||||
|
|
||||||
|
seen.add(normalized)
|
||||||
|
links.append(normalized)
|
||||||
|
|
||||||
|
if len(links) >= MAX_LINKS:
|
||||||
|
break
|
||||||
|
|
||||||
|
content = clean_text_bs4(soup)
|
||||||
|
|
||||||
|
domain = urlparse(base_url).hostname or ""
|
||||||
|
domain = domain.lower()
|
||||||
|
|
||||||
|
# TLD is intentionally simple here; the DB can later be extended with
|
||||||
|
# Public Suffix List handling if needed.
|
||||||
|
tld = ""
|
||||||
|
if "." in domain:
|
||||||
|
tld = "." + domain.rsplit(".", 1)[-1]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": title,
|
||||||
|
"headings": headings,
|
||||||
|
"content": content,
|
||||||
|
"links": links,
|
||||||
|
"domain": domain,
|
||||||
|
"tld": tld,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# PAGE STORAGE
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def save_page(conn, url, source_url, status, content_type, parsed, crawl_ms):
|
||||||
|
# Sanitize everything that came from the web before it reaches
|
||||||
|
# PostgreSQL. NUL bytes are legal in HTML but illegal in text columns.
|
||||||
|
url = clean_text(url)
|
||||||
|
source_url = clean_text(source_url)
|
||||||
|
content_type = clean_text(content_type)
|
||||||
|
|
||||||
|
safe_parsed = {
|
||||||
|
"title": clean_text(parsed["title"]),
|
||||||
|
"headings": clean_text_list(parsed["headings"]),
|
||||||
|
"content": clean_text(parsed["content"]),
|
||||||
|
"links": clean_text_list(parsed["links"]),
|
||||||
|
"domain": clean_text(parsed["domain"]),
|
||||||
|
"tld": clean_text(parsed["tld"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO pages (
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
headings,
|
||||||
|
content,
|
||||||
|
links,
|
||||||
|
domain,
|
||||||
|
tld,
|
||||||
|
source_url,
|
||||||
|
http_status,
|
||||||
|
content_type,
|
||||||
|
discovered_at,
|
||||||
|
crawled_at,
|
||||||
|
crawl_time_ms
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT discovered_at
|
||||||
|
FROM crawl_queue
|
||||||
|
WHERE url = %s),
|
||||||
|
now()
|
||||||
|
),
|
||||||
|
now(),
|
||||||
|
%s
|
||||||
|
)
|
||||||
|
ON CONFLICT (url) DO UPDATE SET
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
headings = EXCLUDED.headings,
|
||||||
|
content = EXCLUDED.content,
|
||||||
|
links = EXCLUDED.links,
|
||||||
|
domain = EXCLUDED.domain,
|
||||||
|
tld = EXCLUDED.tld,
|
||||||
|
source_url = EXCLUDED.source_url,
|
||||||
|
http_status = EXCLUDED.http_status,
|
||||||
|
content_type = EXCLUDED.content_type,
|
||||||
|
crawled_at = now(),
|
||||||
|
crawl_time_ms = EXCLUDED.crawl_time_ms
|
||||||
|
""", (
|
||||||
|
url,
|
||||||
|
safe_parsed["title"],
|
||||||
|
safe_parsed["headings"],
|
||||||
|
safe_parsed["content"],
|
||||||
|
safe_parsed["links"],
|
||||||
|
safe_parsed["domain"],
|
||||||
|
safe_parsed["tld"],
|
||||||
|
source_url,
|
||||||
|
status,
|
||||||
|
content_type,
|
||||||
|
url,
|
||||||
|
crawl_ms,
|
||||||
|
))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# WORKER
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def worker(worker_id):
|
||||||
|
process_name = f"crawler-{worker_id}"
|
||||||
|
mp.current_process().name = process_name
|
||||||
|
|
||||||
|
conn = None
|
||||||
|
robots = RobotsCache()
|
||||||
|
limiter = RateLimiter()
|
||||||
|
|
||||||
|
crawled = 0
|
||||||
|
failed = 0
|
||||||
|
last_stats = time.monotonic()
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = db_connect()
|
||||||
|
|
||||||
|
logging.info(
|
||||||
|
"Started. DB=%s:%s/%s",
|
||||||
|
DB_HOST, DB_PORT, DB_NAME
|
||||||
|
)
|
||||||
|
|
||||||
|
while not STOP.is_set():
|
||||||
|
item = get_next_queue_item(conn)
|
||||||
|
|
||||||
|
if not item:
|
||||||
|
logging.info("Queue empty. Worker waiting...")
|
||||||
|
time.sleep(5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
queue_id, url, source_url = item
|
||||||
|
|
||||||
|
logging.info("=" * 70)
|
||||||
|
logging.info("[CRAWL] %s", url)
|
||||||
|
logging.info("[FROM ] %s", source_url or "-")
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
|
||||||
|
status, html, error = fetch(url, limiter, robots)
|
||||||
|
|
||||||
|
if error:
|
||||||
|
logging.info("[HTTP ] %s -> %s", url, error)
|
||||||
|
finish_queue_item(conn, queue_id, success=False)
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
logging.info("[HTTP ] %s -> HTTP %s", url, status)
|
||||||
|
|
||||||
|
# A redirect normally arrives as the final URL in urllib.
|
||||||
|
# The requested URL remains the canonical queue identity.
|
||||||
|
if html is None:
|
||||||
|
finish_queue_item(conn, queue_id, success=False)
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = parse_html(html, url)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.exception("[PARSE] %s: %s", url, exc)
|
||||||
|
finish_queue_item(conn, queue_id, success=False)
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
crawl_ms = int((time.monotonic() - started) * 1000)
|
||||||
|
|
||||||
|
logging.info("[TITLE] %s", parsed["title"][:160])
|
||||||
|
logging.info("[DOMAIN] %s", parsed["domain"])
|
||||||
|
logging.info("[HEADS ] %d", len(parsed["headings"]))
|
||||||
|
logging.info("[LINKS ] %d gefunden", len(parsed["links"]))
|
||||||
|
|
||||||
|
save_page(
|
||||||
|
conn=conn,
|
||||||
|
url=url,
|
||||||
|
source_url=source_url,
|
||||||
|
status=status,
|
||||||
|
content_type="text/html",
|
||||||
|
parsed=parsed,
|
||||||
|
crawl_ms=crawl_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
inserted = queue_urls(
|
||||||
|
conn,
|
||||||
|
parsed["links"],
|
||||||
|
url
|
||||||
|
)
|
||||||
|
|
||||||
|
logging.info(
|
||||||
|
"[SORT ] %s -> %d Links, %d neue URLs",
|
||||||
|
url,
|
||||||
|
len(parsed["links"]),
|
||||||
|
inserted,
|
||||||
|
)
|
||||||
|
|
||||||
|
finish_queue_item(conn, queue_id, success=True)
|
||||||
|
|
||||||
|
crawled += 1
|
||||||
|
|
||||||
|
# Periodic stats; not on every page to avoid unnecessary DB load.
|
||||||
|
if time.monotonic() - last_stats >= 30:
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*) FROM crawl_queue WHERE status='pending'"
|
||||||
|
)
|
||||||
|
queued = cur.fetchone()[0]
|
||||||
|
|
||||||
|
write_stats(conn, crawled, queued, failed)
|
||||||
|
last_stats = time.monotonic()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Worker crashed.")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if conn is not None:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
logging.info(
|
||||||
|
"Stopped. crawled=%d failed=%d",
|
||||||
|
crawled, failed
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# STARTUP
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 70)
|
||||||
|
print(" JonasSearch Crawler Gamma 0.1")
|
||||||
|
print("=" * 70)
|
||||||
|
print(f"Start: {START_URL}")
|
||||||
|
print(f"Workers: {WORKERS}")
|
||||||
|
print(f"Delay: {DEFAULT_DELAY:.1f} Sekunden")
|
||||||
|
print(f"Robots-Cache: {ROBOTS_CACHE_SECONDS // 3600} Stunden")
|
||||||
|
print(f"DB: {DB_HOST}:{DB_PORT}/{DB_NAME}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
conn = db_connect()
|
||||||
|
|
||||||
|
ensure_stats_table(conn)
|
||||||
|
requeue_stale_items(conn)
|
||||||
|
|
||||||
|
# Seed URL. ON CONFLICT makes restarting harmless.
|
||||||
|
queue_urls(conn, [normalize_url(START_URL)], None)
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
workers = []
|
||||||
|
|
||||||
|
for i in range(WORKERS):
|
||||||
|
p = mp.Process(target=worker, args=(i,), name=f"crawler-{i}")
|
||||||
|
p.start()
|
||||||
|
workers.append(p)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while any(p.is_alive() for p in workers):
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# If a worker dies unexpectedly, stop the remaining workers.
|
||||||
|
for p in workers:
|
||||||
|
if p.exitcode not in (None, 0):
|
||||||
|
logging.error(
|
||||||
|
"Worker %s exited with code %s.",
|
||||||
|
p.name,
|
||||||
|
p.exitcode,
|
||||||
|
)
|
||||||
|
STOP.set()
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
STOP.set()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
STOP.set()
|
||||||
|
|
||||||
|
for p in workers:
|
||||||
|
p.join(timeout=10)
|
||||||
|
|
||||||
|
for p in workers:
|
||||||
|
if p.is_alive():
|
||||||
|
p.terminate()
|
||||||
|
p.join()
|
||||||
|
|
||||||
|
print("Crawler stopped.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mp.set_start_method("spawn", force=True)
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user