返回 ppt-master
image_search.py
根目录 / skills / ppt-master / scripts / image_search.py
1 #!/usr/bin/env python3
2 """Web image search CLI.
3
4 Sister tool to ``image_gen.py``: instead of generating an image from a
5 prompt, this searches openly-licensed image providers and downloads a
6 single best match.
7
8 Workflow:
9 1. Build an :class:`ImageSearchRequest` from CLI args.
10 2. Quality-first license search:
11 - Default: ask each provider for ``all`` allowed matches (CC0,
12 Public Domain, Pexels, Pixabay, CC BY, CC BY-SA), pick the
13 highest-scoring downloadable candidate, and record whether it
14 needs attribution.
15 - Strict mode: when ``--strict-no-attribution`` is set, ask only
16 for ``no-attribution-only`` matches and fail if none can be
17 downloaded.
18 3. Download the chosen image into ``--output``.
19 4. Append a record to ``image_sources.json`` (the single source of
20 truth for downstream credit rendering).
21
22 Examples:
23 # Default: zero-config, quality-first across allowed licenses
24 python3 scripts/image_search.py "offshore wind farm" \
25 --filename cover_bg.jpg --slide 01_cover \
26 --orientation landscape -o projects/demo/images
27
28 # Strict mode: refuse anything that would require attribution
29 python3 scripts/image_search.py "abstract gradient" \
30 --filename hero.jpg --strict-no-attribution \
31 -o projects/demo/images
32
33 # Pin a specific provider (useful when an API key is set)
34 python3 scripts/image_search.py "executive meeting" \
35 --filename team.jpg --provider pexels \
36 --orientation landscape -o projects/demo/images
37 """
38
39 from __future__ import annotations
40
41 import argparse
42 import concurrent.futures
43 import importlib
44 import json
45 import os
46 import sys
47 import tempfile
48 import threading
49 from dataclasses import dataclass
50 from datetime import datetime, timezone
51 from pathlib import Path
52 from typing import Callable, Optional
53
54 import requests
55
56 # Make sibling modules importable when this script is invoked directly.
57 _SCRIPTS_DIR = Path(__file__).resolve().parent
58 if str(_SCRIPTS_DIR) not in sys.path:
59 sys.path.insert(0, str(_SCRIPTS_DIR))
60
61 from console_encoding import configure_utf8_stdio # noqa: E402
62 from config import load_prefixed_env_file # noqa: E402
63 from image_backends.backend_common import download_image # noqa: E402
64 from image_sources.provider_common import ( # noqa: E402
65 AssetCandidate,
66 ImageSearchRequest,
67 USER_AGENT,
68 build_attribution_text,
69 ensure_json_parent,
70 score_candidate,
71 )
72
73 configure_utf8_stdio()
74
75
76 # ---------------------------------------------------------------------------
77 # Provider registry
78 # ---------------------------------------------------------------------------
79
80 PROVIDER_MODULES: dict[str, str] = {
81 "openverse": "image_sources.provider_openverse",
82 "wikimedia": "image_sources.provider_wikimedia",
83 "pexels": "image_sources.provider_pexels",
84 "pixabay": "image_sources.provider_pixabay",
85 }
86
87 # Providers that work without configuration. ``image_search.py`` defaults
88 # to these so a fresh clone can search immediately.
89 ZERO_CONFIG_PROVIDERS: tuple[str, ...] = ("openverse", "wikimedia")
90 KEYED_PROVIDERS: tuple[str, ...] = ("pexels", "pixabay")
91 ALL_PROVIDERS: tuple[str, ...] = ZERO_CONFIG_PROVIDERS + KEYED_PROVIDERS
92
93 ORIENTATION_CHOICES = ("any", "landscape", "portrait", "square")
94
95 # --- Batch mode (`--batch image_queries.json`) -----------------------------
96 # Web providers are politeness-sensitive (Wikimedia/Openverse expect a modest
97 # rate), so the default concurrency is deliberately low. Sister-tool
98 # `image_gen.py` hits a paid API and defaults higher; here 3 keeps several
99 # rows in flight without hammering any single free provider. Set to 1 to
100 # restore strict one-at-a-time pacing.
101 DEFAULT_SEARCH_CONCURRENCY = 3
102
103 SEARCH_STATUS_PENDING = "Pending"
104 SEARCH_STATUS_SOURCED = "Sourced"
105 SEARCH_STATUS_FAILED = "Failed"
106 SEARCH_STATUS_NEEDS_MANUAL = "Needs-Manual"
107 SEARCH_VALID_STATUSES = {
108 SEARCH_STATUS_PENDING,
109 SEARCH_STATUS_SOURCED,
110 SEARCH_STATUS_FAILED,
111 SEARCH_STATUS_NEEDS_MANUAL,
112 }
113 # A row reaching `Needs-Manual` after the full provider/stage chain is terminal
114 # (see image-searcher.md §8); only Pending/Failed rows are retried on re-run.
115 SEARCH_RETRYABLE_STATUSES = {SEARCH_STATUS_PENDING, SEARCH_STATUS_FAILED}
116 SEARCH_REQUIRED_ITEM_FIELDS = ("filename", "query", "status")
117 SEARCH_FAILURE_NO_MATCH = "no-match"
118 SEARCH_FAILURE_RETRYABLE = "retryable"
119
120 _WEAK_REQUIRED_TERM_PARTS = frozenset({
121 "ancient town",
122 "bridge",
123 "canyon",
124 "cave",
125 "city",
126 "floating bridge",
127 "forest",
128 "gate",
129 "grand canyon",
130 "ground fissure",
131 "lake",
132 "monastery",
133 "monument",
134 "river",
135 "shrine",
136 "square",
137 "station",
138 "stone forest",
139 "stone pillar",
140 "stream",
141 "temple",
142 "valley",
143 "village",
144 "古城",
145 "古镇",
146 "地缝",
147 "大峡谷",
148 "寺",
149 "峡谷",
150 "广场",
151 "桥",
152 "洞",
153 "溪",
154 "石林",
155 "石柱",
156 })
157
158
159 def _parse_required_terms(raw: object) -> tuple[str, ...]:
160 """Parse entity-safety terms from CLI / batch JSON.
161
162 Multiple groups are ANDed. Alternatives inside one group are separated by
163 ``|``; comma splitting is accepted as CLI convenience. Examples:
164 ``["Chongqing", "Jiefangbei|Liberation Monument"]``.
165 """
166 if raw is None:
167 return ()
168 values: list[str]
169 if isinstance(raw, str):
170 values = [raw]
171 elif isinstance(raw, (list, tuple)):
172 values = []
173 for item in raw:
174 if not isinstance(item, str):
175 raise ValueError("required_terms items must be strings")
176 values.append(item)
177 else:
178 raise ValueError("required_terms must be a string or list of strings")
179
180 terms: list[str] = []
181 for value in values:
182 for part in value.split(","):
183 part = part.strip()
184 if part:
185 terms.append(part)
186 return tuple(terms)
187
188
189 def _warn_weak_required_terms(required_terms: tuple[str, ...]) -> None:
190 """Warn when required_terms contain generic category words.
191
192 These terms are useful in the query but dangerous as identity gates:
193 broadening a small Chinese attraction from its proper name to "canyon" /
194 "stone pillar" raises coverage while admitting wrong entities.
195 """
196 weak: list[str] = []
197 for group in required_terms:
198 for part in group.split("|"):
199 normalized = part.strip().lower()
200 if normalized in _WEAK_REQUIRED_TERM_PARTS:
201 weak.append(part.strip())
202 if weak:
203 print(
204 " warning: required_terms contains generic category term(s) "
205 f"{weak}; keep proper-name / geography anchors too, and prefer "
206 "Needs-Manual or --from-url over loosening identity gates.",
207 file=sys.stderr,
208 )
209
210
211 # ---------------------------------------------------------------------------
212 # .env loading
213 # ---------------------------------------------------------------------------
214
215
216 def _load_search_env_file() -> None:
217 """Load image-search keys from the shared PPT Master .env locations."""
218 load_prefixed_env_file(("PEXELS_", "PIXABAY_"))
219
220
221 # ---------------------------------------------------------------------------
222 # Provider dispatch
223 # ---------------------------------------------------------------------------
224
225
226 def _load_provider(name: str):
227 return importlib.import_module(PROVIDER_MODULES[name])
228
229
230 def _is_keyed_provider_unconfigured(provider_name: str, exc: Exception) -> bool:
231 """Treat 'API key missing' as a non-fatal skip so the default provider
232 chain can keep going."""
233 if provider_name not in KEYED_PROVIDERS:
234 return False
235 return "API_KEY" in str(exc)
236
237
238 @dataclass
239 class SearchDownloadResult:
240 """Carry one search result without collapsing no-match and retryable failures."""
241
242 candidate: Optional[AssetCandidate] = None
243 provider_name: Optional[str] = None
244 stage: Optional[str] = None
245 actual_dimensions: Optional[tuple[int, int]] = None
246 staged_path: Optional[Path] = None
247 output_path: Optional[Path] = None
248 failure_kind: Optional[str] = None
249 error: Optional[str] = None
250
251
252 class DownloadQualityError(ValueError):
253 """Signal a readable candidate that fails the requested image contract."""
254
255
256 def _is_pillow_decompression_error(exc: BaseException) -> bool:
257 """Recognize Pillow's safety exception without making Pillow a hard import."""
258 cls = type(exc)
259 return (
260 cls.__name__ == "DecompressionBombError"
261 and cls.__module__.startswith("PIL.")
262 )
263
264
265 def _is_recoverable_image_error(exc: BaseException) -> bool:
266 """Return whether a provider/download/image failure can be reported cleanly."""
267 return isinstance(
268 exc,
269 (
270 requests.RequestException,
271 OSError,
272 RuntimeError,
273 SyntaxError,
274 ValueError,
275 ),
276 ) or _is_pillow_decompression_error(exc)
277
278
279 def _try_provider(
280 name: str,
281 request: ImageSearchRequest,
282 license_tier_filter: str,
283 *,
284 provider_is_explicit: bool = False,
285 ) -> tuple[Optional[list[AssetCandidate]], Optional[str]]:
286 """Run one provider while preserving whether it errored or returned no rows.
287
288 An explicitly selected provider is required; a missing key is retryable
289 instead of an optional-provider skip.
290 """
291 try:
292 module = _load_provider(name)
293 return module.search(request, license_tier_filter=license_tier_filter), None
294 except RuntimeError as exc:
295 if (
296 not provider_is_explicit
297 and _is_keyed_provider_unconfigured(name, exc)
298 ):
299 print(
300 f" [{name}] skipped: {exc}",
301 file=sys.stderr,
302 )
303 return None, None
304 else:
305 print(f" [{name}] error: {exc}", file=sys.stderr)
306 return None, f"{name}: {exc}"
307 except (requests.RequestException, OSError, ValueError) as exc:
308 print(f" [{name}] error: {exc}", file=sys.stderr)
309 return None, f"{name}: {exc}"
310 except ImportError as exc:
311 print(f" [{name}] error: {exc}", file=sys.stderr)
312 return None, f"{name}: provider import failed: {exc}"
313
314
315 # ---------------------------------------------------------------------------
316 # Post-download quality validation
317 # ---------------------------------------------------------------------------
318
319 _MIN_DOWNLOAD_PIXELS = 800 * 600 # preserve the existing absolute thumbnail floor
320
321
322 def _validate_downloaded_quality(
323 path: Path,
324 *,
325 min_width: int = 0,
326 min_height: int = 0,
327 enforce_thumbnail_floor: bool = True,
328 ) -> bool:
329 """Reject unreadable images and actual EXIF-oriented dimensions below contract.
330
331 Upstream metadata can be inaccurate (e.g. Openverse aggregates rawpixel
332 which only exposes a preview). This function checks what was actually
333 written to disk. Automated paths also reject thumbnails/previews; explicit
334 manual paths may disable that absolute floor while retaining their own
335 requested dimensions.
336 """
337 try:
338 from PIL import Image, ImageOps # type: ignore
339 except ImportError as exc:
340 raise RuntimeError(
341 "Pillow is required to validate downloaded image dimensions. "
342 "Install it with: pip install Pillow"
343 ) from exc
344 try:
345 with Image.open(path) as im:
346 oriented = ImageOps.exif_transpose(im)
347 oriented.load()
348 w, h = oriented.size
349 if oriented is not im:
350 oriented.close()
351 if w < min_width or h < min_height:
352 print(
353 f" rejected: downloaded image dimensions {w}x{h} are below "
354 f"the requested minimum {min_width}x{min_height}",
355 file=sys.stderr,
356 )
357 return False
358 if enforce_thumbnail_floor and w * h < _MIN_DOWNLOAD_PIXELS:
359 print(
360 f" rejected: downloaded image too small "
361 f"({w}x{h} = {w*h:,} px < {_MIN_DOWNLOAD_PIXELS:,} px minimum)",
362 file=sys.stderr,
363 )
364 return False
365 return True
366 except Exception as exc:
367 if not _is_recoverable_image_error(exc):
368 raise
369 print(
370 f" rejected: downloaded file is not a readable image ({exc})",
371 file=sys.stderr,
372 )
373 return False
374
375
376 def _stage_validated_image(
377 url: str,
378 output_path: Path,
379 *,
380 min_width: int,
381 min_height: int,
382 enforce_thumbnail_floor: bool = True,
383 ) -> tuple[Path, tuple[int, int]]:
384 """Download and validate beside the target without changing the canonical."""
385 output_path.parent.mkdir(parents=True, exist_ok=True)
386 fd, temp_name = tempfile.mkstemp(
387 prefix=f".{output_path.stem}.",
388 suffix=output_path.suffix,
389 dir=str(output_path.parent),
390 )
391 os.close(fd)
392 temp_path = Path(temp_name)
393 keep_temp = False
394 try:
395 download_image(
396 url,
397 str(temp_path),
398 headers={"User-Agent": USER_AGENT},
399 )
400 if not _validate_downloaded_quality(
401 temp_path,
402 min_width=min_width,
403 min_height=min_height,
404 enforce_thumbnail_floor=enforce_thumbnail_floor,
405 ):
406 raise DownloadQualityError(
407 "downloaded image did not satisfy the requested dimensions/readability"
408 )
409 actual_dimensions = _measure_actual_image(temp_path)
410 if actual_dimensions is None:
411 raise DownloadQualityError(
412 "downloaded image dimensions could not be measured"
413 )
414 keep_temp = True
415 return temp_path, actual_dimensions
416 finally:
417 if not keep_temp:
418 try:
419 temp_path.unlink(missing_ok=True)
420 except OSError:
421 pass
422
423
424 def _commit_staged_image(
425 staged_path: Path,
426 target_path: Path,
427 manifest_writer: Callable[[], Path],
428 ) -> Path:
429 """Install a staged image and roll it back if provenance cannot be written."""
430 target_path.parent.mkdir(parents=True, exist_ok=True)
431 backup_path: Optional[Path] = None
432 installed = False
433
434 try:
435 if target_path.exists():
436 if not target_path.is_file():
437 raise RuntimeError(
438 f"image target exists but is not a regular file: {target_path}"
439 )
440 fd, backup_name = tempfile.mkstemp(
441 prefix=f".{target_path.stem}.backup.",
442 suffix=target_path.suffix,
443 dir=str(target_path.parent),
444 )
445 os.close(fd)
446 reserved_backup_path = Path(backup_name)
447 reserved_backup_path.unlink()
448 os.replace(target_path, reserved_backup_path)
449 backup_path = reserved_backup_path
450
451 os.replace(staged_path, target_path)
452 installed = True
453 written = manifest_writer()
454 except Exception as exc:
455 rollback_errors: list[str] = []
456 if installed:
457 try:
458 target_path.unlink(missing_ok=True)
459 except OSError as rollback_exc:
460 rollback_errors.append(f"cannot remove new target: {rollback_exc}")
461 if backup_path is not None and backup_path.exists():
462 try:
463 os.replace(backup_path, target_path)
464 except OSError as rollback_exc:
465 rollback_errors.append(f"cannot restore prior target: {rollback_exc}")
466 if rollback_errors:
467 raise RuntimeError(
468 f"{exc}; image rollback also failed: {'; '.join(rollback_errors)}"
469 ) from exc
470 raise
471 else:
472 if backup_path is not None:
473 try:
474 backup_path.unlink(missing_ok=True)
475 except OSError as exc:
476 print(
477 f" warning: could not remove image backup {backup_path}: {exc}",
478 file=sys.stderr,
479 )
480 return written
481 finally:
482 try:
483 staged_path.unlink(missing_ok=True)
484 except OSError:
485 pass
486
487
488 def _write_review_copy(
489 src: Path, dest_dir: Path, name: str, max_side: int = 1024
490 ) -> Optional[Path]:
491 """Write a downscaled JPEG review copy of ``src`` into ``dest_dir``.
492
493 The placed / promoted asset is always the full-resolution original; this
494 bounded copy exists only so the agent can Read a sanely-sized image to
495 confirm suitability regardless of how large the source is. Best-effort —
496 returns None (non-fatal) if Pillow or the source is unavailable.
497 """
498 try:
499 from PIL import Image, ImageOps # type: ignore
500 except ImportError:
501 return None
502 review_path: Optional[Path] = None
503 try:
504 dest_dir.mkdir(parents=True, exist_ok=True)
505 review_path = dest_dir / f"{Path(name).stem}.jpg"
506 with Image.open(src) as im:
507 oriented = ImageOps.exif_transpose(im)
508 review = oriented.convert("RGB")
509 review.thumbnail((max_side, max_side))
510 review.save(review_path, "JPEG", quality=85)
511 review.close()
512 if oriented is not im:
513 oriented.close()
514 return review_path
515 except Exception as exc:
516 if not _is_recoverable_image_error(exc):
517 raise
518 if review_path is not None:
519 try:
520 review_path.unlink(missing_ok=True)
521 except OSError:
522 pass
523 return None
524
525
526 def _save_candidates_pool(
527 ranked: list[tuple[float, str, AssetCandidate]],
528 output_dir: Path,
529 stem: str,
530 selected_filename: str,
531 min_width: int,
532 min_height: int,
533 max_candidates: int = 4,
534 ) -> None:
535 """Download top-N candidates into ``candidates/<stem>/`` and write
536 a ``candidates.json`` manifest for manual review."""
537 cand_dir = output_dir / "candidates" / stem
538 cand_dir.mkdir(parents=True, exist_ok=True)
539
540 pool: list[dict] = []
541 idx = 0
542 for score, provider_name, candidate in ranked:
543 if idx >= max_candidates:
544 break
545 suffix = Path(candidate.download_url.split("?")[0]).suffix or ".jpg"
546 cand_filename = f"candidate_{idx + 1:02d}{suffix}"
547 cand_path = cand_dir / cand_filename
548 keep_candidate = False
549 try:
550 download_image(
551 candidate.download_url,
552 str(cand_path),
553 headers={"User-Agent": USER_AGENT},
554 )
555 if not _validate_downloaded_quality(
556 cand_path,
557 min_width=min_width,
558 min_height=min_height,
559 ):
560 continue
561 actual_dim = _measure_actual_image(cand_path)
562 review_path = _write_review_copy(
563 cand_path,
564 cand_dir / "review",
565 cand_filename,
566 )
567 idx += 1
568 pool.append({
569 "rank": idx,
570 "score": round(score, 2),
571 "filename": cand_filename,
572 "review": f"review/{review_path.name}" if review_path else None,
573 "provider": provider_name,
574 "title": candidate.title,
575 "author": candidate.author,
576 "source_page_url": candidate.source_page_url,
577 "download_url": candidate.download_url,
578 "license_name": candidate.license_name,
579 "license_url": candidate.license_url,
580 "license_tier": candidate.license_tier,
581 "attribution_required": (
582 candidate.license_tier == "attribution-required"
583 ),
584 "attribution_text": build_attribution_text(
585 selected_filename,
586 candidate,
587 ),
588 "width": actual_dim[0] if actual_dim else candidate.width,
589 "height": actual_dim[1] if actual_dim else candidate.height,
590 })
591 keep_candidate = True
592 except Exception as exc:
593 if not _is_recoverable_image_error(exc):
594 raise
595 continue
596 finally:
597 if not keep_candidate:
598 try:
599 cand_path.unlink(missing_ok=True)
600 except OSError:
601 pass
602
603 if pool:
604 meta = {
605 "target_filename": selected_filename,
606 "selected": pool[0]["filename"],
607 "searched_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
608 "candidates": pool,
609 }
610 meta_path = cand_dir / "candidates.json"
611 _write_json_atomic(meta_path, meta)
612 print(f" candidates: {cand_dir}/ ({len(pool)} saved)", file=sys.stderr)
613
614
615 def search_and_download(
616 providers: list[str],
617 request: ImageSearchRequest,
618 *,
619 output_path: Path,
620 strict_no_attribution: bool,
621 save_candidates: bool = False,
622 max_candidates: int = 4,
623 provider_is_explicit: bool = False,
624 ) -> SearchDownloadResult:
625 """Find a candidate AND successfully download it.
626
627 By default only the best match is downloaded. When ``save_candidates``
628 is True (opt-in), the top-N candidates are also saved to
629 ``candidates/<stem>/`` so the agent can review and ``--promote`` a
630 better fit when the best match does not pass visual confirmation.
631
632 Returns a structured result so batch mode can keep transient/provider
633 failures retryable while treating a complete no-match as terminal.
634 ``provider_is_explicit`` distinguishes a required provider from an optional
635 member of the default fallback chain.
636 """
637 license_filters: list[str] = (
638 ["no-attribution-only"] if strict_no_attribution else ["all"]
639 )
640
641 provider_errors: list[str] = []
642 download_errors: list[str] = []
643 quality_rejections = 0
644
645 for stage in license_filters:
646 ranked: list[tuple[float, str, AssetCandidate]] = []
647 for provider_name in providers:
648 print(f" -> trying {provider_name} ({stage}) ...", file=sys.stderr)
649 candidates, provider_error = _try_provider(
650 provider_name,
651 request,
652 stage,
653 provider_is_explicit=provider_is_explicit,
654 )
655 if provider_error:
656 provider_errors.append(provider_error)
657 if not candidates:
658 continue
659
660 provider_ranked = [
661 (score_candidate(c, request), provider_name, c) for c in candidates
662 ]
663 provider_ranked = [
664 item for item in provider_ranked if item[0] != float("-inf")
665 ]
666 if not provider_ranked:
667 reason = "query"
668 if request.required_terms:
669 reason += f" / required_terms={list(request.required_terms)}"
670 print(
671 f" no candidate matched {reason}; trying next provider/stage",
672 file=sys.stderr,
673 )
674 continue
675 ranked.extend(provider_ranked)
676
677 sorted_ranked = sorted(ranked, key=lambda item: item[0], reverse=True)
678
679 # --- Save candidate pool (before picking the winner) ---
680 if save_candidates and sorted_ranked:
681 stem = Path(output_path).stem
682 try:
683 _save_candidates_pool(
684 sorted_ranked,
685 output_path.parent,
686 stem,
687 output_path.name,
688 request.min_width,
689 request.min_height,
690 max_candidates=max_candidates,
691 )
692 except Exception as exc:
693 if not _is_recoverable_image_error(exc):
694 raise
695 print(
696 f" warning: candidate pool could not be saved: {exc}",
697 file=sys.stderr,
698 )
699
700 # --- Pick the best downloadable candidate ---
701 for _score, provider_name, candidate in sorted_ranked:
702 # If candidates were already saved, the file may already
703 # exist in the candidates dir — but we still need the
704 # primary copy at output_path.
705 try:
706 staged_path, actual_dimensions = _stage_validated_image(
707 candidate.download_url,
708 output_path,
709 min_width=request.min_width,
710 min_height=request.min_height,
711 )
712 return SearchDownloadResult(
713 candidate=candidate,
714 provider_name=provider_name,
715 stage=stage,
716 actual_dimensions=actual_dimensions,
717 staged_path=staged_path,
718 output_path=output_path,
719 )
720 except DownloadQualityError:
721 quality_rejections += 1
722 continue
723 except Exception as exc:
724 if not _is_recoverable_image_error(exc):
725 raise
726 print(
727 f" download failed for {candidate.title!r}: {exc}",
728 file=sys.stderr,
729 )
730 download_errors.append(f"{provider_name}/{candidate.title}: {exc}")
731 continue
732
733 retryable_errors = provider_errors + download_errors
734 if retryable_errors:
735 return SearchDownloadResult(
736 failure_kind=SEARCH_FAILURE_RETRYABLE,
737 error="; ".join(retryable_errors)[:500],
738 )
739
740 detail = "no acceptable candidate across all providers/stages"
741 if quality_rejections:
742 detail += f" ({quality_rejections} candidate(s) failed actual-size/readability gates)"
743 return SearchDownloadResult(
744 failure_kind=SEARCH_FAILURE_NO_MATCH,
745 error=detail,
746 )
747
748
749 # ---------------------------------------------------------------------------
750 # Manifest
751 # ---------------------------------------------------------------------------
752
753
754 def default_manifest_path(output_dir: str) -> Path:
755 return Path(output_dir) / "image_sources.json"
756
757
758 def _validate_bare_filename(value: str, *, field_name: str = "filename") -> str:
759 """Require a bare filename with no absolute or parent path components."""
760 if (
761 not value.strip()
762 or value in {".", ".."}
763 or "/" in value
764 or "\\" in value
765 or ":" in value
766 or Path(value).is_absolute()
767 ):
768 raise ValueError(
769 f"{field_name} must be a bare filename without path components: {value!r}"
770 )
771 return value
772
773
774 def _measure_actual_image(path: Path) -> Optional[tuple[int, int]]:
775 """Return ``(width, height)`` of the file actually saved at ``path``.
776
777 Upstream metadata (``candidate.width``/``height``) describes the
778 original image on the provider's server, which may differ from what
779 we are allowed to download — for example, second-tier sources
780 aggregated by Openverse (rawpixel etc.) often only expose a
781 1024px-wide preview. The Executor needs to know what is actually on
782 disk for layout purposes; this function provides that ground truth.
783
784 Returns ``None`` if Pillow is unavailable or the file is unreadable.
785 """
786 try:
787 from PIL import Image, ImageOps # type: ignore
788 except ImportError:
789 return None
790 try:
791 with Image.open(path) as im:
792 oriented = ImageOps.exif_transpose(im)
793 try:
794 return int(oriented.width), int(oriented.height)
795 finally:
796 if oriented is not im:
797 oriented.close()
798 except Exception as exc:
799 if not _is_recoverable_image_error(exc):
800 raise
801 return None
802
803
804 def _candidate_to_manifest_item(
805 candidate: AssetCandidate,
806 args: argparse.Namespace,
807 *,
808 provider_name: str,
809 stage: str,
810 actual_dimensions: Optional[tuple[int, int]] = None,
811 ) -> dict:
812 """Build the manifest entry.
813
814 ``width`` / ``height`` reflect the file actually saved to disk
815 (measured by Pillow after download). The upstream-claimed dimensions
816 are only kept under ``metadata_dimensions`` when they disagree with
817 reality, which is the only case where this distinction matters.
818 """
819 if actual_dimensions is not None:
820 width, height = actual_dimensions
821 else:
822 width, height = candidate.width, candidate.height
823
824 item = {
825 "filename": args.filename,
826 "slide": args.slide,
827 "purpose": args.purpose,
828 "search_query": args.query,
829 "orientation": args.orientation,
830 "provider": provider_name,
831 "stage": stage,
832 "title": candidate.title,
833 "author": candidate.author,
834 "source_page_url": candidate.source_page_url,
835 "download_url": candidate.download_url,
836 "license_name": candidate.license_name,
837 "license_url": candidate.license_url,
838 "license_tier": candidate.license_tier,
839 "attribution_required": candidate.license_tier == "attribution-required",
840 "width": width,
841 "height": height,
842 "attribution_text": build_attribution_text(args.filename, candidate),
843 "status": "sourced",
844 }
845 required_terms = _parse_required_terms(
846 getattr(args, "required_terms", None) or getattr(args, "require_terms", None)
847 )
848 if required_terms:
849 item["required_terms"] = list(required_terms)
850
851 # Only carry upstream-claimed dimensions when they differ — this flags
852 # cases where the provider returned a preview rather than the original.
853 if (
854 actual_dimensions is not None
855 and candidate.width
856 and candidate.height
857 and (candidate.width, candidate.height) != actual_dimensions
858 ):
859 item["metadata_dimensions"] = {
860 "width": candidate.width,
861 "height": candidate.height,
862 "note": "upstream-reported size; actual downloaded file is smaller (likely a preview)",
863 }
864
865 return item
866
867
868 def _read_existing_manifest(path: Path) -> dict:
869 if not path.exists():
870 return {}
871 try:
872 payload = json.loads(path.read_text(encoding="utf-8"))
873 except (OSError, json.JSONDecodeError) as exc:
874 raise RuntimeError(
875 f"existing image sources manifest is unreadable: {path} ({exc}); "
876 "repair or restore it before continuing"
877 ) from exc
878 if not isinstance(payload, dict):
879 raise RuntimeError(
880 f"existing image sources manifest must be a JSON object: {path}"
881 )
882 items = payload.get("items")
883 if not isinstance(items, list):
884 raise RuntimeError(
885 f"existing image sources manifest must contain an 'items' array: {path}"
886 )
887 if any(not isinstance(item, dict) for item in items):
888 raise RuntimeError(
889 f"existing image sources manifest contains a non-object item: {path}"
890 )
891 seen_filenames: dict[str, str] = {}
892 for index, item in enumerate(items):
893 filename = item.get("filename")
894 if not isinstance(filename, str):
895 raise RuntimeError(
896 f"existing image sources manifest items[{index}].filename "
897 f"must be a non-empty bare filename: {path}"
898 )
899 try:
900 _validate_bare_filename(filename)
901 except ValueError as exc:
902 raise RuntimeError(
903 f"existing image sources manifest items[{index}]: {exc}: {path}"
904 ) from exc
905 normalized_filename = filename.casefold()
906 if normalized_filename in seen_filenames:
907 raise RuntimeError(
908 f"existing image sources manifest filename {filename!r} conflicts "
909 f"with {seen_filenames[normalized_filename]!r} "
910 f"(case-insensitive): {path}"
911 )
912 seen_filenames[normalized_filename] = filename
913 return payload
914
915
916 def _write_json_atomic(path: str | Path, payload: dict) -> Path:
917 """Write JSON through a same-directory temporary file and atomic rename."""
918 target = ensure_json_parent(path)
919 fd, tmp_path = tempfile.mkstemp(
920 prefix=target.stem + ".", suffix=".tmp", dir=str(target.parent)
921 )
922 try:
923 with os.fdopen(fd, "w", encoding="utf-8") as handle:
924 json.dump(payload, handle, ensure_ascii=False, indent=2)
925 handle.write("\n")
926 os.replace(tmp_path, target)
927 except Exception:
928 try:
929 os.unlink(tmp_path)
930 except OSError:
931 pass
932 raise
933 return target
934
935
936 def write_sources_manifest(path: Path, item: dict) -> Path:
937 """Append ``item`` to the manifest at ``path``, replacing any prior
938 entry that targets the same filename."""
939 manifest_path = Path(path)
940 payload = _read_existing_manifest(manifest_path)
941 filename = item.get("filename")
942 if not isinstance(filename, str):
943 raise RuntimeError("new image source item requires a string filename")
944 try:
945 _validate_bare_filename(filename)
946 except ValueError as exc:
947 raise RuntimeError(f"new image source item: {exc}") from exc
948
949 items: list[dict] = list(payload.get("items") or [])
950 normalized_filename = filename.casefold()
951 differently_cased = next(
952 (
953 existing["filename"]
954 for existing in items
955 if isinstance(existing.get("filename"), str)
956 and existing["filename"].casefold() == normalized_filename
957 and existing["filename"] != filename
958 ),
959 None,
960 )
961 if differently_cased is not None:
962 raise RuntimeError(
963 f"new image source filename {filename!r} conflicts with existing "
964 f"{differently_cased!r} (case-insensitive)"
965 )
966 items = [
967 i
968 for i in items
969 if not isinstance(i.get("filename"), str)
970 or i["filename"].casefold() != normalized_filename
971 ]
972 items.append(item)
973
974 payload["items"] = items
975 payload["generated_at"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
976 payload.setdefault(
977 "license_verification",
978 "provider metadata used; manual review recommended for external delivery",
979 )
980
981 return _write_json_atomic(manifest_path, payload)
982
983
984 # ---------------------------------------------------------------------------
985 # Promote: replace primary image with a candidate
986 # ---------------------------------------------------------------------------
987
988
989 def promote_candidate(
990 output_dir: Path,
991 target_filename: str,
992 candidate_filename: str,
993 manifest_path: Optional[Path] = None,
994 ) -> int:
995 """Replace the primary image with a candidate from the pool.
996
997 Steps:
998 1. Stage and validate ``candidates/<stem>/<candidate_filename>``
999 2. Replace the canonical image and its provenance as one rollback unit
1000 3. Advance ``candidates.json`` only after provenance succeeds
1001 """
1002 import shutil
1003
1004 target_filename = _validate_bare_filename(
1005 target_filename, field_name="target filename"
1006 )
1007 candidate_filename = _validate_bare_filename(
1008 candidate_filename, field_name="candidate filename"
1009 )
1010 mpath = manifest_path or default_manifest_path(str(output_dir))
1011 try:
1012 manifest = _read_existing_manifest(mpath)
1013 except RuntimeError as exc:
1014 print(f"Error: {exc}", file=sys.stderr)
1015 return 1
1016
1017 stem = Path(target_filename).stem
1018 cand_dir = output_dir / "candidates" / stem
1019 cand_meta_path = cand_dir / "candidates.json"
1020
1021 if not cand_meta_path.is_file():
1022 print(f"Error: {cand_meta_path} not found.", file=sys.stderr)
1023 return 1
1024
1025 try:
1026 meta = json.loads(cand_meta_path.read_text(encoding="utf-8"))
1027 except (OSError, json.JSONDecodeError) as exc:
1028 print(f"Error: cannot read {cand_meta_path}: {exc}", file=sys.stderr)
1029 return 1
1030 if not isinstance(meta, dict) or not isinstance(meta.get("candidates"), list):
1031 print(
1032 f"Error: {cand_meta_path} must contain a candidates array.",
1033 file=sys.stderr,
1034 )
1035 return 1
1036 candidates = meta.get("candidates", [])
1037
1038 entry = next(
1039 (
1040 candidate
1041 for candidate in candidates
1042 if isinstance(candidate, dict)
1043 and candidate.get("filename") == candidate_filename
1044 ),
1045 None,
1046 )
1047 if entry is None:
1048 names = [
1049 str(candidate.get("filename"))
1050 for candidate in candidates
1051 if isinstance(candidate, dict) and candidate.get("filename")
1052 ]
1053 print(
1054 f"Error: '{candidate_filename}' not found. Available: {', '.join(names)}",
1055 file=sys.stderr,
1056 )
1057 return 1
1058
1059 src_path = cand_dir / candidate_filename
1060 dst_path = output_dir / target_filename
1061 if not src_path.is_file():
1062 print(f"Error: {src_path} does not exist on disk.", file=sys.stderr)
1063 return 1
1064
1065 items: list[dict] = list(manifest.get("items") or [])
1066 target_item: Optional[dict] = None
1067 for item in items:
1068 filename = item.get("filename")
1069 if (
1070 isinstance(filename, str)
1071 and filename.casefold() == target_filename.casefold()
1072 ):
1073 target_item = item
1074 break
1075 if target_item is None:
1076 print(
1077 f"Error: {mpath} has no provenance entry for {target_filename!r}.",
1078 file=sys.stderr,
1079 )
1080 return 1
1081 if target_item["filename"] != target_filename:
1082 print(
1083 f"Error: target filename casing {target_filename!r} conflicts with "
1084 f"provenance filename {target_item['filename']!r}.",
1085 file=sys.stderr,
1086 )
1087 return 1
1088
1089 output_dir.mkdir(parents=True, exist_ok=True)
1090 fd, staged_name = tempfile.mkstemp(
1091 prefix=f".{dst_path.stem}.promote.",
1092 suffix=dst_path.suffix,
1093 dir=str(dst_path.parent),
1094 )
1095 os.close(fd)
1096 staged_path = Path(staged_name)
1097 try:
1098 shutil.copy2(src_path, staged_path)
1099 if not _validate_downloaded_quality(
1100 staged_path,
1101 enforce_thumbnail_floor=False,
1102 ):
1103 raise DownloadQualityError(
1104 "candidate image did not satisfy the readability/size gate"
1105 )
1106 actual_dim = _measure_actual_image(staged_path)
1107 if actual_dim is None:
1108 raise DownloadQualityError("candidate dimensions could not be measured")
1109 w, h = actual_dim
1110 if w < 1 or h < 1:
1111 raise DownloadQualityError("candidate dimensions must be positive")
1112 if w * h < _MIN_DOWNLOAD_PIXELS:
1113 print(
1114 f" warning: explicitly promoted image is low resolution ({w}x{h})",
1115 file=sys.stderr,
1116 )
1117
1118 target_item["provider"] = entry.get("provider", "")
1119 target_item["title"] = entry.get("title", "")
1120 target_item["author"] = entry.get("author", "")
1121 target_item["source_page_url"] = entry.get("source_page_url", "")
1122 target_item["download_url"] = entry.get("download_url", "")
1123 target_item["license_name"] = entry.get("license_name", "")
1124 target_item["license_url"] = entry.get("license_url", "")
1125 target_item["license_tier"] = entry.get("license_tier", "")
1126 target_item["attribution_required"] = entry.get(
1127 "attribution_required",
1128 False,
1129 )
1130 # Recompute the credit from the promoted candidate — never carry the
1131 # replaced image's attribution_text (wrong author/title/source).
1132 target_item["attribution_text"] = build_attribution_text(
1133 target_filename,
1134 AssetCandidate(
1135 provider=entry.get("provider", ""),
1136 title=entry.get("title", ""),
1137 source_page_url=entry.get("source_page_url", ""),
1138 license_name=entry.get("license_name", ""),
1139 license_url=entry.get("license_url", ""),
1140 license_tier=entry.get("license_tier", ""),
1141 width=w,
1142 height=h,
1143 download_url=entry.get("download_url", ""),
1144 author=entry.get("author", ""),
1145 ),
1146 )
1147 target_item["width"] = w
1148 target_item["height"] = h
1149 target_item.pop("metadata_dimensions", None)
1150 target_item["status"] = "promoted"
1151 manifest["items"] = items
1152 manifest["generated_at"] = (
1153 datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
1154 )
1155
1156 _commit_staged_image(
1157 staged_path,
1158 dst_path,
1159 lambda: _write_json_atomic(mpath, manifest),
1160 )
1161 except Exception as exc:
1162 if not _is_recoverable_image_error(exc):
1163 raise
1164 print(f"Error: candidate promotion failed: {exc}", file=sys.stderr)
1165 return 1
1166 finally:
1167 try:
1168 staged_path.unlink(missing_ok=True)
1169 except OSError:
1170 pass
1171
1172 print(f" promoted: {candidate_filename} → {target_filename}", file=sys.stderr)
1173 print(f" manifest updated: {mpath}", file=sys.stderr)
1174
1175 # The selection marker must never move ahead of canonical provenance.
1176 meta["selected"] = candidate_filename
1177 try:
1178 _write_json_atomic(cand_meta_path, meta)
1179 except OSError as exc:
1180 print(
1181 f"Error: image was promoted, but {cand_meta_path} could not be updated: "
1182 f"{exc}",
1183 file=sys.stderr,
1184 )
1185 return 1
1186
1187 review = _write_review_copy(dst_path, output_dir / ".review", target_filename)
1188 if review is not None:
1189 print(f" review copy: {review}", file=sys.stderr)
1190 return 0
1191
1192
1193 # ---------------------------------------------------------------------------
1194 # Manual URL replacement (model-agnostic)
1195 # ---------------------------------------------------------------------------
1196
1197
1198 def fetch_url_replace(
1199 output_dir: Path,
1200 target_filename: str,
1201 url: str,
1202 manifest_path: Optional[Path] = None,
1203 *,
1204 slide: str = "",
1205 purpose: str = "",
1206 search_query: str = "",
1207 orientation: str = "",
1208 required_terms: tuple[str, ...] = (),
1209 min_width: int = 1200,
1210 min_height: int = 800,
1211 ) -> int:
1212 """Download a user-supplied image URL into the target and record it.
1213
1214 The model-agnostic manual path: when an automated best match is not
1215 suitable (or the running model cannot see images at all), a human finds a
1216 good image, passes its URL, and it replaces the target. License is unknown
1217 for an arbitrary URL, so the manifest marks it ``manual`` and notes that
1218 verifying usage rights is the user's responsibility.
1219 """
1220 target_filename = _validate_bare_filename(
1221 target_filename, field_name="target filename"
1222 )
1223 mpath = manifest_path or default_manifest_path(str(output_dir))
1224 try:
1225 existing_manifest = _read_existing_manifest(mpath)
1226 except RuntimeError as exc:
1227 print(f"Error: {exc}", file=sys.stderr)
1228 return 1
1229 prior = next(
1230 (
1231 item
1232 for item in existing_manifest.get("items", [])
1233 if isinstance(item.get("filename"), str)
1234 and item["filename"].casefold() == target_filename.casefold()
1235 ),
1236 {},
1237 )
1238 if prior and prior["filename"] != target_filename:
1239 print(
1240 f"Error: target filename casing {target_filename!r} conflicts with "
1241 f"provenance filename {prior['filename']!r}.",
1242 file=sys.stderr,
1243 )
1244 return 1
1245 try:
1246 inherited_required_terms = _parse_required_terms(
1247 prior.get("required_terms")
1248 )
1249 except ValueError as exc:
1250 print(
1251 f"Error: existing provenance has invalid required_terms: {exc}",
1252 file=sys.stderr,
1253 )
1254 return 1
1255 final_required_terms = inherited_required_terms or required_terms
1256
1257 output_dir.mkdir(parents=True, exist_ok=True)
1258 dst_path = output_dir / target_filename
1259 try:
1260 staged_path, actual_dim = _stage_validated_image(
1261 url,
1262 dst_path,
1263 min_width=min_width,
1264 min_height=min_height,
1265 enforce_thumbnail_floor=False,
1266 )
1267 except (
1268 DownloadQualityError,
1269 requests.RequestException,
1270 OSError,
1271 RuntimeError,
1272 ValueError,
1273 ) as exc:
1274 print(f"Error: failed to download {url}: {exc}", file=sys.stderr)
1275 return 1
1276
1277 # Inherit page context (which slide / purpose / query this image serves)
1278 # from the entry being replaced; override only source / license / size /
1279 # status so the audit trail survives a manual swap.
1280 item = {
1281 "filename": target_filename,
1282 "slide": prior.get("slide") or slide,
1283 "purpose": prior.get("purpose") or purpose,
1284 "search_query": prior.get("search_query") or search_query,
1285 "orientation": prior.get("orientation") or orientation,
1286 "provider": "manual",
1287 "title": "",
1288 "author": "",
1289 "source_page_url": url,
1290 "download_url": url,
1291 "license_name": "unverified — user-supplied URL",
1292 "license_url": "",
1293 "license_tier": "manual",
1294 "attribution_required": False,
1295 "width": actual_dim[0],
1296 "height": actual_dim[1],
1297 "attribution_text": "",
1298 "status": "manual",
1299 "note": (
1300 "Manually supplied image URL; verifying usage rights is the user's "
1301 "responsibility."
1302 ),
1303 }
1304 if final_required_terms:
1305 item["required_terms"] = list(final_required_terms)
1306
1307 try:
1308 written = _commit_staged_image(
1309 staged_path,
1310 dst_path,
1311 lambda: write_sources_manifest(mpath, item),
1312 )
1313 except (
1314 OSError,
1315 RuntimeError,
1316 ValueError,
1317 ) as exc:
1318 print(
1319 f"Error: failed to replace {target_filename} and record provenance: {exc}",
1320 file=sys.stderr,
1321 )
1322 return 1
1323 finally:
1324 try:
1325 staged_path.unlink(missing_ok=True)
1326 except OSError:
1327 pass
1328
1329 print(f" fetched: {url} -> {target_filename}", file=sys.stderr)
1330 print(f" manifest updated: {written}", file=sys.stderr)
1331 review = _write_review_copy(dst_path, output_dir / ".review", target_filename)
1332 if review is not None:
1333 print(f" review copy: {review}", file=sys.stderr)
1334 return 0
1335
1336
1337 # ---------------------------------------------------------------------------
1338 # Batch mode (`--batch image_queries.json`)
1339 # ---------------------------------------------------------------------------
1340
1341
1342 def load_search_manifest(path: str) -> dict:
1343 """Load and validate an ``image_queries.json`` batch manifest.
1344
1345 Schema (top level): ``{"items": [ ... ]}``. Each item requires
1346 ``filename``, ``query``, ``status``. Optional per-item overrides:
1347 ``slide``, ``purpose``, ``orientation``, ``provider``,
1348 ``strict_no_attribution``, ``min_width``, ``min_height``, ``last_error``.
1349 """
1350 try:
1351 data = json.loads(Path(path).read_text(encoding="utf-8"))
1352 except OSError as exc:
1353 raise ValueError(f"Cannot read {path}: {exc}") from exc
1354 except json.JSONDecodeError as exc:
1355 raise ValueError(
1356 f"Invalid JSON in {path}: {exc.msg} "
1357 f"(line {exc.lineno}, col {exc.colno})"
1358 ) from exc
1359
1360 if not isinstance(data, dict):
1361 raise ValueError(
1362 f"{path}: top level must be a JSON object, got {type(data).__name__}"
1363 )
1364
1365 items = data.get("items")
1366 if not isinstance(items, list) or not items:
1367 raise ValueError(f"{path}: 'items' must be a non-empty array")
1368
1369 seen_filenames: dict[str, str] = {}
1370 for i, item in enumerate(items):
1371 prefix = f"{path}: items[{i}]"
1372 if not isinstance(item, dict):
1373 raise ValueError(f"{prefix} must be an object")
1374 for field in SEARCH_REQUIRED_ITEM_FIELDS:
1375 if field not in item:
1376 raise ValueError(f"{prefix} missing required field '{field}'")
1377 if not isinstance(item[field], str) or not item[field].strip():
1378 raise ValueError(
1379 f"{prefix} field '{field}' must be a non-empty string"
1380 )
1381 if item["status"] not in SEARCH_VALID_STATUSES:
1382 raise ValueError(
1383 f"{prefix} status '{item['status']}' is invalid. "
1384 f"Valid: {sorted(SEARCH_VALID_STATUSES)}"
1385 )
1386 if "required_terms" in item:
1387 try:
1388 _parse_required_terms(item["required_terms"])
1389 except ValueError as exc:
1390 raise ValueError(f"{prefix} {exc}") from exc
1391 for dimension_field in ("min_width", "min_height"):
1392 if dimension_field not in item:
1393 continue
1394 value = item[dimension_field]
1395 if (
1396 not isinstance(value, int)
1397 or isinstance(value, bool)
1398 or value < 1
1399 ):
1400 raise ValueError(
1401 f"{prefix} field '{dimension_field}' must be a positive integer"
1402 )
1403 fname = item["filename"]
1404 try:
1405 _validate_bare_filename(fname)
1406 except ValueError as exc:
1407 raise ValueError(f"{prefix} {exc}") from exc
1408 normalized_filename = fname.casefold()
1409 if normalized_filename in seen_filenames:
1410 raise ValueError(
1411 f"{prefix} filename {fname!r} conflicts with "
1412 f"{seen_filenames[normalized_filename]!r} (case-insensitive)"
1413 )
1414 seen_filenames[normalized_filename] = fname
1415
1416 return data
1417
1418
1419 def save_search_manifest(path: str, data: dict) -> None:
1420 """Atomically write the batch manifest back (tmp file + rename)."""
1421 try:
1422 _write_json_atomic(path, data)
1423 except OSError as exc:
1424 raise RuntimeError(
1425 f"cannot update image query manifest {path}: {exc}"
1426 ) from exc
1427
1428
1429 def _resolve_search_concurrency(cli_value: Optional[int]) -> int:
1430 """CLI value wins over IMAGE_SEARCH_CONCURRENCY env; default 3."""
1431 if cli_value is not None:
1432 return max(1, cli_value)
1433 env_val = os.environ.get("IMAGE_SEARCH_CONCURRENCY", "").strip()
1434 if env_val.isdigit():
1435 return max(1, int(env_val))
1436 return DEFAULT_SEARCH_CONCURRENCY
1437
1438
1439 def _search_one_item(
1440 item: dict,
1441 *,
1442 output_dir: Path,
1443 save_candidates: bool,
1444 max_candidates: int,
1445 default_provider: Optional[str],
1446 default_strict: bool,
1447 default_min_width: int,
1448 default_min_height: int,
1449 ) -> tuple[
1450 Optional[dict],
1451 Optional[str],
1452 bool,
1453 Optional[Path],
1454 Optional[Path],
1455 ]:
1456 """Run the full search + download for one batch item (thread worker).
1457
1458 Returns ``(manifest_item, error, retryable, staged_path, output_path)``.
1459 Only network and staged-file work happens here; canonical replacement and
1460 all manifest writes are serialized by the caller.
1461 """
1462 filename = _validate_bare_filename(item["filename"])
1463 orientation = item.get("orientation", "any") or "any"
1464 strict = bool(item.get("strict_no_attribution", default_strict))
1465 required_terms = _parse_required_terms(item.get("required_terms"))
1466 _warn_weak_required_terms(required_terms)
1467 request = ImageSearchRequest(
1468 query=item["query"],
1469 purpose=item.get("purpose", ""),
1470 orientation="" if orientation == "any" else orientation,
1471 filename=filename,
1472 slide=item.get("slide", ""),
1473 min_width=int(item.get("min_width", default_min_width)),
1474 min_height=int(item.get("min_height", default_min_height)),
1475 required_terms=required_terms,
1476 )
1477
1478 pinned = item.get("provider") or default_provider
1479 providers = [pinned] if pinned else _default_provider_chain()
1480 output_path = output_dir / filename
1481
1482 result = search_and_download(
1483 providers,
1484 request,
1485 output_path=output_path,
1486 strict_no_attribution=strict,
1487 save_candidates=save_candidates,
1488 max_candidates=max_candidates,
1489 provider_is_explicit=bool(pinned),
1490 )
1491 if result.candidate is None:
1492 return (
1493 None,
1494 result.error or "search failed",
1495 result.failure_kind == SEARCH_FAILURE_RETRYABLE,
1496 None,
1497 None,
1498 )
1499 if result.staged_path is None or result.output_path is None:
1500 return (
1501 None,
1502 "search succeeded without a staged output",
1503 True,
1504 None,
1505 None,
1506 )
1507
1508 try:
1509 item_args = argparse.Namespace(
1510 filename=filename,
1511 slide=item.get("slide", ""),
1512 purpose=item.get("purpose", ""),
1513 query=item["query"],
1514 orientation=orientation,
1515 required_terms=request.required_terms,
1516 )
1517 manifest_item = _candidate_to_manifest_item(
1518 result.candidate,
1519 item_args,
1520 provider_name=result.provider_name or "",
1521 stage=result.stage or "",
1522 actual_dimensions=result.actual_dimensions,
1523 )
1524 return (
1525 manifest_item,
1526 None,
1527 False,
1528 result.staged_path,
1529 result.output_path,
1530 )
1531 except Exception:
1532 if result.staged_path is not None:
1533 try:
1534 result.staged_path.unlink(missing_ok=True)
1535 except OSError:
1536 pass
1537 raise
1538
1539
1540 def run_search_manifest(
1541 manifest: dict,
1542 manifest_path: str,
1543 *,
1544 output_dir: Path,
1545 sources_manifest_path: Path,
1546 concurrency: int,
1547 save_candidates: bool,
1548 max_candidates: int,
1549 default_provider: Optional[str],
1550 default_strict: bool,
1551 default_min_width: int,
1552 default_min_height: int,
1553 ) -> tuple[int, int, int, int]:
1554 """Process all Pending/Failed rows concurrently with a bounded pool.
1555
1556 On success the rich provenance entry is appended to ``image_sources.json``
1557 (the credit source of truth) and the row's status flips to ``Sourced``.
1558 A row that exhausts the provider/stage chain becomes ``Needs-Manual``
1559 (terminal). Status is written back after each completion, so an interrupt
1560 preserves finished rows. Returns ``(sourced, needs_manual, failed, skipped)``.
1561 """
1562 sources_manifest = _read_existing_manifest(sources_manifest_path)
1563 items = manifest["items"]
1564
1565 provenance_filenames = {
1566 item["filename"].casefold(): item["filename"]
1567 for item in sources_manifest.get("items", [])
1568 if isinstance(item.get("filename"), str)
1569 }
1570 repaired_sourced = False
1571 for item in items:
1572 if item["status"] != SEARCH_STATUS_SOURCED:
1573 continue
1574 filename = item["filename"]
1575 target_path = output_dir / filename
1576 reasons: list[str] = []
1577 provenance_filename = provenance_filenames.get(filename.casefold())
1578 if provenance_filename is None:
1579 reasons.append("image_sources.json has no matching provenance entry")
1580 elif provenance_filename != filename:
1581 reasons.append(
1582 "image_sources.json filename casing does not match "
1583 f"({provenance_filename!r} vs {filename!r})"
1584 )
1585 if not target_path.is_file():
1586 reasons.append("target file is missing")
1587 else:
1588 min_width = int(item.get("min_width", default_min_width))
1589 min_height = int(item.get("min_height", default_min_height))
1590 try:
1591 target_is_valid = _validate_downloaded_quality(
1592 target_path,
1593 min_width=min_width,
1594 min_height=min_height,
1595 )
1596 except RuntimeError as exc:
1597 reasons.append(f"target validation unavailable: {exc}")
1598 else:
1599 if not target_is_valid:
1600 reasons.append(
1601 "target file is unreadable or below requested dimensions"
1602 )
1603 if reasons:
1604 item["status"] = SEARCH_STATUS_FAILED
1605 item["last_error"] = (
1606 "Sourced state validation failed: " + "; ".join(reasons)
1607 )[:500]
1608 repaired_sourced = True
1609 print(f" [RETRY] {filename} — {item['last_error']}")
1610 if repaired_sourced:
1611 save_search_manifest(manifest_path, manifest)
1612
1613 pending_idx = [
1614 i for i, it in enumerate(items)
1615 if it["status"] in SEARCH_RETRYABLE_STATUSES
1616 ]
1617 total = len(pending_idx)
1618 skipped = len(items) - total
1619
1620 if total == 0:
1621 print(
1622 f"[Batch] Nothing to do — all {len(items)} row(s) already in a "
1623 "terminal state (Sourced / Needs-Manual)."
1624 )
1625 return 0, 0, 0, skipped
1626
1627 print(
1628 f"\n[Batch] {total} row(s) to search, {skipped} already done. "
1629 f"concurrency={concurrency}\n"
1630 )
1631
1632 sourced_count = 0
1633 needs_manual_count = 0
1634 failed_count = 0
1635 write_lock = threading.Lock()
1636
1637 def _one(idx: int):
1638 try:
1639 manifest_item, error, retryable, staged_path, target_path = (
1640 _search_one_item(
1641 items[idx],
1642 output_dir=output_dir,
1643 save_candidates=save_candidates,
1644 max_candidates=max_candidates,
1645 default_provider=default_provider,
1646 default_strict=default_strict,
1647 default_min_width=default_min_width,
1648 default_min_height=default_min_height,
1649 )
1650 )
1651 return (
1652 idx,
1653 manifest_item,
1654 error,
1655 retryable,
1656 staged_path,
1657 target_path,
1658 )
1659 except Exception as exc: # noqa: BLE001 — provider code raises freely
1660 return idx, None, str(exc)[:500], True, None, None
1661
1662 futures: list[concurrent.futures.Future] = []
1663 try:
1664 with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as ex:
1665 futures = [ex.submit(_one, i) for i in pending_idx]
1666 for fut in concurrent.futures.as_completed(futures):
1667 (
1668 idx,
1669 manifest_item,
1670 error,
1671 retryable,
1672 staged_path,
1673 target_path,
1674 ) = fut.result()
1675 item = items[idx]
1676 with write_lock:
1677 if (
1678 manifest_item is not None
1679 and staged_path is not None
1680 and target_path is not None
1681 ):
1682 try:
1683 _commit_staged_image(
1684 staged_path,
1685 target_path,
1686 lambda: write_sources_manifest(
1687 sources_manifest_path,
1688 manifest_item,
1689 ),
1690 )
1691 except Exception as exc:
1692 if not _is_recoverable_image_error(exc):
1693 raise
1694 item["status"] = SEARCH_STATUS_FAILED
1695 item["last_error"] = (
1696 f"canonical/provenance commit failed: {exc}"
1697 )[:500]
1698 failed_count += 1
1699 print(
1700 f" [FAIL] {item['filename']} — "
1701 f"{item['last_error']}"
1702 )
1703 else:
1704 item["status"] = SEARCH_STATUS_SOURCED
1705 item["provider"] = manifest_item.get("provider", "")
1706 item["license_tier"] = manifest_item.get(
1707 "license_tier",
1708 "",
1709 )
1710 item.pop("last_error", None)
1711 sourced_count += 1
1712 review = _write_review_copy(
1713 target_path,
1714 output_dir / ".review",
1715 target_path.name,
1716 )
1717 if review is not None:
1718 print(
1719 f" review copy: {review}",
1720 file=sys.stderr,
1721 )
1722 print(
1723 f" [OK] {item['filename']} "
1724 f"({item['provider']})"
1725 )
1726 elif retryable:
1727 item["status"] = SEARCH_STATUS_FAILED
1728 item["last_error"] = error or "provider/download failure"
1729 failed_count += 1
1730 print(
1731 f" [FAIL] {item['filename']} — {item['last_error']}"
1732 )
1733 else:
1734 item["status"] = SEARCH_STATUS_NEEDS_MANUAL
1735 item["last_error"] = error or "search failed"
1736 needs_manual_count += 1
1737 print(
1738 f" [MANUAL] {item['filename']} — "
1739 f"{item['last_error']}"
1740 )
1741 save_search_manifest(manifest_path, manifest)
1742 finally:
1743 # Workers only stage files. Any result not committed because of an
1744 # interrupt or a later manifest error must not leave candidate residue.
1745 for future in futures:
1746 if not future.done():
1747 continue
1748 try:
1749 outcome = future.result()
1750 except BaseException:
1751 continue
1752 staged_path = outcome[4]
1753 if staged_path is not None:
1754 try:
1755 staged_path.unlink(missing_ok=True)
1756 except OSError:
1757 pass
1758
1759 print(
1760 f"\n[Batch] Done: {sourced_count} sourced / {failed_count} failed / "
1761 f"{needs_manual_count} needs-manual ({skipped} pre-skipped). "
1762 f"Manifest: {manifest_path}"
1763 )
1764 return sourced_count, needs_manual_count, failed_count, skipped
1765
1766
1767 # ---------------------------------------------------------------------------
1768 # CLI
1769 # ---------------------------------------------------------------------------
1770
1771
1772 def build_parser() -> argparse.ArgumentParser:
1773 parser = argparse.ArgumentParser(
1774 description=(
1775 "Search openly-licensed web images and download a single best match. "
1776 "Sister to image_gen.py."
1777 ),
1778 formatter_class=argparse.RawDescriptionHelpFormatter,
1779 )
1780 parser.add_argument(
1781 "query",
1782 nargs="?",
1783 default=None,
1784 help="Search query (1-4 concrete keywords work best). Omit in --batch mode.",
1785 )
1786 parser.add_argument(
1787 "--filename",
1788 default=None,
1789 help=(
1790 "Local filename for the chosen image (e.g. cover_bg.jpg). "
1791 "Required for single-query, --promote, and --from-url modes; "
1792 "ignored in --batch."
1793 ),
1794 )
1795 parser.add_argument(
1796 "-o",
1797 "--output",
1798 default=".",
1799 help="Output directory. Manifest defaults to <output>/image_sources.json.",
1800 )
1801 parser.add_argument(
1802 "--provider",
1803 choices=ALL_PROVIDERS,
1804 default=None,
1805 help=(
1806 "Pin one provider. Default: try zero-config providers (openverse, "
1807 "wikimedia) plus any keyed provider whose API key is set."
1808 ),
1809 )
1810 parser.add_argument(
1811 "--orientation",
1812 choices=ORIENTATION_CHOICES,
1813 default="any",
1814 help="Preferred orientation.",
1815 )
1816 parser.add_argument(
1817 "--purpose",
1818 default="",
1819 help="Purpose tag stored in the manifest (e.g. background, hero, side).",
1820 )
1821 parser.add_argument(
1822 "--slide",
1823 default="",
1824 help="Slide identifier the image belongs to (e.g. 01_cover).",
1825 )
1826 parser.add_argument(
1827 "--strict-no-attribution",
1828 action="store_true",
1829 help=(
1830 "Refuse CC BY / CC BY-SA results. If no attribution-free match is "
1831 "downloadable, exit non-zero."
1832 ),
1833 )
1834 parser.add_argument(
1835 "--min-width",
1836 type=int,
1837 default=1200,
1838 help="Minimum acceptable image width in pixels (default: 1200).",
1839 )
1840 parser.add_argument(
1841 "--min-height",
1842 type=int,
1843 default=800,
1844 help="Minimum acceptable image height in pixels (default: 800).",
1845 )
1846 parser.add_argument(
1847 "--require-terms",
1848 action="append",
1849 default=None,
1850 metavar="TERM[,TERM...]",
1851 help=(
1852 "Entity-safety gate: require each metadata term group before a "
1853 "candidate can be accepted. Repeatable; comma separates groups; "
1854 "'A|B' means aliases within one group. Example: "
1855 "--require-terms Chongqing --require-terms 'Jiefangbei|Liberation Monument'."
1856 ),
1857 )
1858 parser.add_argument(
1859 "--manifest",
1860 default=None,
1861 help="Override manifest path. Defaults to <output>/image_sources.json.",
1862 )
1863 parser.add_argument(
1864 "--batch",
1865 default=None,
1866 metavar="QUERIES_JSON",
1867 help=(
1868 "Process a batch of search requests from an image_queries.json "
1869 "manifest concurrently, writing provenance into image_sources.json "
1870 "and status back into the queries manifest."
1871 ),
1872 )
1873 parser.add_argument(
1874 "--concurrency",
1875 type=int,
1876 default=None,
1877 help=(
1878 "Max concurrent searches in --batch mode. Defaults to "
1879 f"IMAGE_SEARCH_CONCURRENCY env or {DEFAULT_SEARCH_CONCURRENCY}. "
1880 "Keep modest — free providers are rate-sensitive; use 1 for "
1881 "strict one-at-a-time pacing."
1882 ),
1883 )
1884 parser.add_argument(
1885 "--save-candidates",
1886 action="store_true",
1887 help=(
1888 "Opt-in: also save a small candidate pool to candidates/<stem>/ "
1889 "(with downscaled review copies) so a better fit can be promoted "
1890 "when the best match fails visual confirmation. Default: only the "
1891 "best match is downloaded."
1892 ),
1893 )
1894 parser.add_argument(
1895 "--max-candidates",
1896 type=int,
1897 default=4,
1898 help="Max candidates to save when --save-candidates is set (default: 4).",
1899 )
1900 parser.add_argument(
1901 "--promote",
1902 default=None,
1903 metavar="CANDIDATE_FILE",
1904 help=(
1905 "Promote a candidate to replace the primary image. "
1906 "Example: --promote candidate_03.jpg --filename 05_wulong.jpg -o images/"
1907 ),
1908 )
1909 parser.add_argument(
1910 "--from-url",
1911 default=None,
1912 metavar="URL",
1913 help=(
1914 "Manual replacement: download a user-supplied image URL into "
1915 "--filename and record it (license marked 'manual'). Works without "
1916 "a multimodal model. Example: --from-url https://… --filename team.jpg -o images/"
1917 ),
1918 )
1919 return parser
1920
1921
1922 def _default_provider_chain() -> list[str]:
1923 """Keyed high-quality providers first; zero-config providers as fallback.
1924 This is the search order when ``--provider`` is unset."""
1925 chain: list[str] = []
1926 if os.environ.get("PEXELS_API_KEY"):
1927 chain.append("pexels")
1928 if os.environ.get("PIXABAY_API_KEY"):
1929 chain.append("pixabay")
1930 chain.extend(ZERO_CONFIG_PROVIDERS)
1931 return chain
1932
1933
1934 def main(argv: Optional[list[str]] = None) -> int:
1935 _load_search_env_file()
1936
1937 parser = build_parser()
1938 args = parser.parse_args(argv)
1939
1940 try:
1941 if args.filename:
1942 args.filename = _validate_bare_filename(args.filename)
1943 if args.promote:
1944 args.promote = _validate_bare_filename(
1945 args.promote, field_name="--promote candidate filename"
1946 )
1947 except ValueError as exc:
1948 parser.error(str(exc))
1949 if args.min_width < 1 or args.min_height < 1:
1950 parser.error("--min-width and --min-height must both be positive integers")
1951
1952 output_dir = Path(args.output)
1953
1954 # --- Promote mode ---
1955 if args.promote:
1956 if not args.filename:
1957 parser.error("--filename is required in --promote mode")
1958 return promote_candidate(
1959 output_dir,
1960 args.filename,
1961 args.promote,
1962 manifest_path=Path(args.manifest) if args.manifest else None,
1963 )
1964
1965 # --- Manual URL replacement ---
1966 if args.from_url:
1967 if not args.filename:
1968 parser.error("--filename is required with --from-url")
1969 return fetch_url_replace(
1970 output_dir,
1971 args.filename,
1972 args.from_url,
1973 manifest_path=Path(args.manifest) if args.manifest else None,
1974 slide=args.slide,
1975 purpose=args.purpose,
1976 search_query=args.query or "",
1977 orientation="" if args.orientation == "any" else args.orientation,
1978 required_terms=_parse_required_terms(args.require_terms),
1979 min_width=args.min_width,
1980 min_height=args.min_height,
1981 )
1982
1983 # --- Batch mode ---
1984 if args.batch:
1985 if not os.path.isfile(args.batch):
1986 print(f"Error: queries manifest not found: {args.batch}", file=sys.stderr)
1987 return 1
1988 try:
1989 manifest = load_search_manifest(args.batch)
1990 except ValueError as exc:
1991 print(f"Error: {exc}", file=sys.stderr)
1992 return 1
1993 batch_output_dir = (
1994 output_dir if args.output != "." else Path(args.batch).parent
1995 )
1996 batch_output_dir.mkdir(parents=True, exist_ok=True)
1997 sources_manifest_path = (
1998 Path(args.manifest) if args.manifest
1999 else default_manifest_path(str(batch_output_dir))
2000 )
2001 try:
2002 _, needs_manual, failed, _ = run_search_manifest(
2003 manifest,
2004 args.batch,
2005 output_dir=batch_output_dir,
2006 sources_manifest_path=sources_manifest_path,
2007 concurrency=_resolve_search_concurrency(args.concurrency),
2008 save_candidates=args.save_candidates,
2009 max_candidates=args.max_candidates,
2010 default_provider=args.provider,
2011 default_strict=args.strict_no_attribution,
2012 default_min_width=args.min_width,
2013 default_min_height=args.min_height,
2014 )
2015 except KeyboardInterrupt:
2016 print("\n\nInterrupted by user. Partial progress preserved in manifest.")
2017 return 130
2018 except RuntimeError as exc:
2019 print(f"Error: {exc}", file=sys.stderr)
2020 return 1
2021 # Mirror image_gen.py: any unresolved retryable or manual row keeps the
2022 # command non-zero. It is a gate signal, not a request to hide progress.
2023 return 1 if needs_manual or failed else 0
2024
2025 # --- Single-query search mode ---
2026 if not args.query:
2027 parser.error("query is required unless --batch, --promote, or --from-url is used")
2028 if not args.filename:
2029 parser.error("--filename is required in single-query mode")
2030
2031 request = ImageSearchRequest(
2032 query=args.query,
2033 purpose=args.purpose,
2034 orientation="" if args.orientation == "any" else args.orientation,
2035 filename=args.filename,
2036 slide=args.slide,
2037 min_width=args.min_width,
2038 min_height=args.min_height,
2039 required_terms=_parse_required_terms(args.require_terms),
2040 )
2041 _warn_weak_required_terms(request.required_terms)
2042
2043 providers = [args.provider] if args.provider else _default_provider_chain()
2044
2045 manifest_path = (
2046 Path(args.manifest) if args.manifest else default_manifest_path(args.output)
2047 )
2048 try:
2049 _read_existing_manifest(manifest_path)
2050 except RuntimeError as exc:
2051 print(f"Error: {exc}", file=sys.stderr)
2052 return 1
2053
2054 output_dir.mkdir(parents=True, exist_ok=True)
2055 output_path = output_dir / args.filename
2056
2057 print(f"Searching providers: {', '.join(providers)}", file=sys.stderr)
2058 result = search_and_download(
2059 providers,
2060 request,
2061 output_path=output_path,
2062 strict_no_attribution=args.strict_no_attribution,
2063 save_candidates=args.save_candidates,
2064 max_candidates=args.max_candidates,
2065 provider_is_explicit=bool(args.provider),
2066 )
2067
2068 if result.candidate is None:
2069 print(
2070 f"{result.error or 'Image search failed'}. "
2071 "Try a shorter query, use default attribution mode if strict mode "
2072 "is enabled, or set an API key for a keyed provider.",
2073 file=sys.stderr,
2074 )
2075 return 1
2076
2077 print(
2078 f" picked: {result.candidate.title!r} from {result.provider_name} "
2079 f"({result.candidate.license_name or 'no license string'}, "
2080 f"{result.candidate.license_tier})",
2081 file=sys.stderr,
2082 )
2083
2084 # The staged file has already been measured; upstream metadata can still be
2085 # off (e.g. Openverse aggregates rawpixel which only exposes previews).
2086 actual_dimensions = result.actual_dimensions
2087 if (
2088 actual_dimensions is not None
2089 and result.candidate.width
2090 and result.candidate.height
2091 and actual_dimensions[0] * actual_dimensions[1]
2092 < 0.5 * result.candidate.width * result.candidate.height
2093 ):
2094 print(
2095 f"\n[!] Downloaded image is much smaller than upstream metadata "
2096 f"({actual_dimensions[0]}x{actual_dimensions[1]} vs "
2097 f"{result.candidate.width}x{result.candidate.height}). The provider "
2098 f"likely only exposes a preview here. Layout based on the manifest's "
2099 f"width/height will be accurate; the metadata_dimensions field "
2100 f"is preserved for reference.",
2101 file=sys.stderr,
2102 )
2103
2104 if result.staged_path is None or result.output_path is None:
2105 print("Error: image search returned no staged output.", file=sys.stderr)
2106 if result.staged_path is not None:
2107 try:
2108 result.staged_path.unlink(missing_ok=True)
2109 except OSError:
2110 pass
2111 return 1
2112 try:
2113 item = _candidate_to_manifest_item(
2114 result.candidate,
2115 args,
2116 provider_name=result.provider_name or "",
2117 stage=result.stage or "",
2118 actual_dimensions=actual_dimensions,
2119 )
2120 written = _commit_staged_image(
2121 result.staged_path,
2122 result.output_path,
2123 lambda: write_sources_manifest(manifest_path, item),
2124 )
2125 except (OSError, RuntimeError, ValueError) as exc:
2126 print(f"Error: {exc}", file=sys.stderr)
2127 return 1
2128 finally:
2129 try:
2130 result.staged_path.unlink(missing_ok=True)
2131 except OSError:
2132 pass
2133 print(f" manifest: {written}", file=sys.stderr)
2134 review = _write_review_copy(
2135 result.output_path,
2136 result.output_path.parent / ".review",
2137 result.output_path.name,
2138 )
2139 if review is not None:
2140 print(f" review copy: {review}", file=sys.stderr)
2141
2142 if result.candidate.license_tier == "attribution-required":
2143 print(
2144 "\n[!] This image requires on-slide attribution. "
2145 "Executor should add a small credit element to the slide using "
2146 "the 'attribution_text' field in the manifest.",
2147 file=sys.stderr,
2148 )
2149
2150 return 0
2151
2152
2153 if __name__ == "__main__":
2154 raise SystemExit(main())
2155
2155 lines PYTHON