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