add pictures
This commit is contained in:
+281
-33
@@ -7,7 +7,11 @@ 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
|
||||
- Stores title, headings, cleaned text, links and image URLs
|
||||
- Image URLs from <img src/srcset>, <picture>/<source>, og:image,
|
||||
twitter:image and link rel="image_src" are resolved to absolute
|
||||
http(s) URLs, deduplicated and stored in pages.pictures
|
||||
(migration: add_pages_pictures.sql)
|
||||
- Search vector is maintained by the PostgreSQL trigger
|
||||
- URL deduplication via UNIQUE(url)
|
||||
- Deadlock-safe parallel queue inserts (deterministic ordering,
|
||||
@@ -37,7 +41,7 @@ Features:
|
||||
release) and --update-deployment N (bump generation + required_version)
|
||||
|
||||
Expected PostgreSQL schema:
|
||||
pages
|
||||
pages (incl. pictures text[], see add_pages_pictures.sql)
|
||||
crawl_queue
|
||||
crawler_stats
|
||||
|
||||
@@ -150,7 +154,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", "3"))
|
||||
CRAWLER_VERSION = int(os.getenv("JONASSEARCH_CRAWLER_VERSION", "5"))
|
||||
|
||||
# Exit codes. 42 is reserved exclusively for "I must be updated" so
|
||||
# the launcher can distinguish update, crash and normal exit.
|
||||
@@ -235,6 +239,15 @@ MAX_HEADING_LENGTH = 1000
|
||||
MAX_TEXT_LENGTH = 2_000_000
|
||||
MAX_LINKS = 10_000
|
||||
|
||||
# Image URLs per page. A page with thousands of <img>/srcset entries
|
||||
# would otherwise bloat pages.pictures for no search value; the first
|
||||
# images in document order are kept.
|
||||
MAX_PICTURES = 1_000
|
||||
|
||||
# Hard cap for one image URL (text[] has no B-tree limit, but absurd
|
||||
# data: URLs / tracking parameters are not worth storing).
|
||||
MAX_IMAGE_URL_LENGTH = 2_000
|
||||
|
||||
# Cap for the text that feeds the full-text index. PostgreSQL
|
||||
# hard-limits a single tsvector value to 1,048,575 bytes (not
|
||||
# configurable), and the trigger pages_search_vector_update() builds
|
||||
@@ -281,11 +294,23 @@ 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.
|
||||
# still finding crawlable hosts behind a small wall of blocked ones.
|
||||
# Larger walls are handled in SQL: hosts that are known to be busy are
|
||||
# excluded from the candidate query itself (see busy_hosts()), so a
|
||||
# blocked host with thousands of queued URLs cannot hide the rest of
|
||||
# the queue behind it.
|
||||
QUEUE_CLAIM_SCAN_LIMIT = int(
|
||||
os.getenv("JONASSEARCH_CLAIM_SCAN_LIMIT", "200")
|
||||
)
|
||||
|
||||
# Upper bound for the host list that is passed into the claim query.
|
||||
# Longest-running blocks are kept first, because those form the wall at
|
||||
# the head of the priority order; hosts beyond the cap are still
|
||||
# skipped by the per-candidate check, they are just not pre-filtered.
|
||||
QUEUE_CLAIM_BUSY_HOST_LIMIT = int(
|
||||
os.getenv("JONASSEARCH_CLAIM_BUSY_HOST_LIMIT", "1000")
|
||||
)
|
||||
|
||||
# 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.
|
||||
@@ -738,11 +763,18 @@ def claim_next_queue_item(conn, scheduler, robots):
|
||||
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.
|
||||
other. Hosts that the HostScheduler already knows to be busy
|
||||
(per-host crawl delay, active block backoff) are excluded by the
|
||||
query itself. For the remaining candidates the scheduler decides
|
||||
whether a host may be contacted right now. The first ready
|
||||
candidate is claimed, so the worker automatically works on another
|
||||
domain while a rate-limited or blocked host cools down.
|
||||
|
||||
The SQL-level host filter is what keeps a blocked host from
|
||||
starving the whole queue: blocked rows stay 'pending' at the top
|
||||
of the priority order, and without the filter a wall of them could
|
||||
fill every scan window forever while tens of thousands of other
|
||||
hosts wait behind it.
|
||||
|
||||
Returns (item, wait_hint):
|
||||
item (id, url, source_url) of the claimed URL, or None
|
||||
@@ -757,27 +789,28 @@ def claim_next_queue_item(conn, scheduler, robots):
|
||||
wait_hint = None
|
||||
item = None
|
||||
|
||||
busy_hosts = scheduler.busy_hosts(now)
|
||||
# Longest waits first: those hosts form the wall at the head of the
|
||||
# priority order and are the most valuable ones to exclude.
|
||||
excluded_hosts = [
|
||||
host for host, _wait in sorted(
|
||||
busy_hosts.items(), key=lambda pair: pair[1], reverse=True
|
||||
)
|
||||
][:QUEUE_CLAIM_BUSY_HOST_LIMIT]
|
||||
|
||||
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,))
|
||||
cur.execute("""
|
||||
SELECT id, url, source_url
|
||||
FROM crawl_queue
|
||||
WHERE status = 'pending'
|
||||
AND NOT (id = ANY(%s::bigint[]))
|
||||
AND lower(split_part(split_part(url, '://', 2), '/', 1))
|
||||
<> ALL(%s::text[])
|
||||
ORDER BY priority DESC, discovered_at ASC, id ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT %s
|
||||
""", (seen_ids, excluded_hosts, QUEUE_CLAIM_BATCH))
|
||||
candidates = cur.fetchall()
|
||||
|
||||
if not candidates:
|
||||
@@ -815,6 +848,18 @@ def claim_next_queue_item(conn, scheduler, robots):
|
||||
if len(candidates) < QUEUE_CLAIM_BATCH:
|
||||
break
|
||||
|
||||
if item is None and scanned == 0 and excluded_hosts:
|
||||
# The busy-host filter removed every pending URL from the query
|
||||
# before it could return a single candidate. Report when the
|
||||
# soonest host is crawlable again - but only if there really are
|
||||
# pending URLs; otherwise the queue is simply empty.
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT 1 FROM crawl_queue WHERE status = 'pending' LIMIT 1"
|
||||
)
|
||||
if cur.fetchone() is not None:
|
||||
wait_hint = min(busy_hosts.values())
|
||||
|
||||
return item, wait_hint
|
||||
|
||||
|
||||
@@ -1584,6 +1629,48 @@ def normalize_url(url, base_url=None):
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_image_url(raw, base_url):
|
||||
"""
|
||||
Turn an image reference from HTML into an absolute http(s) URL.
|
||||
|
||||
Unlike normalize_url() this does NOT reject image file extensions
|
||||
(that list exists to keep binary resources out of the page queue).
|
||||
Relative paths and protocol-relative URLs are resolved against the
|
||||
page URL; fragments are dropped because they do not identify a
|
||||
different image resource. data:, javascript:, ftp: and other
|
||||
non-http(s) sources are rejected, as are empty/whitespace values,
|
||||
non-public hosts and URLs that are unreasonably long.
|
||||
"""
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
|
||||
raw = unescape(raw).strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
try:
|
||||
absolute = urljoin(base_url, raw)
|
||||
parsed = urlparse(absolute)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if parsed.scheme.lower() not in ALLOWED_SCHEMES:
|
||||
return None
|
||||
if not parsed.hostname:
|
||||
return None
|
||||
if is_non_crawlable_host(parsed.hostname):
|
||||
return None
|
||||
|
||||
# Fragments never select a different image file.
|
||||
clean, _fragment = urldefrag(absolute)
|
||||
if any(char.isspace() for char in clean):
|
||||
return None
|
||||
if len(clean) > MAX_IMAGE_URL_LENGTH:
|
||||
return None
|
||||
|
||||
return clean
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ROBOTS
|
||||
# ============================================================
|
||||
@@ -1688,6 +1775,10 @@ class HostScheduler:
|
||||
self.last_request = {}
|
||||
self.blocked_until = {}
|
||||
self.block_count = {}
|
||||
# Delay last used for a host (robots.txt crawl-delay or
|
||||
# DEFAULT_DELAY), so busy_hosts() can report the host's schedule
|
||||
# without looking at any URL again.
|
||||
self.host_delay = {}
|
||||
|
||||
@staticmethod
|
||||
def _host(url):
|
||||
@@ -1703,12 +1794,54 @@ class HostScheduler:
|
||||
host = self._host(url)
|
||||
if not host:
|
||||
return 0.0
|
||||
self.host_delay[host] = delay
|
||||
last = self.last_request.get(host)
|
||||
# A host that was never contacted has no pending crawl delay:
|
||||
# monotonic() has an arbitrary origin, so treating "never" as 0
|
||||
# and adding a large robots delay would wrongly block the host
|
||||
# for a long time on a freshly booted machine.
|
||||
rate_until = last + delay if last is not None else 0.0
|
||||
next_allowed = max(
|
||||
self.last_request.get(host, 0.0) + delay,
|
||||
rate_until,
|
||||
self.blocked_until.get(host, 0.0),
|
||||
)
|
||||
return max(0.0, next_allowed - now)
|
||||
|
||||
def busy_hosts(self, now=None):
|
||||
"""
|
||||
Map of hosts that cannot be contacted right now to the number
|
||||
of seconds they still have to wait.
|
||||
|
||||
The queue claim passes these hosts into SQL: skipping busy hosts
|
||||
only within the small candidate window would let a wall of
|
||||
blocked URLs at the head of the priority order hide every other
|
||||
crawlable host in the queue (the wall stays pending at the top,
|
||||
so each rescan looks at the very same rows forever).
|
||||
|
||||
Expired entries are pruned here because this runs once per
|
||||
claim - the per-process state then cannot grow without bound on
|
||||
a queue with millions of distinct hosts.
|
||||
"""
|
||||
if now is None:
|
||||
now = time.monotonic()
|
||||
|
||||
busy = {}
|
||||
|
||||
for host, last in list(self.last_request.items()):
|
||||
next_allowed = last + self.host_delay.get(host, 0.0)
|
||||
if next_allowed > now:
|
||||
busy[host] = next_allowed - now
|
||||
else:
|
||||
del self.last_request[host]
|
||||
|
||||
for host, until in list(self.blocked_until.items()):
|
||||
if until > now:
|
||||
busy[host] = max(busy.get(host, 0.0), until - now)
|
||||
else:
|
||||
del self.blocked_until[host]
|
||||
|
||||
return busy
|
||||
|
||||
def is_ready(self, url, delay, now=None):
|
||||
return self.wait_time(url, delay, now) <= 0.0
|
||||
|
||||
@@ -1725,14 +1858,16 @@ class HostScheduler:
|
||||
stays untouched, so the item is retried exactly when the delay
|
||||
has passed.
|
||||
"""
|
||||
host = self._host(url)
|
||||
self.host_delay[host] = delay
|
||||
wait_for = self.wait_time(url, delay)
|
||||
if wait_for > MAX_INLINE_RATE_WAIT:
|
||||
return False
|
||||
if wait_for > 0:
|
||||
if wait_for >= 1.0:
|
||||
logging.info("[RATE ] %s: %.1fs", self._host(url), wait_for)
|
||||
logging.info("[RATE ] %s: %.1fs", host, wait_for)
|
||||
time.sleep(wait_for)
|
||||
self.last_request[self._host(url)] = time.monotonic()
|
||||
self.last_request[host] = time.monotonic()
|
||||
return True
|
||||
|
||||
def _register_block(self, host, seconds):
|
||||
@@ -1926,6 +2061,104 @@ def clean_text_bs4(soup):
|
||||
return text[:MAX_TEXT_LENGTH]
|
||||
|
||||
|
||||
# Common attributes used for lazy-loaded images. They are not
|
||||
# standardized, but these three are reliable enough in practice.
|
||||
IMAGE_LAZY_SRC_ATTRS = ("data-src", "data-lazy-src", "data-original")
|
||||
|
||||
# srcset appears on <img> and on <source> inside <picture>.
|
||||
IMAGE_SRCSET_ATTRS = ("srcset", "data-srcset")
|
||||
|
||||
# Social/social-preview image metadata.
|
||||
IMAGE_META_KEYS = frozenset({
|
||||
"og:image",
|
||||
"og:image:url",
|
||||
"og:image:secure_url",
|
||||
"twitter:image",
|
||||
"twitter:image:src",
|
||||
})
|
||||
|
||||
|
||||
def _srcset_candidates(value):
|
||||
"""
|
||||
Yield the URL part of every srcset candidate.
|
||||
|
||||
srcset format: "img.jpg 1x, img@2x.jpg 2x" (or a width descriptor).
|
||||
Parsed like the HTML spec: whitespace separates URL and descriptor,
|
||||
a trailing comma ends a candidate. This keeps commas inside URLs
|
||||
(including data: URLs, which are rejected later) from being split
|
||||
into bogus relative candidates.
|
||||
"""
|
||||
if not value:
|
||||
return
|
||||
|
||||
tokens = str(value).split()
|
||||
index = 0
|
||||
while index < len(tokens):
|
||||
candidate = tokens[index]
|
||||
index += 1
|
||||
if not candidate:
|
||||
continue
|
||||
|
||||
ends_candidate = candidate.endswith(",")
|
||||
candidate = candidate.rstrip(",").strip()
|
||||
if not candidate:
|
||||
continue
|
||||
|
||||
if not ends_candidate:
|
||||
# The next token is the descriptor ("2x", "640w"), if any.
|
||||
index += 1
|
||||
|
||||
yield candidate
|
||||
|
||||
|
||||
def extract_pictures(soup, base_url):
|
||||
"""
|
||||
All absolute, deduplicated image URLs of a parsed document.
|
||||
|
||||
Sources: <img src>, lazy-loading data-* attributes, <img srcset>,
|
||||
<picture> via <source srcset>, Open-Graph/Twitter preview images and
|
||||
<link rel="image_src">. Order of first appearance is preserved.
|
||||
"""
|
||||
pictures = []
|
||||
seen = set()
|
||||
|
||||
def add(raw):
|
||||
if len(pictures) >= MAX_PICTURES:
|
||||
return
|
||||
normalized = normalize_image_url(raw, base_url)
|
||||
if not normalized or normalized in seen:
|
||||
return
|
||||
seen.add(normalized)
|
||||
pictures.append(normalized)
|
||||
|
||||
for tag in soup.find_all("img"):
|
||||
add(tag.get("src"))
|
||||
for attr in IMAGE_LAZY_SRC_ATTRS:
|
||||
add(tag.get(attr))
|
||||
for attr in IMAGE_SRCSET_ATTRS:
|
||||
for candidate in _srcset_candidates(tag.get(attr)):
|
||||
add(candidate)
|
||||
|
||||
for tag in soup.find_all("source"):
|
||||
for attr in IMAGE_SRCSET_ATTRS:
|
||||
for candidate in _srcset_candidates(tag.get(attr)):
|
||||
add(candidate)
|
||||
|
||||
for tag in soup.find_all("meta"):
|
||||
key = (tag.get("property") or tag.get("name") or "").strip().lower()
|
||||
if key in IMAGE_META_KEYS:
|
||||
add(tag.get("content"))
|
||||
|
||||
for tag in soup.find_all("link"):
|
||||
rel = tag.get("rel") or []
|
||||
if isinstance(rel, str):
|
||||
rel = rel.lower().split()
|
||||
if any(str(value).lower() == "image_src" for value in rel):
|
||||
add(tag.get("href"))
|
||||
|
||||
return pictures
|
||||
|
||||
|
||||
def parse_html(html, base_url):
|
||||
if BeautifulSoup is None:
|
||||
raise RuntimeError(
|
||||
@@ -1965,6 +2198,10 @@ def parse_html(html, base_url):
|
||||
if len(links) >= MAX_LINKS:
|
||||
break
|
||||
|
||||
# Extract images BEFORE clean_text_bs4() mutates the tree (it
|
||||
# removes navigation, noscript, svg, ... elements).
|
||||
pictures = extract_pictures(soup, base_url)
|
||||
|
||||
content = clean_text_bs4(soup)
|
||||
|
||||
domain = urlparse(base_url).hostname or ""
|
||||
@@ -1981,6 +2218,7 @@ def parse_html(html, base_url):
|
||||
"headings": headings,
|
||||
"content": content,
|
||||
"links": links,
|
||||
"pictures": pictures,
|
||||
"domain": domain,
|
||||
"tld": tld,
|
||||
}
|
||||
@@ -2006,6 +2244,11 @@ def save_page(conn, url, source_url, status, content_type, parsed, crawl_ms):
|
||||
"headings": clean_text_list(parsed["headings"]),
|
||||
"content": clean_text(parsed["content"]),
|
||||
"links": clean_text_list(parsed["links"]),
|
||||
# parse_html() already deduplicates; repeat it here so a caller
|
||||
# with a hand-built parsed dict can never write duplicates.
|
||||
"pictures": list(dict.fromkeys(
|
||||
clean_text_list(parsed.get("pictures") or [])
|
||||
))[:MAX_PICTURES],
|
||||
"domain": clean_text(parsed["domain"]),
|
||||
"tld": clean_text(parsed["tld"]),
|
||||
}
|
||||
@@ -2037,6 +2280,7 @@ def save_page(conn, url, source_url, status, content_type, parsed, crawl_ms):
|
||||
headings,
|
||||
content,
|
||||
links,
|
||||
pictures,
|
||||
domain,
|
||||
tld,
|
||||
source_url,
|
||||
@@ -2047,7 +2291,7 @@ def save_page(conn, url, source_url, status, content_type, parsed, crawl_ms):
|
||||
crawl_time_ms
|
||||
)
|
||||
VALUES (
|
||||
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||||
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||||
COALESCE(
|
||||
(SELECT discovered_at
|
||||
FROM crawl_queue
|
||||
@@ -2062,6 +2306,7 @@ def save_page(conn, url, source_url, status, content_type, parsed, crawl_ms):
|
||||
headings = EXCLUDED.headings,
|
||||
content = EXCLUDED.content,
|
||||
links = EXCLUDED.links,
|
||||
pictures = EXCLUDED.pictures,
|
||||
domain = EXCLUDED.domain,
|
||||
tld = EXCLUDED.tld,
|
||||
source_url = EXCLUDED.source_url,
|
||||
@@ -2075,6 +2320,7 @@ def save_page(conn, url, source_url, status, content_type, parsed, crawl_ms):
|
||||
safe_parsed["headings"],
|
||||
index_content,
|
||||
safe_parsed["links"],
|
||||
safe_parsed["pictures"],
|
||||
safe_parsed["domain"],
|
||||
safe_parsed["tld"],
|
||||
source_url,
|
||||
@@ -2149,7 +2395,8 @@ def worker(worker_id):
|
||||
sleep_for = min(wait_hint, QUEUE_MAX_IDLE_SLEEP)
|
||||
logging.info(
|
||||
"[WAIT ] All candidate hosts busy or blocked; "
|
||||
"worker sleeps %.1fs",
|
||||
"soonest host free in %.1fs, worker sleeps %.1fs",
|
||||
wait_hint,
|
||||
sleep_for,
|
||||
)
|
||||
else:
|
||||
@@ -2269,6 +2516,7 @@ def worker(worker_id):
|
||||
logging.info("[DOMAIN] %s", parsed["domain"])
|
||||
logging.info("[HEADS ] %d", len(parsed["headings"]))
|
||||
logging.info("[LINKS ] %d gefunden", len(parsed["links"]))
|
||||
logging.info("[IMGS ] %d gefunden", len(parsed["pictures"]))
|
||||
|
||||
try:
|
||||
safe_commit(
|
||||
|
||||
Reference in New Issue
Block a user