cli-web-pexels 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli_web/pexels/README.md +83 -0
- cli_web/pexels/__init__.py +3 -0
- cli_web/pexels/__main__.py +6 -0
- cli_web/pexels/commands/__init__.py +0 -0
- cli_web/pexels/commands/collections.py +63 -0
- cli_web/pexels/commands/photos.py +116 -0
- cli_web/pexels/commands/users.py +62 -0
- cli_web/pexels/commands/videos.py +162 -0
- cli_web/pexels/core/__init__.py +0 -0
- cli_web/pexels/core/client.py +299 -0
- cli_web/pexels/core/exceptions.py +54 -0
- cli_web/pexels/core/models.py +213 -0
- cli_web/pexels/pexels_cli.py +139 -0
- cli_web/pexels/skills/SKILL.md +105 -0
- cli_web/pexels/tests/TEST.md +130 -0
- cli_web/pexels/tests/__init__.py +0 -0
- cli_web/pexels/tests/test_core.py +326 -0
- cli_web/pexels/tests/test_e2e.py +168 -0
- cli_web/pexels/utils/__init__.py +0 -0
- cli_web/pexels/utils/doctor.py +188 -0
- cli_web/pexels/utils/helpers.py +42 -0
- cli_web/pexels/utils/mcp_server.py +290 -0
- cli_web/pexels/utils/output.py +139 -0
- cli_web/pexels/utils/repl_skin.py +486 -0
- cli_web_pexels-0.1.0.dist-info/METADATA +11 -0
- cli_web_pexels-0.1.0.dist-info/RECORD +29 -0
- cli_web_pexels-0.1.0.dist-info/WHEEL +5 -0
- cli_web_pexels-0.1.0.dist-info/entry_points.txt +2 -0
- cli_web_pexels-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""HTTP client for Pexels — fetches SSR pages and parses __NEXT_DATA__.
|
|
2
|
+
|
|
3
|
+
Uses curl_cffi to bypass Cloudflare protection on pexels.com.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from curl_cffi import requests as curl_requests
|
|
11
|
+
from curl_cffi.requests.exceptions import RequestException
|
|
12
|
+
|
|
13
|
+
from .exceptions import (
|
|
14
|
+
NetworkError,
|
|
15
|
+
NotFoundError,
|
|
16
|
+
ParseError,
|
|
17
|
+
PexelsError,
|
|
18
|
+
RateLimitError,
|
|
19
|
+
ServerError,
|
|
20
|
+
)
|
|
21
|
+
from .models import (
|
|
22
|
+
normalize_collection,
|
|
23
|
+
normalize_collection_summary,
|
|
24
|
+
normalize_media_item,
|
|
25
|
+
normalize_photo,
|
|
26
|
+
normalize_photo_detail,
|
|
27
|
+
normalize_user,
|
|
28
|
+
normalize_video,
|
|
29
|
+
normalize_video_detail,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
BASE_URL = "https://www.pexels.com"
|
|
33
|
+
SUGGESTIONS_URL = f"{BASE_URL}/en-us/api/v3/search/suggestions"
|
|
34
|
+
|
|
35
|
+
_NEXT_DATA_RE = re.compile(r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', re.DOTALL)
|
|
36
|
+
|
|
37
|
+
_HEADERS = {
|
|
38
|
+
"User-Agent": (
|
|
39
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
40
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
41
|
+
"Chrome/120.0.0.0 Safari/537.36"
|
|
42
|
+
),
|
|
43
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
44
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PexelsClient:
|
|
49
|
+
"""Client for fetching data from Pexels via SSR page parsing."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, timeout: float = 30.0):
|
|
52
|
+
self.timeout = timeout
|
|
53
|
+
|
|
54
|
+
def __enter__(self) -> "PexelsClient":
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
def __exit__(self, *exc) -> None:
|
|
58
|
+
pass # No persistent connection — curl_cffi calls are scoped per request.
|
|
59
|
+
|
|
60
|
+
def _request(self, url: str, params: dict | None = None) -> curl_requests.Response:
|
|
61
|
+
"""Make an HTTP GET request with Cloudflare bypass."""
|
|
62
|
+
try:
|
|
63
|
+
resp = curl_requests.get(
|
|
64
|
+
url,
|
|
65
|
+
params=params,
|
|
66
|
+
headers=_HEADERS,
|
|
67
|
+
impersonate="chrome",
|
|
68
|
+
timeout=self.timeout,
|
|
69
|
+
allow_redirects=True,
|
|
70
|
+
)
|
|
71
|
+
except RequestException as e:
|
|
72
|
+
raise NetworkError(f"Request failed: {e}") from e
|
|
73
|
+
except Exception as e:
|
|
74
|
+
if "timeout" in str(e).lower():
|
|
75
|
+
raise NetworkError(f"Request timed out: {url}") from e
|
|
76
|
+
raise NetworkError(f"Connection failed: {e}") from e
|
|
77
|
+
|
|
78
|
+
self._check_status(resp, url)
|
|
79
|
+
return resp
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _check_status(resp, url: str) -> None:
|
|
83
|
+
"""Check HTTP status and raise typed exceptions."""
|
|
84
|
+
code = resp.status_code
|
|
85
|
+
if code < 400:
|
|
86
|
+
return
|
|
87
|
+
text = resp.text[:200]
|
|
88
|
+
msg = f"HTTP {code}: {text}"
|
|
89
|
+
if code == 404:
|
|
90
|
+
raise NotFoundError(msg)
|
|
91
|
+
if code == 429:
|
|
92
|
+
retry_after = resp.headers.get("Retry-After")
|
|
93
|
+
raise RateLimitError(msg, retry_after=float(retry_after) if retry_after else None)
|
|
94
|
+
if 500 <= code < 600:
|
|
95
|
+
raise ServerError(msg, status_code=code)
|
|
96
|
+
raise PexelsError(msg)
|
|
97
|
+
|
|
98
|
+
def _get_page(self, path: str, params: dict | None = None) -> dict:
|
|
99
|
+
"""Fetch an SSR page and extract __NEXT_DATA__ JSON."""
|
|
100
|
+
url = f"{BASE_URL}{path}"
|
|
101
|
+
filtered = {k: v for k, v in (params or {}).items() if v is not None}
|
|
102
|
+
resp = self._request(url, params=filtered if filtered else None)
|
|
103
|
+
|
|
104
|
+
match = _NEXT_DATA_RE.search(resp.text)
|
|
105
|
+
if not match:
|
|
106
|
+
raise ParseError(f"No __NEXT_DATA__ found at {url}")
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
data = json.loads(match.group(1))
|
|
110
|
+
except json.JSONDecodeError as e:
|
|
111
|
+
raise ParseError(f"Invalid __NEXT_DATA__ JSON: {e}") from e
|
|
112
|
+
|
|
113
|
+
return data.get("props", {}).get("pageProps", {})
|
|
114
|
+
|
|
115
|
+
def _get_json(self, url: str, params: dict | None = None) -> Any:
|
|
116
|
+
"""Fetch a JSON API endpoint."""
|
|
117
|
+
resp = self._request(url, params=params)
|
|
118
|
+
return resp.json()
|
|
119
|
+
|
|
120
|
+
# ── Photos ─────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
def search_photos(
|
|
123
|
+
self,
|
|
124
|
+
query: str,
|
|
125
|
+
page: int = 1,
|
|
126
|
+
orientation: str | None = None,
|
|
127
|
+
size: str | None = None,
|
|
128
|
+
color: str | None = None,
|
|
129
|
+
) -> dict:
|
|
130
|
+
"""Search photos. Returns {data, pagination}."""
|
|
131
|
+
params = {
|
|
132
|
+
"page": page if page > 1 else None,
|
|
133
|
+
"orientation": orientation,
|
|
134
|
+
"size": size,
|
|
135
|
+
"color": color,
|
|
136
|
+
}
|
|
137
|
+
props = self._get_page(f"/search/{query}/", params)
|
|
138
|
+
initial = props.get("initialData", {})
|
|
139
|
+
return {
|
|
140
|
+
"data": [normalize_photo(p) for p in (initial.get("data") or [])],
|
|
141
|
+
"pagination": initial.get("pagination", {}),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
def get_photo(self, slug: str) -> dict:
|
|
145
|
+
"""Get photo detail by slug (e.g., 'green-leaves-1072179')."""
|
|
146
|
+
if slug.isdigit():
|
|
147
|
+
slug = f"photo-{slug}"
|
|
148
|
+
props = self._get_page(f"/photo/{slug}/")
|
|
149
|
+
medium = props.get("medium", {})
|
|
150
|
+
if not medium:
|
|
151
|
+
raise NotFoundError(f"Photo not found: {slug}")
|
|
152
|
+
details = props.get("mediumDetails", {})
|
|
153
|
+
return normalize_photo_detail(medium, details)
|
|
154
|
+
|
|
155
|
+
# ── Videos ─────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
def search_videos(
|
|
158
|
+
self,
|
|
159
|
+
query: str,
|
|
160
|
+
page: int = 1,
|
|
161
|
+
orientation: str | None = None,
|
|
162
|
+
) -> dict:
|
|
163
|
+
"""Search videos. Returns {data, pagination}."""
|
|
164
|
+
params = {
|
|
165
|
+
"page": page if page > 1 else None,
|
|
166
|
+
"orientation": orientation,
|
|
167
|
+
}
|
|
168
|
+
props = self._get_page(f"/search/videos/{query}/", params)
|
|
169
|
+
initial = props.get("initialData", {})
|
|
170
|
+
return {
|
|
171
|
+
"data": [normalize_video(v) for v in (initial.get("data") or [])],
|
|
172
|
+
"pagination": initial.get("pagination", {}),
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
def get_video(self, slug: str) -> dict:
|
|
176
|
+
"""Get video detail by slug."""
|
|
177
|
+
if slug.isdigit():
|
|
178
|
+
slug = f"video-{slug}"
|
|
179
|
+
props = self._get_page(f"/video/{slug}/")
|
|
180
|
+
medium = props.get("medium", {})
|
|
181
|
+
if not medium:
|
|
182
|
+
raise NotFoundError(f"Video not found: {slug}")
|
|
183
|
+
return normalize_video_detail(medium)
|
|
184
|
+
|
|
185
|
+
# ── Users ──────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
def get_user(self, username: str) -> dict:
|
|
188
|
+
"""Get user profile by username."""
|
|
189
|
+
props = self._get_page(f"/@{username}/")
|
|
190
|
+
user = props.get("user", {})
|
|
191
|
+
if not user:
|
|
192
|
+
raise NotFoundError(f"User not found: {username}")
|
|
193
|
+
media_page = props.get("firstPageOfMedia", {})
|
|
194
|
+
return {
|
|
195
|
+
"user": normalize_user(user),
|
|
196
|
+
"media": {
|
|
197
|
+
"data": [normalize_media_item(m) for m in (media_page.get("data") or [])],
|
|
198
|
+
"pagination": media_page.get("pagination", {}),
|
|
199
|
+
},
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
def get_user_media(self, username: str, page: int = 1) -> dict:
|
|
203
|
+
"""Get paginated user media."""
|
|
204
|
+
params = {"page": page if page > 1 else None}
|
|
205
|
+
props = self._get_page(f"/@{username}/", params)
|
|
206
|
+
media_page = props.get("firstPageOfMedia") or props.get("initialData") or {}
|
|
207
|
+
return {
|
|
208
|
+
"data": [normalize_media_item(m) for m in (media_page.get("data") or [])],
|
|
209
|
+
"pagination": media_page.get("pagination", {}),
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
# ── Collections ────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
def get_collection(self, slug: str, page: int = 1) -> dict:
|
|
215
|
+
"""Get collection detail + media."""
|
|
216
|
+
params = {"page": page if page > 1 else None}
|
|
217
|
+
props = self._get_page(f"/collections/{slug}/", params)
|
|
218
|
+
collection = props.get("collection", {})
|
|
219
|
+
if not collection:
|
|
220
|
+
raise NotFoundError(f"Collection not found: {slug}")
|
|
221
|
+
initial = props.get("initialData", {})
|
|
222
|
+
return {
|
|
223
|
+
"collection": normalize_collection(collection),
|
|
224
|
+
"media": {
|
|
225
|
+
"data": [normalize_media_item(m) for m in (initial.get("data") or [])],
|
|
226
|
+
"pagination": initial.get("pagination", {}),
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
def discover(self) -> dict:
|
|
231
|
+
"""Get discover page data (popular collections, challenges)."""
|
|
232
|
+
props = self._get_page("/discover/")
|
|
233
|
+
initial = props.get("initialData", {})
|
|
234
|
+
return {
|
|
235
|
+
"popular": [normalize_collection_summary(c) for c in (initial.get("popular") or [])],
|
|
236
|
+
"collections": self._flatten_collection_groups(initial.get("collections") or []),
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
# ── Suggestions ────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
def search_suggestions(self, query: str) -> list[str]:
|
|
242
|
+
"""Get search autocomplete suggestions."""
|
|
243
|
+
data = self._get_json(f"{SUGGESTIONS_URL}/{query}?")
|
|
244
|
+
attrs = data.get("data", {}).get("attributes", {})
|
|
245
|
+
return attrs.get("suggestions", [])
|
|
246
|
+
|
|
247
|
+
# ── Download helpers ───────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
def download_file(self, url: str, output_path: str) -> str:
|
|
250
|
+
"""Download a file (photo or video) to disk."""
|
|
251
|
+
try:
|
|
252
|
+
resp = curl_requests.get(
|
|
253
|
+
url,
|
|
254
|
+
headers=_HEADERS,
|
|
255
|
+
impersonate="chrome",
|
|
256
|
+
timeout=120.0,
|
|
257
|
+
allow_redirects=True,
|
|
258
|
+
)
|
|
259
|
+
except Exception as e:
|
|
260
|
+
raise NetworkError(f"Download failed: {e}") from e
|
|
261
|
+
|
|
262
|
+
self._check_status(resp, url)
|
|
263
|
+
|
|
264
|
+
with open(output_path, "wb") as f:
|
|
265
|
+
f.write(resp.content)
|
|
266
|
+
|
|
267
|
+
return output_path
|
|
268
|
+
|
|
269
|
+
@staticmethod
|
|
270
|
+
def _flatten_collection_groups(groups: list) -> list[dict]:
|
|
271
|
+
"""Flatten nested collection groups from discover page."""
|
|
272
|
+
result = []
|
|
273
|
+
for group in groups:
|
|
274
|
+
if isinstance(group, list):
|
|
275
|
+
for item in group:
|
|
276
|
+
attrs = item.get("attributes", {})
|
|
277
|
+
result.append(
|
|
278
|
+
{
|
|
279
|
+
"id": attrs.get("id"),
|
|
280
|
+
"title": attrs.get("title"),
|
|
281
|
+
"slug": attrs.get("slug"),
|
|
282
|
+
"media_count": attrs.get("collection_media_count"),
|
|
283
|
+
"photos_count": attrs.get("photos_count"),
|
|
284
|
+
"videos_count": attrs.get("videos_count"),
|
|
285
|
+
}
|
|
286
|
+
)
|
|
287
|
+
elif isinstance(group, dict):
|
|
288
|
+
attrs = group.get("attributes", {})
|
|
289
|
+
result.append(
|
|
290
|
+
{
|
|
291
|
+
"id": attrs.get("id"),
|
|
292
|
+
"title": attrs.get("title"),
|
|
293
|
+
"slug": attrs.get("slug"),
|
|
294
|
+
"media_count": attrs.get("collection_media_count"),
|
|
295
|
+
"photos_count": attrs.get("photos_count"),
|
|
296
|
+
"videos_count": attrs.get("videos_count"),
|
|
297
|
+
}
|
|
298
|
+
)
|
|
299
|
+
return result
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Domain-specific exception hierarchy for cli-web-pexels."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PexelsError(Exception):
|
|
5
|
+
"""Base exception for all Pexels CLI errors."""
|
|
6
|
+
|
|
7
|
+
def to_dict(self) -> dict:
|
|
8
|
+
"""Return a JSON-serializable error dictionary."""
|
|
9
|
+
return {"error": True, "code": error_code_for(self), "message": str(self)}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RateLimitError(PexelsError):
|
|
13
|
+
"""Server returned 429 — too many requests."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, message: str, retry_after: float | None = None):
|
|
16
|
+
self.retry_after = retry_after
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NetworkError(PexelsError):
|
|
21
|
+
"""Connection failed — DNS, TCP, TLS, or timeout."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ServerError(PexelsError):
|
|
25
|
+
"""Server returned 5xx."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, message: str, status_code: int = 500):
|
|
28
|
+
self.status_code = status_code
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NotFoundError(PexelsError):
|
|
33
|
+
"""Resource not found (HTTP 404)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ParseError(PexelsError):
|
|
37
|
+
"""Failed to parse __NEXT_DATA__ or response HTML."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
EXCEPTION_CODE_MAP = {
|
|
41
|
+
RateLimitError: "RATE_LIMITED",
|
|
42
|
+
NotFoundError: "NOT_FOUND",
|
|
43
|
+
ServerError: "SERVER_ERROR",
|
|
44
|
+
NetworkError: "NETWORK_ERROR",
|
|
45
|
+
ParseError: "PARSE_ERROR",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def error_code_for(exc: Exception) -> str:
|
|
50
|
+
"""Get the JSON error code string for an exception."""
|
|
51
|
+
for exc_type, code in EXCEPTION_CODE_MAP.items():
|
|
52
|
+
if isinstance(exc, exc_type):
|
|
53
|
+
return code
|
|
54
|
+
return "UNKNOWN_ERROR"
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Data models for cli-web-pexels.
|
|
2
|
+
|
|
3
|
+
Normalizer functions that transform raw Pexels __NEXT_DATA__ structures
|
|
4
|
+
into clean, flat dictionaries for CLI output and --json serialization.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def normalize_photo(item: dict) -> dict:
|
|
11
|
+
"""Normalize a photo item from search results."""
|
|
12
|
+
attrs = item.get("attributes", {})
|
|
13
|
+
user = attrs.get("user", {})
|
|
14
|
+
image = attrs.get("image", {})
|
|
15
|
+
return {
|
|
16
|
+
"id": attrs.get("id"),
|
|
17
|
+
"type": "photo",
|
|
18
|
+
"slug": attrs.get("slug"),
|
|
19
|
+
"title": attrs.get("title"),
|
|
20
|
+
"description": attrs.get("description"),
|
|
21
|
+
"width": attrs.get("width"),
|
|
22
|
+
"height": attrs.get("height"),
|
|
23
|
+
"license": attrs.get("license"),
|
|
24
|
+
"photographer": _format_name(user),
|
|
25
|
+
"photographer_username": user.get("username"),
|
|
26
|
+
"image_url": image.get("large") or image.get("medium"),
|
|
27
|
+
"download_url": image.get("download_link"),
|
|
28
|
+
"tags": [t.get("name") for t in (attrs.get("tags") or [])[:5]],
|
|
29
|
+
"colors": attrs.get("colors", []),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def normalize_photo_detail(medium: dict, details: dict) -> dict:
|
|
34
|
+
"""Normalize a photo detail page."""
|
|
35
|
+
attrs = medium.get("attributes", {})
|
|
36
|
+
user = attrs.get("user", {})
|
|
37
|
+
image = attrs.get("image", {})
|
|
38
|
+
det_attrs = details.get("attributes", {})
|
|
39
|
+
return {
|
|
40
|
+
"id": attrs.get("id"),
|
|
41
|
+
"type": "photo",
|
|
42
|
+
"slug": attrs.get("slug"),
|
|
43
|
+
"title": attrs.get("title"),
|
|
44
|
+
"description": attrs.get("description"),
|
|
45
|
+
"alt": attrs.get("alt"),
|
|
46
|
+
"width": attrs.get("width"),
|
|
47
|
+
"height": attrs.get("height"),
|
|
48
|
+
"license": attrs.get("license"),
|
|
49
|
+
"created_at": attrs.get("created_at"),
|
|
50
|
+
"photographer": _format_name(user),
|
|
51
|
+
"photographer_username": user.get("username"),
|
|
52
|
+
"photographer_url": f"https://www.pexels.com/@{user.get('slug', '')}",
|
|
53
|
+
"image": {
|
|
54
|
+
"small": image.get("small"),
|
|
55
|
+
"medium": image.get("medium"),
|
|
56
|
+
"large": image.get("large"),
|
|
57
|
+
"download": image.get("download_link"),
|
|
58
|
+
},
|
|
59
|
+
"tags": [t.get("name") for t in (attrs.get("tags") or [])],
|
|
60
|
+
"colors": attrs.get("colors", []),
|
|
61
|
+
"main_color": attrs.get("main_color"),
|
|
62
|
+
"exif": {
|
|
63
|
+
"camera": det_attrs.get("camera"),
|
|
64
|
+
"aperture": det_attrs.get("aperture"),
|
|
65
|
+
"focal_length": det_attrs.get("focal_length"),
|
|
66
|
+
"iso": det_attrs.get("iso"),
|
|
67
|
+
"shutter_speed": det_attrs.get("shutter_speed"),
|
|
68
|
+
},
|
|
69
|
+
"location": det_attrs.get("location"),
|
|
70
|
+
"file_size": det_attrs.get("size"),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def normalize_video(item: dict) -> dict:
|
|
75
|
+
"""Normalize a video item from search results."""
|
|
76
|
+
attrs = item.get("attributes", {})
|
|
77
|
+
user = attrs.get("user", {})
|
|
78
|
+
video = attrs.get("video", {})
|
|
79
|
+
thumb = video.get("thumbnail", {}) if video else {}
|
|
80
|
+
return {
|
|
81
|
+
"id": attrs.get("id"),
|
|
82
|
+
"type": "video",
|
|
83
|
+
"slug": attrs.get("slug"),
|
|
84
|
+
"title": attrs.get("title"),
|
|
85
|
+
"description": attrs.get("description"),
|
|
86
|
+
"width": attrs.get("width"),
|
|
87
|
+
"height": attrs.get("height"),
|
|
88
|
+
"license": attrs.get("license"),
|
|
89
|
+
"photographer": _format_name(user),
|
|
90
|
+
"photographer_username": user.get("username"),
|
|
91
|
+
"thumbnail_url": thumb.get("medium") or thumb.get("small"),
|
|
92
|
+
"preview_url": video.get("preview_src") if video else None,
|
|
93
|
+
"download_url": video.get("download_link") if video else None,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def normalize_video_detail(medium: dict) -> dict:
|
|
98
|
+
"""Normalize a video detail page."""
|
|
99
|
+
attrs = medium.get("attributes", {})
|
|
100
|
+
user = attrs.get("user", {})
|
|
101
|
+
video = attrs.get("video", {})
|
|
102
|
+
thumb = video.get("thumbnail", {}) if video else {}
|
|
103
|
+
files = video.get("video_files", []) if video else []
|
|
104
|
+
return {
|
|
105
|
+
"id": attrs.get("id"),
|
|
106
|
+
"type": "video",
|
|
107
|
+
"slug": attrs.get("slug"),
|
|
108
|
+
"title": attrs.get("title"),
|
|
109
|
+
"description": attrs.get("description"),
|
|
110
|
+
"width": attrs.get("width"),
|
|
111
|
+
"height": attrs.get("height"),
|
|
112
|
+
"license": attrs.get("license"),
|
|
113
|
+
"created_at": attrs.get("created_at"),
|
|
114
|
+
"photographer": _format_name(user),
|
|
115
|
+
"photographer_username": user.get("username"),
|
|
116
|
+
"photographer_url": f"https://www.pexels.com/@{user.get('slug', '')}",
|
|
117
|
+
"thumbnail": {
|
|
118
|
+
"small": thumb.get("small"),
|
|
119
|
+
"medium": thumb.get("medium"),
|
|
120
|
+
"large": thumb.get("large"),
|
|
121
|
+
},
|
|
122
|
+
"video_src": video.get("src") if video else None,
|
|
123
|
+
"preview_src": video.get("preview_src") if video else None,
|
|
124
|
+
"video_files": [
|
|
125
|
+
{
|
|
126
|
+
"quality": f.get("quality"),
|
|
127
|
+
"width": f.get("width"),
|
|
128
|
+
"height": f.get("height"),
|
|
129
|
+
"fps": f.get("fps"),
|
|
130
|
+
"file_type": f.get("file_type"),
|
|
131
|
+
"link": f.get("link"),
|
|
132
|
+
}
|
|
133
|
+
for f in files
|
|
134
|
+
],
|
|
135
|
+
"tags": [t.get("name") for t in (attrs.get("tags") or [])],
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def normalize_user(user: dict) -> dict:
|
|
140
|
+
"""Normalize a user profile."""
|
|
141
|
+
attrs = user.get("attributes", {})
|
|
142
|
+
avatar = attrs.get("avatar", {})
|
|
143
|
+
return {
|
|
144
|
+
"id": attrs.get("id"),
|
|
145
|
+
"username": attrs.get("username"),
|
|
146
|
+
"first_name": attrs.get("first_name"),
|
|
147
|
+
"last_name": attrs.get("last_name"),
|
|
148
|
+
"location": attrs.get("location"),
|
|
149
|
+
"bio": attrs.get("bio"),
|
|
150
|
+
"avatar": avatar.get("medium") or avatar.get("small"),
|
|
151
|
+
"photos_count": attrs.get("photos_count"),
|
|
152
|
+
"media_count": attrs.get("media_count"),
|
|
153
|
+
"followers_count": attrs.get("followers_count"),
|
|
154
|
+
"hero": attrs.get("hero", False),
|
|
155
|
+
"url": f"https://www.pexels.com/@{attrs.get('slug', '')}",
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def normalize_media_item(item: dict) -> dict:
|
|
160
|
+
"""Normalize a media item (photo or video) from user/collection pages."""
|
|
161
|
+
item_type = item.get("type", "photo")
|
|
162
|
+
attrs = item.get("attributes", {})
|
|
163
|
+
image = attrs.get("image", {})
|
|
164
|
+
video = attrs.get("video", {})
|
|
165
|
+
result = {
|
|
166
|
+
"id": attrs.get("id"),
|
|
167
|
+
"type": item_type,
|
|
168
|
+
"slug": attrs.get("slug"),
|
|
169
|
+
"title": attrs.get("title"),
|
|
170
|
+
"width": attrs.get("width"),
|
|
171
|
+
"height": attrs.get("height"),
|
|
172
|
+
}
|
|
173
|
+
if item_type == "video" and video:
|
|
174
|
+
thumb = video.get("thumbnail", {})
|
|
175
|
+
result["thumbnail_url"] = thumb.get("medium") or thumb.get("small")
|
|
176
|
+
elif image:
|
|
177
|
+
result["image_url"] = image.get("medium") or image.get("small")
|
|
178
|
+
return result
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def normalize_collection(collection: dict) -> dict:
|
|
182
|
+
"""Normalize a collection."""
|
|
183
|
+
attrs = collection.get("attributes", {})
|
|
184
|
+
return {
|
|
185
|
+
"id": attrs.get("id"),
|
|
186
|
+
"title": attrs.get("title"),
|
|
187
|
+
"description": attrs.get("description"),
|
|
188
|
+
"slug": attrs.get("slug"),
|
|
189
|
+
"media_count": attrs.get("collection_media_count"),
|
|
190
|
+
"photos_count": attrs.get("photos_count"),
|
|
191
|
+
"videos_count": attrs.get("videos_count"),
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def normalize_collection_summary(item: dict) -> dict:
|
|
196
|
+
"""Normalize a collection from discover/popular."""
|
|
197
|
+
attrs = item.get("attributes", {})
|
|
198
|
+
return {
|
|
199
|
+
"id": attrs.get("id"),
|
|
200
|
+
"title": attrs.get("title"),
|
|
201
|
+
"slug": attrs.get("slug"),
|
|
202
|
+
"media_count": attrs.get("collection_media_count"),
|
|
203
|
+
"photos_count": attrs.get("photos_count"),
|
|
204
|
+
"videos_count": attrs.get("videos_count"),
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ── Helpers ──────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _format_name(user: dict) -> str:
|
|
212
|
+
"""Format user first/last name into a display string."""
|
|
213
|
+
return f"{user.get('first_name', '')} {user.get('last_name', '') or ''}".strip()
|