Harden the DDG collector with token-bucket pacing and prefer the ddgs package
This commit is contained in:
1 parent
480a1ba05f
commit
87e1c9e7a5
5 files changed
+426
-32
No files matched your search
@@ -5,9 +5,12 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
- **DuckDuckGo collection tool (2026-11-03):** `tools/refcollect.py` wraps the `duckduckgo_search`
|
- **DuckDuckGo collection tool (2026-11-03):** `tools/refcollect.py` wraps the `duckduckgo_search`
|
||||||
package (installed locally; renamed `ddgs` upstream) and retries DDG's HTTP 202 rate limit with
|
package (installed locally; renamed `ddgs` upstream). All outbound requests share one
|
||||||
backoff. DDG rate-limits by IP for a while after probing, so the per-venue collections should be
|
`TokenBucket` (`--rate` 0.5 req/s, `--burst` 3) and a polite retry routine: full-jitter
|
||||||
run in small spaced batches. Downloads are `local_only`/`unchecked-local` and never committed.
|
exponential backoff, `Retry-After` honoured, permanent HTTP failures fail fast, six attempts
|
||||||
|
maximum. Requests send a complete Firefox header set, not just the UA. DDG rate-limits by IP for
|
||||||
|
a while after probing, so the per-venue collections should still be run in small spaced batches.
|
||||||
|
Downloads are `local_only`/`unchecked-local` and never committed.
|
||||||
- **Local-only reference override (2026-11-03, owner decision):** for development-only photo
|
- **Local-only reference override (2026-11-03, owner decision):** for development-only photo
|
||||||
research the licence gate does **not** apply. Downloads for this track live in `tmp/research/`
|
research the licence gate does **not** apply. Downloads for this track live in `tmp/research/`
|
||||||
(gitignored) and may come from DuckDuckGo image search, street-view frames, neighbouring
|
(gitignored) and may come from DuckDuckGo image search, street-view frames, neighbouring
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ licence-free zone only.
|
|||||||
- [x] Add a DuckDuckGo image-search fetcher: `tools/refcollect.py search <query> --slug <block>
|
- [x] Add a DuckDuckGo image-search fetcher: `tools/refcollect.py search <query> --slug <block>
|
||||||
--limit N` downloads top results into `tmp/research/<block>/` with a source-URL manifest
|
--limit N` downloads top results into `tmp/research/<block>/` with a source-URL manifest
|
||||||
(no licence filter, marked `local_only`/`unchecked-local`), and `sheet` composes a local
|
(no licence filter, marked `local_only`/`unchecked-local`), and `sheet` composes a local
|
||||||
contact sheet. Retries DDG's HTTP 202 rate limit with backoff. Tests in
|
contact sheet. Requests go through a token bucket plus polite jittered backoff that honours
|
||||||
|
`Retry-After`, and send a full Firefox header set. Tests in
|
||||||
`tools/test_refcollect.py`. **Run note:** DDG rate-limits aggressively; space batches and
|
`tools/test_refcollect.py`. **Run note:** DDG rate-limits aggressively; space batches and
|
||||||
run the per-venue collection below when the limit clears.
|
run the per-venue collection below when the limit clears.
|
||||||
- [ ] Collect Hollywood block reference: El Capitan facade (day/night/close-up), the El Capitan
|
- [ ] Collect Hollywood block reference: El Capitan facade (day/night/close-up), the El Capitan
|
||||||
|
|||||||
@@ -229,7 +229,12 @@ python3 tools/refcollect.py sheet --slug comedy-store # local contact sheet (n
|
|||||||
```
|
```
|
||||||
|
|
||||||
Requires `pip install duckduckgo-search` (renamed `ddgs` upstream). DuckDuckGo rate-limits by IP
|
Requires `pip install duckduckgo-search` (renamed `ddgs` upstream). DuckDuckGo rate-limits by IP
|
||||||
(HTTP 202) and the tool retries with backoff, so run per-venue batches spaced out.
|
(HTTP 202), so every request — the search itself and each image download — passes through a shared
|
||||||
|
token bucket (`--rate`, default 0.5 req/s, and `--burst`, default 3) and a polite retry routine:
|
||||||
|
transient statuses (408/425/429/5xx/52x) and network errors back off exponentially with full
|
||||||
|
jitter, `Retry-After` is honoured, permanent failures fail fast, and the loop gives up after six
|
||||||
|
attempts. Requests carry a full, coherent Firefox header set (`--user-agent` overrides the UA
|
||||||
|
string); the UA alone is no longer enough to look like a browser.
|
||||||
|
|
||||||
### Reference-board research (`refboard`)
|
### Reference-board research (`refboard`)
|
||||||
|
|
||||||
|
|||||||
+248
-26
@@ -12,24 +12,221 @@ override on 2026-11-03 (see TODO.md §3D model re-evaluation → A).
|
|||||||
--slug elcapitan --limit 20
|
--slug elcapitan --limit 20
|
||||||
python3 tools/refcollect.py sheet --slug elcapitan
|
python3 tools/refcollect.py sheet --slug elcapitan
|
||||||
|
|
||||||
Requires the `duckduckgo_search` package (pip install duckduckgo-search, or the
|
Requires the `ddgs` package (`pip install ddgs`); the legacy
|
||||||
renamed `ddgs`). DuckDuckGo rate-limits aggressive queries (HTTP 202): requests
|
`duckduckgo_search` package still works as a fallback.
|
||||||
are spaced and retried with backoff, so a large batch can take a while.
|
|
||||||
|
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 argparse
|
||||||
|
import datetime
|
||||||
|
import email.utils
|
||||||
|
import gzip
|
||||||
import json
|
import json
|
||||||
|
import random
|
||||||
import re
|
import re
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import zlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
RESEARCH_DIR = ROOT / "tmp" / "research"
|
RESEARCH_DIR = ROOT / "tmp" / "research"
|
||||||
UA = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"
|
|
||||||
DOWNLOAD_SPACING_S = 2.0
|
# --- 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:
|
def _rel(path: Path) -> str:
|
||||||
@@ -40,18 +237,26 @@ def _slug(text: str) -> str:
|
|||||||
return re.sub(r"[^a-z0-9]+", "-", text.strip().lower()).strip("-") or "block"
|
return re.sub(r"[^a-z0-9]+", "-", text.strip().lower()).strip("-") or "block"
|
||||||
|
|
||||||
|
|
||||||
def _search_images(query: str, limit: int) -> list[dict]:
|
def _search_images(query: str, limit: int, bucket: TokenBucket) -> list[dict]:
|
||||||
"""Return DDG image results as small dicts; retries on 202 rate limits."""
|
"""Return DDG image results as small dicts; polite retry on rate limits."""
|
||||||
try:
|
try:
|
||||||
from duckduckgo_search import DDGS # legacy import name
|
from ddgs import DDGS # current package name
|
||||||
except ImportError:
|
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:
|
try:
|
||||||
from ddgs import DDGS # current package name
|
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:
|
except ImportError as exc:
|
||||||
sys.exit("error: install the DuckDuckGo search package first: "
|
sys.exit("error: install the DuckDuckGo search package first: "
|
||||||
"pip install duckduckgo-search")
|
"pip install ddgs")
|
||||||
last_exc = None
|
last_exc = None
|
||||||
for attempt in range(5):
|
for attempt in range(1, MAX_ATTEMPTS + 1):
|
||||||
|
bucket.acquire()
|
||||||
try:
|
try:
|
||||||
with DDGS() as ddgs:
|
with DDGS() as ddgs:
|
||||||
rows = ddgs.images(query, max_results=limit)
|
rows = ddgs.images(query, max_results=limit)
|
||||||
@@ -65,17 +270,20 @@ def _search_images(query: str, limit: int) -> list[dict]:
|
|||||||
} for r in rows if r.get("image")]
|
} for r in rows if r.get("image")]
|
||||||
except Exception as exc: # noqa: BLE001 - package raises several types
|
except Exception as exc: # noqa: BLE001 - package raises several types
|
||||||
last_exc = exc
|
last_exc = exc
|
||||||
wait = 8 * (attempt + 1)
|
if attempt == MAX_ATTEMPTS:
|
||||||
print(f" DDG rate limit or error ({exc.__class__.__name__}); "
|
break
|
||||||
f"retrying in {wait}s ...", file=sys.stderr)
|
delay = _backoff_delay(attempt)
|
||||||
time.sleep(wait)
|
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}")
|
sys.exit(f"error: DuckDuckGo image search kept failing: {last_exc}")
|
||||||
|
|
||||||
|
|
||||||
def _download(url: str, path: Path) -> None:
|
def _download(url: str, path: Path, bucket: TokenBucket) -> None:
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
"""Download one image through the polite, rate-limited fetch routine."""
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
body, _ = polite_get(url, bucket=bucket, label=f"download {path.name}")
|
||||||
path.write_bytes(resp.read())
|
path.write_bytes(body)
|
||||||
|
|
||||||
|
|
||||||
def _manifest_rows(rows: list[dict]) -> list[dict]:
|
def _manifest_rows(rows: list[dict]) -> list[dict]:
|
||||||
@@ -89,8 +297,12 @@ def cmd_search(args) -> int:
|
|||||||
slug = _slug(args.slug or args.query)
|
slug = _slug(args.slug or args.query)
|
||||||
out_dir = RESEARCH_DIR / slug
|
out_dir = RESEARCH_DIR / slug
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
print(f"searching DDG: {args.query!r} (limit {args.limit})", flush=True)
|
bucket = TokenBucket(args.rate, args.burst)
|
||||||
rows = _search_images(args.query, args.limit)
|
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 = []
|
manifest = []
|
||||||
for index, row in enumerate(rows, start=1):
|
for index, row in enumerate(rows, start=1):
|
||||||
stem = f"{index:02d}"
|
stem = f"{index:02d}"
|
||||||
@@ -102,12 +314,12 @@ def cmd_search(args) -> int:
|
|||||||
print(f" skip existing {path.name}")
|
print(f" skip existing {path.name}")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
_download(row["image"], path)
|
_download(row["image"], path, bucket)
|
||||||
print(f" {path.name} <- {row['image'][:70]}")
|
print(f" {path.name} <- {row['image'][:70]}")
|
||||||
except Exception as exc: # noqa: BLE001 - some hosts block hotlinking
|
except Exception as exc: # noqa: BLE001 - some hosts block hotlinking
|
||||||
print(f" FAILED {path.name}: {exc.__class__.__name__}", file=sys.stderr)
|
print(
|
||||||
|
f" FAILED {path.name}: {exc.__class__.__name__}", file=sys.stderr)
|
||||||
continue
|
continue
|
||||||
time.sleep(DOWNLOAD_SPACING_S)
|
|
||||||
manifest.append({**row, "file": path.name})
|
manifest.append({**row, "file": path.name})
|
||||||
(out_dir / "manifest.json").write_text(
|
(out_dir / "manifest.json").write_text(
|
||||||
json.dumps(_manifest_rows(manifest), indent=2) + "\n")
|
json.dumps(_manifest_rows(manifest), indent=2) + "\n")
|
||||||
@@ -155,11 +367,21 @@ def build_parser():
|
|||||||
description=__doc__,
|
description=__doc__,
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||||
search = sub.add_parser("search", help="DDG image search into tmp/research/<slug>/")
|
search = sub.add_parser(
|
||||||
|
"search", help="DDG image search into tmp/research/<slug>/")
|
||||||
search.add_argument("query")
|
search.add_argument("query")
|
||||||
search.add_argument("--slug", default="", help="folder slug (default: from query)")
|
search.add_argument("--slug", default="",
|
||||||
|
help="folder slug (default: from query)")
|
||||||
search.add_argument("--limit", type=int, default=20)
|
search.add_argument("--limit", type=int, default=20)
|
||||||
search.add_argument("--force", action="store_true")
|
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)
|
search.set_defaults(handler=cmd_search)
|
||||||
sheet = sub.add_parser("sheet", help="compose a local contact sheet")
|
sheet = sub.add_parser("sheet", help="compose a local contact sheet")
|
||||||
sheet.add_argument("--slug", required=True)
|
sheet.add_argument("--slug", required=True)
|
||||||
|
|||||||
+164
-1
@@ -1,7 +1,12 @@
|
|||||||
"""Tests for the local-only reference collector (no network)."""
|
"""Tests for the local-only reference collector (no network)."""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import email.utils
|
||||||
|
import gzip
|
||||||
import json
|
import json
|
||||||
|
import socket
|
||||||
import unittest
|
import unittest
|
||||||
|
import urllib.error
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -9,6 +14,37 @@ from unittest import mock
|
|||||||
import refcollect as rc
|
import refcollect as rc
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_clock(start=0.0):
|
||||||
|
"""Return (clock, monotonic, sleep) that advance virtual time on sleep."""
|
||||||
|
clock = {"now": start}
|
||||||
|
|
||||||
|
def monotonic():
|
||||||
|
return clock["now"]
|
||||||
|
|
||||||
|
def sleep(seconds):
|
||||||
|
clock["now"] += seconds
|
||||||
|
|
||||||
|
return clock, monotonic, sleep
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
"""Minimal stand-in for the urlopen context manager."""
|
||||||
|
|
||||||
|
def __init__(self, body=b"x", content_encoding=None):
|
||||||
|
self._body = body
|
||||||
|
self.headers = ({"Content-Encoding": content_encoding}
|
||||||
|
if content_encoding else {})
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class SlugTests(unittest.TestCase):
|
class SlugTests(unittest.TestCase):
|
||||||
def test_slug_folds_and_strips(self):
|
def test_slug_folds_and_strips(self):
|
||||||
self.assertEqual(rc._slug("El Capitan Entertainment Centre"),
|
self.assertEqual(rc._slug("El Capitan Entertainment Centre"),
|
||||||
@@ -39,7 +75,8 @@ class SheetTests(unittest.TestCase):
|
|||||||
slug_dir = root / "block"
|
slug_dir = root / "block"
|
||||||
slug_dir.mkdir()
|
slug_dir.mkdir()
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
Image.new("RGB", (640, 400), (i * 60, 0, 0)).save(slug_dir / f"{i:02d}.jpg")
|
Image.new("RGB", (640, 400), (i * 60, 0, 0)
|
||||||
|
).save(slug_dir / f"{i:02d}.jpg")
|
||||||
(slug_dir / "manifest.json").write_text(json.dumps([
|
(slug_dir / "manifest.json").write_text(json.dumps([
|
||||||
{"file": "00.jpg"}, {"file": "01.jpg"}, {"file": "02.jpg"}]))
|
{"file": "00.jpg"}, {"file": "01.jpg"}, {"file": "02.jpg"}]))
|
||||||
with mock.patch.object(rc, "RESEARCH_DIR", root):
|
with mock.patch.object(rc, "RESEARCH_DIR", root):
|
||||||
@@ -47,5 +84,131 @@ class SheetTests(unittest.TestCase):
|
|||||||
self.assertTrue((slug_dir / "sheet.jpg").is_file())
|
self.assertTrue((slug_dir / "sheet.jpg").is_file())
|
||||||
|
|
||||||
|
|
||||||
|
class TokenBucketTests(unittest.TestCase):
|
||||||
|
def test_burst_is_spent_before_sleeping(self):
|
||||||
|
clock, monotonic, sleep = _fake_clock()
|
||||||
|
with mock.patch.object(rc.time, "monotonic", monotonic), \
|
||||||
|
mock.patch.object(rc.time, "sleep", sleep):
|
||||||
|
bucket = rc.TokenBucket(rate=1.0, capacity=2.0)
|
||||||
|
self.assertEqual(bucket.acquire(), 0.0)
|
||||||
|
self.assertEqual(bucket.acquire(), 0.0)
|
||||||
|
self.assertAlmostEqual(bucket.acquire(), 1.0)
|
||||||
|
self.assertAlmostEqual(bucket.acquire(), 1.0)
|
||||||
|
self.assertAlmostEqual(clock["now"], 2.0)
|
||||||
|
|
||||||
|
def test_tokens_refill_up_to_capacity_only(self):
|
||||||
|
clock, monotonic, sleep = _fake_clock()
|
||||||
|
with mock.patch.object(rc.time, "monotonic", monotonic), \
|
||||||
|
mock.patch.object(rc.time, "sleep", sleep):
|
||||||
|
bucket = rc.TokenBucket(rate=1.0, capacity=2.0)
|
||||||
|
bucket.acquire(2.0)
|
||||||
|
self.assertAlmostEqual(bucket.tokens, 0.0)
|
||||||
|
clock["now"] += 100.0
|
||||||
|
self.assertAlmostEqual(bucket.tokens, 2.0) # capped at capacity
|
||||||
|
clock["now"] += 100.0
|
||||||
|
self.assertAlmostEqual(bucket.tokens, 2.0)
|
||||||
|
|
||||||
|
def test_invalid_configuration_is_rejected(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
rc.TokenBucket(rate=0)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
rc.TokenBucket(rate=1.0, capacity=0.5)
|
||||||
|
|
||||||
|
|
||||||
|
class BackoffTests(unittest.TestCase):
|
||||||
|
def test_backoff_grows_and_stays_within_its_window(self):
|
||||||
|
for attempt in range(1, 6):
|
||||||
|
ceiling = min(rc.BACKOFF_CAP_S, rc.BACKOFF_BASE_S *
|
||||||
|
2 ** (attempt - 1))
|
||||||
|
for _ in range(20):
|
||||||
|
delay = rc._backoff_delay(attempt)
|
||||||
|
self.assertGreaterEqual(delay, ceiling / 2)
|
||||||
|
self.assertLessEqual(delay, ceiling)
|
||||||
|
|
||||||
|
def test_backoff_is_capped(self):
|
||||||
|
self.assertLessEqual(rc._backoff_delay(20), rc.BACKOFF_CAP_S)
|
||||||
|
|
||||||
|
def test_retry_after_overrides_the_exponential_guess(self):
|
||||||
|
self.assertEqual(rc._backoff_delay(1, retry_after=30.0), 30.0)
|
||||||
|
self.assertEqual(rc._backoff_delay(
|
||||||
|
1, retry_after=0.0), 1.0) # never instant
|
||||||
|
self.assertEqual(rc._backoff_delay(
|
||||||
|
1, retry_after=10_000.0), rc.BACKOFF_CAP_S)
|
||||||
|
|
||||||
|
def test_retry_after_parses_seconds_and_http_dates(self):
|
||||||
|
self.assertEqual(rc._parse_retry_after("7"), 7.0)
|
||||||
|
self.assertIsNone(rc._parse_retry_after(None))
|
||||||
|
self.assertIsNone(rc._parse_retry_after("soon"))
|
||||||
|
when = email.utils.format_datetime(
|
||||||
|
datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
+ datetime.timedelta(seconds=30))
|
||||||
|
self.assertAlmostEqual(rc._parse_retry_after(when), 30.0, delta=2.0)
|
||||||
|
|
||||||
|
|
||||||
|
class PolitenessTests(unittest.TestCase):
|
||||||
|
def test_user_agent_is_the_current_firefox_string_on_linux(self):
|
||||||
|
self.assertEqual(rc.BROWSER_HEADERS["User-Agent"], rc.USER_AGENT)
|
||||||
|
self.assertIn("X11; Linux x86_64", rc.USER_AGENT)
|
||||||
|
self.assertIn("Firefox/", rc.USER_AGENT)
|
||||||
|
# A UA on its own is a tell: the supporting browser headers must match.
|
||||||
|
for header in ("Accept", "Accept-Language", "Sec-Fetch-Dest", "Referer"):
|
||||||
|
self.assertIn(header, rc.BROWSER_HEADERS)
|
||||||
|
|
||||||
|
def test_gzip_bodies_are_decoded(self):
|
||||||
|
payload = gzip.compress(b"jpeg-bytes")
|
||||||
|
self.assertEqual(rc._decode_body(payload, "gzip"), b"jpeg-bytes")
|
||||||
|
self.assertEqual(rc._decode_body(b"plain", None), b"plain")
|
||||||
|
|
||||||
|
def test_polite_get_retries_transient_errors_then_succeeds(self):
|
||||||
|
error = urllib.error.HTTPError("https://x/i.jpg", 429,
|
||||||
|
"Too Many Requests",
|
||||||
|
{"Retry-After": "3"}, None)
|
||||||
|
bucket = rc.TokenBucket(rate=1000, capacity=1000)
|
||||||
|
with mock.patch.object(rc.urllib.request, "urlopen",
|
||||||
|
side_effect=[error, _FakeResponse(b"jpeg")]) as opened, \
|
||||||
|
mock.patch.object(rc.time, "sleep") as sleep:
|
||||||
|
body, _ = rc.polite_get("https://x/i.jpg", bucket=bucket)
|
||||||
|
self.assertEqual(body, b"jpeg")
|
||||||
|
self.assertEqual(opened.call_count, 2)
|
||||||
|
# Retry-After is honoured instead of the exponential guess.
|
||||||
|
sleep.assert_called_once_with(3.0)
|
||||||
|
sent = opened.call_args_list[0][0][0]
|
||||||
|
self.assertEqual(sent.get_header("User-agent"), rc.USER_AGENT)
|
||||||
|
|
||||||
|
def test_polite_get_does_not_retry_permanent_errors(self):
|
||||||
|
error = urllib.error.HTTPError(
|
||||||
|
"https://x/i.jpg", 404, "Not Found", {}, None)
|
||||||
|
bucket = rc.TokenBucket(rate=1000, capacity=1000)
|
||||||
|
with mock.patch.object(rc.urllib.request, "urlopen",
|
||||||
|
side_effect=error) as opened, \
|
||||||
|
mock.patch.object(rc.time, "sleep") as sleep, \
|
||||||
|
self.assertRaises(urllib.error.HTTPError):
|
||||||
|
rc.polite_get("https://x/i.jpg", bucket=bucket)
|
||||||
|
self.assertEqual(opened.call_count, 1)
|
||||||
|
sleep.assert_not_called()
|
||||||
|
|
||||||
|
def test_polite_get_does_not_retry_dns_failures(self):
|
||||||
|
error = urllib.error.URLError(socket.gaierror(-2, "Name not known"))
|
||||||
|
bucket = rc.TokenBucket(rate=1000, capacity=1000)
|
||||||
|
with mock.patch.object(rc.urllib.request, "urlopen",
|
||||||
|
side_effect=error) as opened, \
|
||||||
|
mock.patch.object(rc.time, "sleep") as sleep, \
|
||||||
|
self.assertRaises(urllib.error.URLError):
|
||||||
|
rc.polite_get("https://nope.invalid/i.jpg", bucket=bucket)
|
||||||
|
self.assertEqual(opened.call_count, 1)
|
||||||
|
sleep.assert_not_called()
|
||||||
|
|
||||||
|
def test_polite_get_gives_up_after_the_attempt_budget(self):
|
||||||
|
error = urllib.error.HTTPError(
|
||||||
|
"https://x/i.jpg", 503, "Unavailable", {}, None)
|
||||||
|
bucket = rc.TokenBucket(rate=1000, capacity=1000)
|
||||||
|
with mock.patch.object(rc.urllib.request, "urlopen",
|
||||||
|
side_effect=error) as opened, \
|
||||||
|
mock.patch.object(rc.time, "sleep"), \
|
||||||
|
self.assertRaises(urllib.error.HTTPError):
|
||||||
|
rc.polite_get("https://x/i.jpg", bucket=bucket, attempts=3)
|
||||||
|
self.assertEqual(opened.call_count, 3)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
Reference in new issue
Block a user