postulator 0.1.1__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.
postulator/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ from .models import Post, AuthorRef, TagRef, SeoMeta
2
+ from .nodes import (
3
+ DocumentNode,
4
+ BlockNode,
5
+ InlineNode,
6
+ TextNode,
7
+ HyperlinkNode,
8
+ ParagraphNode,
9
+ HeadingNode,
10
+ ListNode,
11
+ ListItemNode,
12
+ BlockquoteNode,
13
+ HrNode,
14
+ AssetRef,
15
+ LocalAsset,
16
+ AudiobookAuthor,
17
+ AudiobookNarrator,
18
+ AudiobookSeries,
19
+ AudiobookNode,
20
+ AudiobookListItem,
21
+ AudiobookListNode,
22
+ AudiobookCarouselNode,
23
+ ContentImageNode,
24
+ UnknownNode,
25
+ )
26
+
27
+ __all__ = [
28
+ "Post", "AuthorRef", "TagRef", "SeoMeta",
29
+ "AssetRef", "LocalAsset",
30
+ "DocumentNode", "BlockNode", "InlineNode",
31
+ "TextNode", "HyperlinkNode",
32
+ "ParagraphNode", "HeadingNode", "ListNode", "ListItemNode",
33
+ "BlockquoteNode", "HrNode",
34
+ "AudiobookAuthor", "AudiobookNarrator", "AudiobookSeries",
35
+ "AudiobookNode", "AudiobookListItem", "AudiobookListNode", "AudiobookCarouselNode",
36
+ "ContentImageNode", "UnknownNode",
37
+ ]
@@ -0,0 +1,3 @@
1
+ from .contentful import ContentfulClient
2
+
3
+ __all__ = ["ContentfulClient"]
@@ -0,0 +1,3 @@
1
+ from .client import ContentfulClient
2
+
3
+ __all__ = ["ContentfulClient"]
@@ -0,0 +1,507 @@
1
+ """Contentful read logic — mixed into ContentfulClient via _ReaderMixin."""
2
+
3
+ from __future__ import annotations
4
+ from datetime import datetime, timezone
5
+ from typing import Any, TYPE_CHECKING
6
+
7
+ from ...models import Post, Author, AuthorRef, TagRef, AssetRef, SeoMeta
8
+ from ...nodes import (
9
+ BlockNode, DocumentNode, InlineNode,
10
+ TextNode, HyperlinkNode,
11
+ ParagraphNode, HeadingNode, ListNode, ListItemNode, BlockquoteNode, HrNode,
12
+ AudiobookAuthor, AudiobookNarrator, AudiobookSeries,
13
+ AudiobookNode, AudiobookListItem, AudiobookListNode, AudiobookCarouselNode, ContentImageNode,
14
+ TableCellNode, TableRowNode, TableNode,
15
+ UnknownNode,
16
+ )
17
+
18
+ if TYPE_CHECKING:
19
+ from .client import ContentfulClient
20
+
21
+
22
+ def _field(fields: dict, key: str, locale: str) -> Any:
23
+ f = fields.get(key, {})
24
+ return f.get(locale) or f.get("en-US")
25
+
26
+
27
+ def _parse_date(value: str | None) -> datetime | None:
28
+ if not value:
29
+ return None
30
+ try:
31
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
32
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
33
+ except ValueError:
34
+ return None
35
+
36
+
37
+ def _parse_asset(asset: dict | None, locale: str) -> AssetRef | None:
38
+ if not asset:
39
+ return None
40
+ sys = asset.get("sys", {})
41
+ fields = asset.get("fields", {})
42
+ file = _field(fields, "file", locale) or {}
43
+ details = file.get("details", {})
44
+ image = details.get("image", {})
45
+ raw_url = file.get("url")
46
+ url = (f"https:{raw_url}" if raw_url and raw_url.startswith("//") else raw_url) if raw_url else None
47
+ return AssetRef(
48
+ source_id=sys.get("id"),
49
+ url=url,
50
+ title=_field(fields, "title", locale),
51
+ alt=_field(fields, "description", locale),
52
+ file_name=file.get("fileName"),
53
+ content_type=file.get("contentType"),
54
+ width=image.get("width"),
55
+ height=image.get("height"),
56
+ size=details.get("size"),
57
+ )
58
+
59
+
60
+ def _linked_entry_ids(obj: Any) -> list[str]:
61
+ ids = []
62
+ if isinstance(obj, dict):
63
+ sys = obj.get("sys", {})
64
+ if sys.get("type") == "Link" and sys.get("linkType") == "Entry":
65
+ ids.append(sys["id"])
66
+ for v in obj.values():
67
+ ids.extend(_linked_entry_ids(v))
68
+ elif isinstance(obj, list):
69
+ for item in obj:
70
+ ids.extend(_linked_entry_ids(item))
71
+ return ids
72
+
73
+
74
+ def _entry_ids_from_links(links: list | None) -> list[str]:
75
+ if not links:
76
+ return []
77
+ return [
78
+ l["sys"]["id"] for l in links
79
+ if isinstance(l, dict) and l.get("sys", {}).get("linkType") == "Entry"
80
+ ]
81
+
82
+
83
+ def _parse_inline(node: dict) -> InlineNode:
84
+ nt = node.get("nodeType", "")
85
+ if nt == "hyperlink":
86
+ url = node.get("data", {}).get("uri", "")
87
+ children = [_parse_inline(c) for c in node.get("content", []) if c.get("nodeType") == "text"]
88
+ return HyperlinkNode(url=url, children=children)
89
+ value = node.get("value", "")
90
+ marks = [m["type"] for m in node.get("marks", []) if m.get("type") in ("bold", "italic", "underline", "code", "superscript", "subscript")]
91
+ return TextNode(value=value, marks=marks)
92
+
93
+
94
+ def _parse_paragraph(node: dict) -> ParagraphNode:
95
+ return ParagraphNode(children=[_parse_inline(c) for c in node.get("content", [])])
96
+
97
+
98
+ def _parse_block(node: dict, raw_entries: dict[str, dict], raw_assets: dict[str, dict], locale: str) -> BlockNode:
99
+ nt = node.get("nodeType", "")
100
+
101
+ if nt == "paragraph":
102
+ return _parse_paragraph(node)
103
+
104
+ if nt.startswith("heading-"):
105
+ level = int(nt.split("-")[1])
106
+ return HeadingNode(level=level, children=[_parse_inline(c) for c in node.get("content", [])])
107
+
108
+ if nt in ("unordered-list", "ordered-list"):
109
+ items = [
110
+ ListItemNode(children=[_parse_paragraph(p) for p in item.get("content", []) if p.get("nodeType") == "paragraph"])
111
+ for item in node.get("content", [])
112
+ ]
113
+ return ListNode(ordered=(nt == "ordered-list"), children=items)
114
+
115
+ if nt == "blockquote":
116
+ return BlockquoteNode(children=[_parse_paragraph(c) for c in node.get("content", []) if c.get("nodeType") == "paragraph"])
117
+
118
+ if nt == "hr":
119
+ return HrNode()
120
+
121
+ if nt == "table":
122
+ rows = [
123
+ TableRowNode(children=[
124
+ TableCellNode(
125
+ is_header=(cell.get("nodeType") == "table-header-cell"),
126
+ children=[_parse_block(child, raw_entries, raw_assets, locale) for child in cell.get("content", [])],
127
+ )
128
+ for cell in row.get("content", [])
129
+ ])
130
+ for row in node.get("content", [])
131
+ ]
132
+ return TableNode(children=rows)
133
+
134
+ if nt == "embedded-entry-block":
135
+ entry_id = node.get("data", {}).get("target", {}).get("sys", {}).get("id")
136
+ if entry_id and entry_id in raw_entries:
137
+ return _parse_embed(raw_entries[entry_id], raw_entries, raw_assets, locale)
138
+ return UnknownNode(raw=node)
139
+
140
+ return UnknownNode(raw=node)
141
+
142
+
143
+ def _parse_embed(entry: dict, raw_entries: dict[str, dict], raw_assets: dict[str, dict], locale: str) -> BlockNode:
144
+ sys = entry.get("sys", {})
145
+ ct = sys.get("contentType", {}).get("sys", {}).get("id", "")
146
+ fields = entry.get("fields", {})
147
+
148
+ if ct == "asin":
149
+ cover_url = _field(fields, "cover", locale)
150
+ raw_authors = _field(fields, "authors", locale) or []
151
+ raw_narrators = _field(fields, "narrators", locale) or []
152
+ raw_series = _field(fields, "series", locale) or []
153
+ return AudiobookNode(
154
+ source_id=sys.get("id"),
155
+ asin=_field(fields, "asin", locale) or "",
156
+ marketplace=_field(fields, "marketplace", locale) or "",
157
+ title=_field(fields, "title", locale),
158
+ cover_url=cover_url,
159
+ summary=_field(fields, "summary", locale),
160
+ label=_field(fields, "label", locale),
161
+ pdp=_field(fields, "pdp", locale),
162
+ release_date=_field(fields, "releaseDate", locale),
163
+ authors=[AudiobookAuthor(**a) for a in raw_authors if isinstance(a, dict)],
164
+ narrators=[AudiobookNarrator(**n) for n in raw_narrators if isinstance(n, dict)],
165
+ series=[AudiobookSeries(**s) for s in raw_series if isinstance(s, dict)],
166
+ )
167
+
168
+ if ct == "asinsList":
169
+ asins = _resolve_asins(_field(fields, "asins", locale), raw_entries, locale)
170
+ asin_entry_ids = _entry_ids_from_links(_field(fields, "asins", locale))
171
+ asin_items = _parse_asin_descriptions(_field(fields, "asinDescriptions", locale))
172
+ return AudiobookListNode(
173
+ source_id=sys.get("id"),
174
+ asins=asins,
175
+ asin_entry_ids=asin_entry_ids,
176
+ asin_items=asin_items,
177
+ title=_field(fields, "title", locale),
178
+ label=_field(fields, "label", locale),
179
+ body_copy=_field(fields, "copy", locale),
180
+ player_type=_field(fields, "playerType", locale) or "Cover",
181
+ asins_per_row=_field(fields, "asinsPerRow", locale) or 1,
182
+ descriptions=_field(fields, "descriptions", locale) or "Full",
183
+ filters=_field(fields, "filters", locale),
184
+ options=_field(fields, "options", locale) or [],
185
+ )
186
+
187
+ if ct == "asinsCarousel":
188
+ asins = _resolve_asins(_field(fields, "asins", locale), raw_entries, locale)
189
+ asin_entry_ids = _entry_ids_from_links(_field(fields, "asins", locale))
190
+ return AudiobookCarouselNode(
191
+ source_id=sys.get("id"),
192
+ asins=asins,
193
+ asin_entry_ids=asin_entry_ids,
194
+ items_per_slide=_field(fields, "itemsPerSlide", locale),
195
+ title=_field(fields, "title", locale),
196
+ subtitle=_field(fields, "subtitle", locale),
197
+ body_copy=_field(fields, "copy", locale),
198
+ cta_text=_field(fields, "ctaText", locale),
199
+ cta_url=_field(fields, "ctaUrl", locale),
200
+ options=_field(fields, "options", locale) or [],
201
+ )
202
+
203
+ if ct == "contentImage":
204
+ image_link = _field(fields, "image", locale)
205
+ asset_id = image_link.get("sys", {}).get("id") if isinstance(image_link, dict) else None
206
+ return ContentImageNode(
207
+ source_id=sys.get("id"),
208
+ image=_parse_asset(raw_assets.get(asset_id), locale) if asset_id else None,
209
+ href=_field(fields, "href", locale),
210
+ alignment=_field(fields, "alignment", locale),
211
+ size=_field(fields, "size", locale),
212
+ )
213
+
214
+ return UnknownNode(raw=entry)
215
+
216
+
217
+ def _parse_asin_descriptions(raw: list | None) -> list[AudiobookListItem]:
218
+ if not raw:
219
+ return []
220
+ items = []
221
+ for item in raw:
222
+ if not isinstance(item, dict):
223
+ continue
224
+ items.append(AudiobookListItem(
225
+ key=item.get("key", ""),
226
+ asin=item.get("asin", ""),
227
+ marketplace=item.get("marketplace", ""),
228
+ title=item.get("title"),
229
+ cover_url=item.get("cover"),
230
+ summary=item.get("summary"),
231
+ editor_badge=item.get("editorBadge"),
232
+ ))
233
+ return items
234
+
235
+
236
+ def _resolve_asins(links: list | None, raw_entries: dict[str, dict], locale: str) -> list[str]:
237
+ if not links:
238
+ return []
239
+ asins = []
240
+ for link in links:
241
+ eid = link.get("sys", {}).get("id") if isinstance(link, dict) else None
242
+ if eid and eid in raw_entries:
243
+ asin = _field(raw_entries[eid].get("fields", {}), "asin", locale)
244
+ if asin:
245
+ asins.append(asin)
246
+ return asins
247
+
248
+
249
+ def _parse_body(richtext: dict, raw_entries: dict[str, dict], raw_assets: dict[str, dict], locale: str) -> DocumentNode:
250
+ return [_parse_block(node, raw_entries, raw_assets, locale) for node in richtext.get("content", [])]
251
+
252
+
253
+ def _embedded_ids_from_richtext(richtext: dict) -> list[str]:
254
+ ids = []
255
+ nt = richtext.get("nodeType", "")
256
+ if nt == "embedded-entry-block":
257
+ eid = richtext.get("data", {}).get("target", {}).get("sys", {}).get("id")
258
+ if eid:
259
+ ids.append(eid)
260
+ for child in richtext.get("content", []):
261
+ ids.extend(_embedded_ids_from_richtext(child))
262
+ return ids
263
+
264
+
265
+ def _collect_asset_ids(fields: dict, raw_entries: dict[str, dict], locale: str) -> set[str]:
266
+ asset_ids: set[str] = set()
267
+ image_link = _field(fields, "image", locale)
268
+ if isinstance(image_link, dict) and image_link.get("sys", {}).get("linkType") == "Asset":
269
+ asset_ids.add(image_link["sys"]["id"])
270
+ for entry in raw_entries.values():
271
+ ct = entry.get("sys", {}).get("contentType", {}).get("sys", {}).get("id", "")
272
+ if ct == "contentImage":
273
+ link = _field(entry.get("fields", {}), "image", locale)
274
+ if isinstance(link, dict) and link.get("sys", {}).get("linkType") == "Asset":
275
+ asset_ids.add(link["sys"]["id"])
276
+ seo_link = _field(fields, "seoSettings", locale)
277
+ if isinstance(seo_link, dict):
278
+ seo_eid = seo_link.get("sys", {}).get("id")
279
+ if seo_eid and seo_eid in raw_entries:
280
+ og_link = _field(raw_entries[seo_eid].get("fields", {}), "openGraphImage", locale)
281
+ if isinstance(og_link, dict) and og_link.get("sys", {}).get("linkType") == "Asset":
282
+ asset_ids.add(og_link["sys"]["id"])
283
+ return asset_ids
284
+
285
+
286
+ def _collect_author_asset_ids(fields: dict, raw_entries: dict[str, dict], locale: str) -> set[str]:
287
+ asset_ids: set[str] = set()
288
+ picture_link = _field(fields, "picture", locale)
289
+ if isinstance(picture_link, dict) and picture_link.get("sys", {}).get("linkType") == "Asset":
290
+ asset_ids.add(picture_link["sys"]["id"])
291
+ seo_link = _field(fields, "seoSettings", locale)
292
+ seo_eid = seo_link.get("sys", {}).get("id") if isinstance(seo_link, dict) else None
293
+ if seo_eid and seo_eid in raw_entries:
294
+ og_link = _field(raw_entries[seo_eid].get("fields", {}), "openGraphImage", locale)
295
+ if isinstance(og_link, dict) and og_link.get("sys", {}).get("linkType") == "Asset":
296
+ asset_ids.add(og_link["sys"]["id"])
297
+ return asset_ids
298
+
299
+
300
+ def _parse_author(entry: dict, raw_entries: dict[str, dict], raw_assets: dict[str, dict], locale: str) -> Author:
301
+ sys = entry.get("sys", {})
302
+ fields = entry.get("fields", {})
303
+ picture_link = _field(fields, "picture", locale)
304
+ picture_id = picture_link.get("sys", {}).get("id") if isinstance(picture_link, dict) else None
305
+ return Author(
306
+ source_id=sys.get("id"),
307
+ country_code=_field(fields, "countryCode", locale),
308
+ slug=_field(fields, "slug", locale) or "",
309
+ name=_field(fields, "name", locale) or "",
310
+ short_name=_field(fields, "shortName", locale),
311
+ title=_field(fields, "title", locale),
312
+ bio=_field(fields, "bio", locale),
313
+ picture=_parse_asset(raw_assets.get(picture_id), locale) if picture_id else None,
314
+ seo=_parse_seo(fields, raw_entries, raw_assets, locale),
315
+ )
316
+
317
+
318
+ def _parse_authors(fields: dict, raw_entries: dict[str, dict], locale: str) -> list[AuthorRef]:
319
+ authors = []
320
+ for eid in _entry_ids_from_links(_field(fields, "authors", locale) or []):
321
+ e = raw_entries.get(eid, {})
322
+ ef = e.get("fields", {})
323
+ slug = _field(ef, "slug", locale)
324
+ name = _field(ef, "name", locale)
325
+ if slug and name:
326
+ authors.append(AuthorRef(slug=slug, locale=locale, name=name, source_id=eid))
327
+ return authors
328
+
329
+
330
+ def _parse_tags(fields: dict, raw_entries: dict[str, dict], locale: str) -> list[TagRef]:
331
+ tags = []
332
+ tag_links = (_field(fields, "tags", locale) or []) + (
333
+ [_field(fields, "category", locale)] if _field(fields, "category", locale) else []
334
+ )
335
+ for eid in _entry_ids_from_links(tag_links):
336
+ e = raw_entries.get(eid, {})
337
+ ef = e.get("fields", {})
338
+ slug = _field(ef, "slug", locale)
339
+ name = _field(ef, "name", locale)
340
+ if slug and name:
341
+ tags.append(TagRef(slug=slug, locale=locale, name=name, source_id=eid))
342
+ return tags
343
+
344
+
345
+ def _parse_seo(fields: dict, raw_entries: dict[str, dict], raw_assets: dict[str, dict], locale: str) -> SeoMeta | None:
346
+ seo_link = _field(fields, "seoSettings", locale)
347
+ seo_eid_list = _entry_ids_from_links([seo_link] if seo_link else [])
348
+ if not seo_eid_list:
349
+ return None
350
+ se = raw_entries.get(seo_eid_list[0], {}).get("fields", {})
351
+ og_link = _field(se, "openGraphImage", locale)
352
+ og_asset_id = og_link.get("sys", {}).get("id") if isinstance(og_link, dict) else None
353
+ json_ld_link = _field(se, "jsonLd", locale)
354
+ return SeoMeta(
355
+ source_id=seo_eid_list[0],
356
+ label=_field(se, "label", locale),
357
+ slug_replacement=_field(se, "slugReplacement", locale),
358
+ slug_redirect=_field(se, "slugRedirect", locale),
359
+ no_index=_field(se, "noIndex", locale),
360
+ meta_title=_field(se, "metaTitle", locale),
361
+ meta_description=_field(se, "metaDescription", locale),
362
+ og_title=_field(se, "openGraphTitle", locale),
363
+ og_description=_field(se, "openGraphDescription", locale),
364
+ og_image=_parse_asset(raw_assets.get(og_asset_id), locale) if og_asset_id else None,
365
+ schema_type=_field(se, "schemaType", locale),
366
+ json_ld_id=json_ld_link.get("sys", {}).get("id") if isinstance(json_ld_link, dict) else None,
367
+ similar_content_ids=_entry_ids_from_links(_field(se, "similarContent", locale) or []),
368
+ external_links_source_code=_field(se, "externalLinksSourceCode", locale),
369
+ )
370
+
371
+
372
+ class _ReaderMixin:
373
+ async def read_author(
374
+ self: "ContentfulClient",
375
+ entry_id: str,
376
+ locale: str = "en-US",
377
+ ) -> Author:
378
+ entry = await self.get_entry(entry_id)
379
+ fields = entry.get("fields", {})
380
+ ids_to_fetch: set[str] = set()
381
+ seo_link = _field(fields, "seoSettings", locale)
382
+ if isinstance(seo_link, dict):
383
+ ids_to_fetch.update(_entry_ids_from_links([seo_link]))
384
+ raw_entries: dict[str, dict] = await self.get_entries(list(ids_to_fetch)) if ids_to_fetch else {}
385
+ raw_assets = await self.get_assets(list(_collect_author_asset_ids(fields, raw_entries, locale)))
386
+ return _parse_author(entry, raw_entries, raw_assets, locale)
387
+
388
+ async def list_authors(
389
+ self: "ContentfulClient",
390
+ country_code: str,
391
+ locale: str = "en-US",
392
+ ) -> list[Author]:
393
+ items = await self.find_entries(
394
+ "author",
395
+ {"fields.countryCode": country_code},
396
+ limit=self._batch_size,
397
+ )
398
+ if not items:
399
+ return []
400
+ seo_ids: set[str] = set()
401
+ for item in items:
402
+ seo_link = _field(item.get("fields", {}), "seoSettings", locale)
403
+ if isinstance(seo_link, dict):
404
+ seo_ids.update(_entry_ids_from_links([seo_link]))
405
+ raw_entries = await self.get_entries(list(seo_ids)) if seo_ids else {}
406
+ all_asset_ids: set[str] = set()
407
+ for item in items:
408
+ all_asset_ids.update(_collect_author_asset_ids(item.get("fields", {}), raw_entries, locale))
409
+ raw_assets = await self.get_assets(list(all_asset_ids))
410
+ return [_parse_author(item, raw_entries, raw_assets, locale) for item in items]
411
+
412
+ async def read_post(
413
+ self: "ContentfulClient",
414
+ entry_id: str,
415
+ locale: str = "en-US",
416
+ ) -> Post:
417
+ main_entry = await self.get_entry(entry_id)
418
+ fields = main_entry.get("fields", {})
419
+ richtext_raw = _field(fields, "content", locale)
420
+
421
+ ids_to_fetch: set[str] = set()
422
+ if richtext_raw:
423
+ ids_to_fetch.update(_embedded_ids_from_richtext(richtext_raw))
424
+ for ref_field in ("authors", "tags", "category", "seoSettings", "relatedPosts"):
425
+ val = _field(fields, ref_field, locale)
426
+ if isinstance(val, list):
427
+ ids_to_fetch.update(_entry_ids_from_links(val))
428
+ elif isinstance(val, dict):
429
+ ids_to_fetch.update(_entry_ids_from_links([val]))
430
+
431
+ raw_entries: dict[str, dict] = {}
432
+ if ids_to_fetch:
433
+ self._emit("fetching_entries", count=len(ids_to_fetch))
434
+ raw_entries = await self.get_entries(list(ids_to_fetch))
435
+ nested_ids = set(_linked_entry_ids(raw_entries)) - ids_to_fetch
436
+ seo_link = _field(fields, "seoSettings", locale)
437
+ seo_eid = seo_link.get("sys", {}).get("id") if isinstance(seo_link, dict) else None
438
+ if seo_eid and seo_eid in raw_entries:
439
+ similar = _field(raw_entries[seo_eid].get("fields", {}), "similarContent", locale) or []
440
+ nested_ids.update(_entry_ids_from_links(similar))
441
+ if nested_ids:
442
+ self._emit("fetching_nested", count=len(nested_ids))
443
+ raw_entries.update(await self.get_entries(list(nested_ids)))
444
+
445
+ raw_assets = await self.get_assets(list(_collect_asset_ids(fields, raw_entries, locale)))
446
+
447
+ self._emit("parsing")
448
+
449
+ body = _parse_body(richtext_raw, raw_entries, raw_assets, locale) if richtext_raw else []
450
+
451
+ image_link = _field(fields, "image", locale)
452
+ asset_id = image_link.get("sys", {}).get("id") if isinstance(image_link, dict) else None
453
+
454
+ return Post(
455
+ source_id=entry_id,
456
+ slug=_field(fields, "slug", locale) or "",
457
+ locale=locale,
458
+ title=_field(fields, "title", locale) or "",
459
+ date=_parse_date(_field(fields, "date", locale)) or datetime.now(timezone.utc),
460
+ update_date=_parse_date(_field(fields, "updateDate", locale)),
461
+ introduction=_field(fields, "introduction", locale),
462
+ body=body,
463
+ featured_image=_parse_asset(raw_assets.get(asset_id), locale) if asset_id else None,
464
+ authors=_parse_authors(fields, raw_entries, locale),
465
+ tags=_parse_tags(fields, raw_entries, locale),
466
+ seo=_parse_seo(fields, raw_entries, raw_assets, locale),
467
+ custom_recommended_title=_field(fields, "customRecommendedTitle", locale),
468
+ show_in_feed=not (_field(fields, "hideFromBlogFeed", locale) or False),
469
+ show_publish_date=not (_field(fields, "hidePublishDate", locale) or False),
470
+ show_hero_image=not (_field(fields, "hideHeroImage", locale) or False),
471
+ related_posts=_entry_ids_from_links(_field(fields, "relatedPosts", locale) or []),
472
+ )
473
+
474
+ async def list_tags(
475
+ self: "ContentfulClient",
476
+ country_code: str,
477
+ locale: str = "en-US",
478
+ ) -> list[TagRef]:
479
+ items = await self.find_entries(
480
+ "tag",
481
+ {"fields.countryCode": country_code},
482
+ limit=self._batch_size,
483
+ )
484
+ tags = []
485
+ for item in items:
486
+ sys = item.get("sys", {})
487
+ fields = item.get("fields", {})
488
+ slug = _field(fields, "slug", locale)
489
+ name = _field(fields, "name", locale)
490
+ if slug and name:
491
+ tags.append(TagRef(slug=slug, locale=locale, name=name, source_id=sys.get("id")))
492
+ return tags
493
+
494
+ async def find_entry_by_slug(
495
+ self: "ContentfulClient",
496
+ slug: str,
497
+ locale: str,
498
+ ) -> dict[str, Any] | None:
499
+ country_code = locale.split("-")[-1].upper()
500
+ for content_type in ("post", "category"):
501
+ items = await self.find_entries(
502
+ content_type,
503
+ {"fields.slug": slug, "fields.countryCode": country_code},
504
+ )
505
+ if items:
506
+ return items[0]
507
+ return None