| 1 | """Pexels provider. |
| 2 | |
| 3 | Requires ``PEXELS_API_KEY`` in the environment. Pexels's site-wide license |
| 4 | allows commercial use without attribution, so all returned candidates are |
| 5 | classified as ``no-attribution``. |
| 6 | |
| 7 | API docs: https://www.pexels.com/api/documentation/ |
| 8 | """ |
| 9 | |
| 10 | from __future__ import annotations |
| 11 | |
| 12 | import sys |
| 13 | from pathlib import Path |
| 14 | |
| 15 | _SCRIPTS_DIR = Path(__file__).resolve().parents[1] |
| 16 | if str(_SCRIPTS_DIR) not in sys.path: |
| 17 | sys.path.insert(0, str(_SCRIPTS_DIR)) |
| 18 | |
| 19 | from console_encoding import configure_utf8_stdio # noqa: E402 |
| 20 | |
| 21 | configure_utf8_stdio() |
| 22 | |
| 23 | if __name__ == "__main__": |
| 24 | print(__doc__) |
| 25 | print("Use via: python3 skills/ppt-master/scripts/image_search.py ...") |
| 26 | raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1) |
| 27 | |
| 28 | import os |
| 29 | |
| 30 | import requests |
| 31 | |
| 32 | from image_sources.provider_common import ( |
| 33 | AssetCandidate, |
| 34 | ImageSearchRequest, |
| 35 | LICENSE_TIER_NO_ATTRIBUTION, |
| 36 | USER_AGENT, |
| 37 | build_query_progression, |
| 38 | normalize_license_name, |
| 39 | ) |
| 40 | |
| 41 | |
| 42 | API_URL = "https://api.pexels.com/v1/search" |
| 43 | DEFAULT_PAGE_SIZE = 20 |
| 44 | DEFAULT_TIMEOUT = 30 |
| 45 | |
| 46 | _ORIENTATION_MAP = { |
| 47 | "landscape": "landscape", |
| 48 | "portrait": "portrait", |
| 49 | "square": "square", |
| 50 | } |
| 51 | |
| 52 | |
| 53 | def _require_api_key() -> str: |
| 54 | key = (os.environ.get("PEXELS_API_KEY") or "").strip() |
| 55 | if not key: |
| 56 | raise RuntimeError( |
| 57 | "PEXELS_API_KEY is not set. Add it to your environment or .env file. " |
| 58 | "Get one at https://www.pexels.com/api/" |
| 59 | ) |
| 60 | return key |
| 61 | |
| 62 | |
| 63 | def parse_results(payload: dict) -> list[AssetCandidate]: |
| 64 | """Translate a Pexels response into candidates.""" |
| 65 | candidates: list[AssetCandidate] = [] |
| 66 | for item in payload.get("photos", []) or []: |
| 67 | src = item.get("src") or {} |
| 68 | download_url = (src.get("original") or src.get("large2x") or src.get("large") or "").strip() |
| 69 | if not download_url: |
| 70 | continue |
| 71 | |
| 72 | candidates.append( |
| 73 | AssetCandidate( |
| 74 | provider="pexels", |
| 75 | title=(item.get("alt") or "").strip() or "Pexels photo", |
| 76 | asset_id=str(item.get("id") or ""), |
| 77 | source_page_url=(item.get("url") or "").strip(), |
| 78 | license_name=normalize_license_name("Pexels License"), |
| 79 | license_url="https://www.pexels.com/license/", |
| 80 | license_tier=LICENSE_TIER_NO_ATTRIBUTION, |
| 81 | width=int(item.get("width") or 0), |
| 82 | height=int(item.get("height") or 0), |
| 83 | download_url=download_url, |
| 84 | author=(item.get("photographer") or "").strip(), |
| 85 | raw=item, |
| 86 | ) |
| 87 | ) |
| 88 | return candidates |
| 89 | |
| 90 | |
| 91 | def search( |
| 92 | request: ImageSearchRequest, |
| 93 | *, |
| 94 | license_tier_filter: str = "no-attribution-only", |
| 95 | page_size: int = DEFAULT_PAGE_SIZE, |
| 96 | timeout: int = DEFAULT_TIMEOUT, |
| 97 | ) -> list[AssetCandidate]: |
| 98 | """Search Pexels for candidates. |
| 99 | |
| 100 | Pexels images are uniformly ``no-attribution``, so the ``"all"`` filter |
| 101 | behaves identically to ``"no-attribution-only"``. Both are accepted to |
| 102 | keep the dispatcher simple. |
| 103 | """ |
| 104 | if license_tier_filter not in {"no-attribution-only", "all"}: |
| 105 | raise ValueError(f"unsupported license_tier_filter: {license_tier_filter!r}") |
| 106 | |
| 107 | api_key = _require_api_key() |
| 108 | orientation = (request.orientation or "").strip().lower() |
| 109 | |
| 110 | for query in build_query_progression(request.query): |
| 111 | params: dict[str, str | int] = { |
| 112 | "query": query, |
| 113 | "per_page": page_size, |
| 114 | "size": "large", |
| 115 | } |
| 116 | if orientation in _ORIENTATION_MAP: |
| 117 | params["orientation"] = _ORIENTATION_MAP[orientation] |
| 118 | |
| 119 | response = requests.get( |
| 120 | API_URL, |
| 121 | params=params, |
| 122 | headers={ |
| 123 | "Authorization": api_key, |
| 124 | "User-Agent": USER_AGENT, |
| 125 | "Accept": "application/json", |
| 126 | }, |
| 127 | timeout=timeout, |
| 128 | ) |
| 129 | response.raise_for_status() |
| 130 | candidates = parse_results(response.json()) |
| 131 | if candidates: |
| 132 | return candidates |
| 133 | |
| 134 | return [] |
| 135 |