405 lines
16 KiB
Python
405 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Local-only reference collector — licence-free, gitignored research material.
|
|
|
|
Downloads images from DuckDuckGo image search into `tmp/research/<slug>/` for
|
|
development reference only. This tool deliberately applies **no licence gate**:
|
|
everything it fetches stays under the gitignored `tmp/` tree and must never be
|
|
committed, shipped, or promoted into `docs/screens/refs/` without passing the
|
|
normal sidecar licence + privacy gate. The owner authorised this local-only
|
|
override on 2026-11-03 (see TODO.md §3D model re-evaluation → A).
|
|
|
|
python3 tools/refcollect.py search "El Capitan Entertainment Centre facade" \
|
|
--slug elcapitan --limit 20
|
|
python3 tools/refcollect.py sheet --slug elcapitan
|
|
|
|
Requires the `ddgs` package (`pip install ddgs`); the legacy
|
|
`duckduckgo_search` package still works as a fallback.
|
|
|
|
Every outbound request goes through a token bucket and a polite retry routine:
|
|
DuckDuckGo and the image hosts rate-limit aggressive clients, so a large batch
|
|
is deliberately slow. Tune the pace with `--rate` (requests per second) and
|
|
`--burst`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime
|
|
import email.utils
|
|
import gzip
|
|
import json
|
|
import random
|
|
import re
|
|
import socket
|
|
import ssl
|
|
import sys
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
import zlib
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
RESEARCH_DIR = ROOT / "tmp" / "research"
|
|
|
|
# --- politeness ------------------------------------------------------------
|
|
|
|
# A current desktop Firefox on Linux. Kept in one place so the search client and
|
|
# the downloader present the same, consistent identity.
|
|
USER_AGENT = ("Mozilla/5.0 (X11; Linux x86_64; rv:159.0) "
|
|
"Gecko/20100101 Firefox/159.0")
|
|
|
|
# A coherent Firefox header set. Servers increasingly look past the user agent;
|
|
# a plain UA with no matching Accept/Accept-Language/Sec-Fetch headers is itself
|
|
# a tell, so send the whole set the browser would send.
|
|
#
|
|
# Accept-Encoding deliberately stops at gzip and deflate: urllib cannot decode
|
|
# brotli or zstd, and advertising an encoding we cannot read would corrupt every
|
|
# download. `_decode_body` undoes the two encodings we do advertise.
|
|
BROWSER_HEADERS = {
|
|
"User-Agent": USER_AGENT,
|
|
"Accept": ("image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,"
|
|
"*/*;q=0.5"),
|
|
"Accept-Language": "en-US,en;q=0.9,de-CH;q=0.8,de;q=0.7",
|
|
"Accept-Encoding": "gzip, deflate",
|
|
"DNT": "1",
|
|
"Sec-Fetch-Dest": "image",
|
|
"Sec-Fetch-Mode": "no-cors",
|
|
"Sec-Fetch-Site": "cross-site",
|
|
"Sec-GPC": "1",
|
|
"Referer": "https://duckduckgo.com/",
|
|
"Upgrade-Insecure-Requests": "1",
|
|
}
|
|
|
|
DEFAULT_RATE = 0.5 # requests per second (one every two seconds)
|
|
DEFAULT_BURST = 3.0 # instant burst a cold start may spend
|
|
BACKOFF_BASE_S = 4.0 # first exponential-backoff ceiling
|
|
BACKOFF_CAP_S = 120.0 # never wait longer than two minutes
|
|
MAX_ATTEMPTS = 6
|
|
REQUEST_TIMEOUT_S = 60.0
|
|
RETRY_STATUS = frozenset(
|
|
{408, 425, 429, 500, 502, 503, 504, 520, 521, 522, 524})
|
|
|
|
|
|
class TokenBucket:
|
|
"""Token bucket that paces outbound requests instead of fixed sleeps.
|
|
|
|
Tokens accrue at ``rate`` per second up to ``capacity``. Each request spends
|
|
one token; when the bucket is empty the caller sleeps only as long as it
|
|
takes for the next token to accrue. A burst allowance lets a cold start fire
|
|
a few requests back to back, which is what a human opening a page looks
|
|
like, while the sustained rate stays gentle.
|
|
"""
|
|
|
|
def __init__(self, rate: float, capacity: float | None = None) -> None:
|
|
if rate <= 0:
|
|
raise ValueError("rate must be positive")
|
|
self.rate = float(rate)
|
|
self.capacity = float(
|
|
capacity if capacity is not None else max(1.0, rate))
|
|
if self.capacity < 1.0:
|
|
raise ValueError("capacity must hold at least one token")
|
|
self._tokens = self.capacity
|
|
self._stamp = time.monotonic()
|
|
self._lock = threading.Lock()
|
|
|
|
def _refill(self) -> None:
|
|
now = time.monotonic()
|
|
self._tokens = min(self.capacity,
|
|
self._tokens + (now - self._stamp) * self.rate)
|
|
self._stamp = now
|
|
|
|
@property
|
|
def tokens(self) -> float:
|
|
with self._lock:
|
|
self._refill()
|
|
return self._tokens
|
|
|
|
def acquire(self, tokens: float = 1.0) -> float:
|
|
"""Block until ``tokens`` are available and return the seconds waited."""
|
|
if tokens > self.capacity:
|
|
raise ValueError("requested tokens exceed bucket capacity")
|
|
waited = 0.0
|
|
while True:
|
|
with self._lock:
|
|
self._refill()
|
|
if self._tokens >= tokens:
|
|
self._tokens -= tokens
|
|
return waited
|
|
delay = (tokens - self._tokens) / self.rate
|
|
time.sleep(delay)
|
|
waited += delay
|
|
|
|
|
|
def _parse_retry_after(value: str | None) -> float | None:
|
|
"""Read a Retry-After header (delta seconds or HTTP date) as seconds."""
|
|
if not value:
|
|
return None
|
|
value = value.strip()
|
|
if value.isdigit():
|
|
return float(value)
|
|
try:
|
|
when = email.utils.parsedate_to_datetime(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if when is None:
|
|
return None
|
|
if when.tzinfo is None:
|
|
when = when.replace(tzinfo=datetime.timezone.utc)
|
|
delta = (when - datetime.datetime.now(datetime.timezone.utc)).total_seconds()
|
|
return max(0.0, delta)
|
|
|
|
|
|
def _backoff_delay(attempt: int, retry_after: float | None = None) -> float:
|
|
"""Full-jitter exponential backoff, capped, deferring to Retry-After.
|
|
|
|
``attempt`` starts at 1. When the host told us how long to wait, that wins,
|
|
so we never come back sooner than asked. Otherwise pick uniformly from the
|
|
top half of the exponential window: enough spread to avoid a thundering herd
|
|
of retries, without doubling the total wait time.
|
|
"""
|
|
if retry_after is not None:
|
|
return min(BACKOFF_CAP_S, max(1.0, retry_after))
|
|
ceiling = min(BACKOFF_CAP_S, BACKOFF_BASE_S * 2 ** (attempt - 1))
|
|
return random.uniform(ceiling / 2.0, ceiling)
|
|
|
|
|
|
def _is_permanent_network_error(exc: BaseException) -> bool:
|
|
"""True when retrying cannot help: bad DNS or an untrusted certificate."""
|
|
reason = getattr(exc, "reason", None) or exc
|
|
return isinstance(reason, (socket.gaierror, ssl.SSLCertVerificationError))
|
|
|
|
|
|
def _decode_body(body: bytes, content_encoding: str | None) -> bytes:
|
|
"""Undo a Content-Encoding we advertised in BROWSER_HEADERS."""
|
|
encoding = (content_encoding or "").lower().strip()
|
|
if encoding in ("gzip", "x-gzip"):
|
|
return gzip.decompress(body)
|
|
if encoding == "deflate":
|
|
try:
|
|
return zlib.decompress(body)
|
|
except zlib.error:
|
|
return zlib.decompress(body, -zlib.MAX_WBITS) # raw deflate
|
|
return body
|
|
|
|
|
|
def polite_get(url: str, *, bucket: TokenBucket, headers: dict | None = None,
|
|
attempts: int = MAX_ATTEMPTS, timeout: float = REQUEST_TIMEOUT_S,
|
|
label: str = "request") -> tuple[bytes, object]:
|
|
"""Fetch ``url`` politely and return ``(body, response headers)``.
|
|
|
|
Each attempt takes a token from ``bucket`` first. Transient failures are
|
|
retried with jittered exponential backoff, honouring Retry-After; permanent
|
|
failures raise immediately so a dead link does not burn the whole budget.
|
|
"""
|
|
request_headers = dict(BROWSER_HEADERS)
|
|
if headers:
|
|
request_headers.update(headers)
|
|
|
|
last_exc: BaseException | None = None
|
|
for attempt in range(1, attempts + 1):
|
|
bucket.acquire()
|
|
retry_after = None
|
|
try:
|
|
request = urllib.request.Request(url, headers=request_headers)
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
body = _decode_body(response.read(),
|
|
response.headers.get("Content-Encoding"))
|
|
return body, response.headers
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code not in RETRY_STATUS:
|
|
raise
|
|
last_exc = exc
|
|
retry_after = _parse_retry_after(exc.headers.get("Retry-After"))
|
|
reason = f"HTTP {exc.code} {exc.reason or ''}".strip()
|
|
except (urllib.error.URLError, OSError, ValueError) as exc:
|
|
if _is_permanent_network_error(exc):
|
|
raise
|
|
last_exc = exc
|
|
reason = exc.__class__.__name__
|
|
if attempt == attempts:
|
|
break
|
|
delay = _backoff_delay(attempt, retry_after)
|
|
print(f" {label}: {reason}; politely waiting {delay:.1f}s "
|
|
f"before attempt {attempt + 1}/{attempts}",
|
|
file=sys.stderr, flush=True)
|
|
time.sleep(delay)
|
|
raise last_exc if last_exc is not None else RuntimeError(
|
|
f"could not fetch {url}")
|
|
|
|
|
|
def _rel(path: Path) -> str:
|
|
return str(path.relative_to(ROOT)) if path.is_relative_to(ROOT) else str(path)
|
|
|
|
|
|
def _slug(text: str) -> str:
|
|
return re.sub(r"[^a-z0-9]+", "-", text.strip().lower()).strip("-") or "block"
|
|
|
|
|
|
def _search_images(query: str, limit: int, bucket: TokenBucket) -> list[dict]:
|
|
"""Return DDG image results as small dicts; polite retry on rate limits."""
|
|
try:
|
|
from ddgs import DDGS # current package name
|
|
except ImportError:
|
|
# Legacy `duckduckgo_search` still works but warns it was renamed to
|
|
# `ddgs`; silence that warning so it does not leak into the run log.
|
|
import warnings
|
|
try:
|
|
with warnings.catch_warnings():
|
|
warnings.filterwarnings(
|
|
"ignore", category=RuntimeWarning,
|
|
message=r".*renamed to .ddgs.*")
|
|
from duckduckgo_search import DDGS # legacy import name
|
|
except ImportError as exc:
|
|
sys.exit("error: install the DuckDuckGo search package first: "
|
|
"pip install ddgs")
|
|
last_exc = None
|
|
for attempt in range(1, MAX_ATTEMPTS + 1):
|
|
bucket.acquire()
|
|
try:
|
|
with DDGS() as ddgs:
|
|
rows = ddgs.images(query, max_results=limit)
|
|
return [{
|
|
"title": (r.get("title") or "")[:200],
|
|
"image": r.get("image") or "",
|
|
"thumbnail": r.get("thumbnail") or "",
|
|
"source": r.get("url") or "",
|
|
"width": r.get("width") or 0,
|
|
"height": r.get("height") or 0,
|
|
} for r in rows if r.get("image")]
|
|
except Exception as exc: # noqa: BLE001 - package raises several types
|
|
last_exc = exc
|
|
if attempt == MAX_ATTEMPTS:
|
|
break
|
|
delay = _backoff_delay(attempt)
|
|
print(f" DDG search failed ({exc.__class__.__name__}); politely "
|
|
f"waiting {delay:.1f}s before attempt {attempt + 1}/"
|
|
f"{MAX_ATTEMPTS}", file=sys.stderr, flush=True)
|
|
time.sleep(delay)
|
|
sys.exit(f"error: DuckDuckGo image search kept failing: {last_exc}")
|
|
|
|
|
|
def _download(url: str, path: Path, bucket: TokenBucket) -> None:
|
|
"""Download one image through the polite, rate-limited fetch routine."""
|
|
body, _ = polite_get(url, bucket=bucket, label=f"download {path.name}")
|
|
path.write_bytes(body)
|
|
|
|
|
|
def _manifest_rows(rows: list[dict]) -> list[dict]:
|
|
out = []
|
|
for row in rows:
|
|
out.append({**row, "local_only": True, "licence": "unchecked-local"})
|
|
return out
|
|
|
|
|
|
def cmd_search(args) -> int:
|
|
slug = _slug(args.slug or args.query)
|
|
out_dir = RESEARCH_DIR / slug
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
bucket = TokenBucket(args.rate, args.burst)
|
|
if getattr(args, "user_agent", ""):
|
|
BROWSER_HEADERS["User-Agent"] = args.user_agent
|
|
print(f"searching DDG: {args.query!r} (limit {args.limit}, "
|
|
f"{args.rate:g} req/s, burst {args.burst:g})", flush=True)
|
|
rows = _search_images(args.query, args.limit, bucket)
|
|
manifest = []
|
|
for index, row in enumerate(rows, start=1):
|
|
stem = f"{index:02d}"
|
|
suffix = Path(row["image"].split("?")[0]).suffix.lower() or ".jpg"
|
|
if suffix not in (".jpg", ".jpeg", ".png", ".webp", ".gif"):
|
|
suffix = ".jpg"
|
|
path = out_dir / f"{stem}{suffix}"
|
|
if path.exists() and not args.force:
|
|
print(f" skip existing {path.name}")
|
|
else:
|
|
try:
|
|
_download(row["image"], path, bucket)
|
|
print(f" {path.name} <- {row['image'][:70]}")
|
|
except Exception as exc: # noqa: BLE001 - some hosts block hotlinking
|
|
print(
|
|
f" FAILED {path.name}: {exc.__class__.__name__}", file=sys.stderr)
|
|
continue
|
|
manifest.append({**row, "file": path.name})
|
|
(out_dir / "manifest.json").write_text(
|
|
json.dumps(_manifest_rows(manifest), indent=2) + "\n")
|
|
print(f"manifest -> {out_dir / 'manifest.json'} ({len(manifest)} entries)")
|
|
return 0
|
|
|
|
|
|
def cmd_sheet(args) -> int:
|
|
"""Compose a local contact sheet (never committed) from a manifest folder."""
|
|
from PIL import Image
|
|
slug = _slug(args.slug)
|
|
out_dir = RESEARCH_DIR / slug
|
|
manifest_path = out_dir / "manifest.json"
|
|
if not manifest_path.is_file():
|
|
sys.exit(f"error: no manifest at {manifest_path}; run `search` first")
|
|
rows = json.loads(manifest_path.read_text())
|
|
images = []
|
|
for row in rows:
|
|
path = out_dir / row["file"]
|
|
if not path.is_file():
|
|
continue
|
|
try:
|
|
with Image.open(path) as img:
|
|
img.load()
|
|
images.append(img.convert("RGB"))
|
|
except Exception as exc: # noqa: BLE001 - AVIF/misnamed or corrupt frames
|
|
print(f" skip unreadable {path.name}: {exc.__class__.__name__}",
|
|
file=sys.stderr)
|
|
if not images:
|
|
sys.exit("error: no readable frames in this folder (convert AVIF/webp "
|
|
"files with ffmpeg first)")
|
|
cell = 320
|
|
cols = 4
|
|
rows_count = (len(images) + cols - 1) // cols
|
|
sheet = Image.new("RGB", (cols * cell, rows_count * cell), (18, 18, 18))
|
|
for i, img in enumerate(images):
|
|
thumb = img.copy()
|
|
thumb.thumbnail((cell, cell), Image.Resampling.LANCZOS)
|
|
x = (i % cols) * cell
|
|
y = (i // cols) * cell
|
|
sheet.paste(thumb, (x, y))
|
|
out = out_dir / "sheet.jpg"
|
|
sheet.save(out, quality=82)
|
|
print(f"wrote {_rel(out)} {sheet.size[0]}x{sheet.size[1]} "
|
|
f"({len(images)} frames)")
|
|
return 0
|
|
|
|
|
|
def build_parser():
|
|
parser = argparse.ArgumentParser(
|
|
description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
search = sub.add_parser(
|
|
"search", help="DDG image search into tmp/research/<slug>/")
|
|
search.add_argument("query")
|
|
search.add_argument("--slug", default="",
|
|
help="folder slug (default: from query)")
|
|
search.add_argument("--limit", type=int, default=20)
|
|
search.add_argument("--force", action="store_true")
|
|
search.add_argument("--rate", type=float, default=DEFAULT_RATE,
|
|
help="requests per second, enforced by a token bucket "
|
|
f"(default {DEFAULT_RATE:g})")
|
|
search.add_argument("--burst", type=float, default=DEFAULT_BURST,
|
|
help="token-bucket capacity, i.e. allowed instant burst "
|
|
f"(default {DEFAULT_BURST:g})")
|
|
search.add_argument("--user-agent", default="",
|
|
help="override the default Firefox user agent string")
|
|
search.set_defaults(handler=cmd_search)
|
|
sheet = sub.add_parser("sheet", help="compose a local contact sheet")
|
|
sheet.add_argument("--slug", required=True)
|
|
sheet.set_defaults(handler=cmd_sheet)
|
|
return parser
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
args = build_parser().parse_args(argv)
|
|
return args.handler(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|