Programmatically create, read, and publish Audible blog posts to Contentful CMA.
Project description
Postulator
Overview
Postulator is a Python library for programmatically creating, reading, and publishing blog posts to Contentful CMA (Content Management API). It provides Pydantic models for posts, rich-text body nodes, audiobook embeds, SEO settings, and authors — plus an async Contentful client that handles ASIN resolution, asset uploads, and entry publishing in a single pipeline.
The primary consumer is other LLMs and automation scripts that need to compose and publish Audible blog content to a Contentful space.
Installation
pip install -r requirements.txt
Dependencies: pydantic, httpx, python-dotenv, scraperator.
Configuration
Set these environment variables (or use a .env file with python-dotenv):
| Variable | Required | Description |
|---|---|---|
CONTENTFUL_TOKEN |
Yes | Contentful CMA personal access token |
CONTENTFUL_SPACE_ID |
Yes | Contentful space ID |
CONTENTFUL_ENVIRONMENT |
No | Contentful environment (defaults to "master") |
Quick Start
import asyncio
from datetime import datetime, timezone
from postulator import Post, ParagraphNode, TextNode, HeadingNode, AudiobookNode
from postulator.adapters.contentful import ContentfulClient
post = Post(
slug="my-first-post",
locale="fr-FR",
title="My First Post",
date=datetime.now(timezone.utc),
body=[
HeadingNode(level=2, children=[TextNode(value="Hello")]),
ParagraphNode(children=[TextNode(value="This is a paragraph.")]),
AudiobookNode(asin="B0D53WYQ3S", marketplace="FR"),
],
)
async def main():
async with ContentfulClient(
space_id="<space_id>",
environment="master",
token="<token>",
) as client:
created = await client.create_post(post, publish=True)
print(created.source_id)
asyncio.run(main())
The pipeline automatically:
- Enriches
AudiobookNodes by scraping Audible (title, cover, PDP URL, authors, etc.) - Creates/reuses
asinentries in Contentful - Creates
asinsList/asinsCarouselentries for list/carousel nodes - Uploads any
LocalAssetimages - Creates/updates the
seoSettingsentry ifpost.seois set - Creates the
postentry with rich-text body referencing all embedded entries - Publishes everything
Post Model
Post — the top-level model representing a blog post.
| Field | Type | Default | Description |
|---|---|---|---|
source_id |
str | None |
None |
Contentful entry ID. Required for write_post, auto-set by create_post. |
slug |
str |
— | URL slug |
locale |
str |
— | BCP-47 locale (e.g. "fr-FR", "en-GB"). Controls countryCode and Audible marketplace — does not affect Contentful field locale (always en-US). |
title |
str |
— | Post title |
date |
datetime |
— | Publish date |
introduction |
str | None |
None |
Short intro text |
body |
DocumentNode |
— | List of BlockNode (the rich-text body) |
featured_image |
AssetRef | LocalAsset | None |
None |
Hero image |
authors |
list[AuthorRef] |
[] |
Author references (must have source_id set for write) |
tags |
list[TagRef] |
[] |
Tag references (must have source_id set for write) |
update_date |
datetime | None |
None |
Last-updated date |
seo |
SeoMeta | None |
None |
SEO settings (created/updated automatically during write) |
custom_recommended_title |
str | None |
None |
Override title for recommended content widgets |
show_in_feed |
bool |
True |
Show in blog feed (maps to hideFromBlogFeed inverted) |
show_publish_date |
bool |
True |
Show publish date on page |
show_hero_image |
bool |
True |
Show hero image on page |
related_posts |
list[str] |
[] |
Contentful entry IDs of related posts |
Author Model
Author — represents a blog author entry. Used with create_author / write_author.
| Field | Type | Default | Description |
|---|---|---|---|
source_id |
str | None |
None |
Contentful entry ID. Required for write_author. |
country_code |
str | None |
None |
e.g. "FR", "UK" |
slug |
str |
— | URL slug |
name |
str |
— | Display name |
short_name |
str | None |
None |
Abbreviated name |
title |
str | None |
None |
Job title / role |
bio |
str | None |
None |
Biography text |
picture |
AssetRef | LocalAsset | None |
None |
Profile picture |
seo |
SeoMeta | None |
None |
SEO settings for the author page |
Body Nodes
DocumentNode is list[BlockNode]. Each BlockNode is a discriminated union (on type).
Standard Block Nodes
ParagraphNode (type="paragraph")
children: list[InlineNode]— list ofTextNodeand/orHyperlinkNode
HeadingNode (type="heading")
level: int— 1–6children: list[InlineNode]
ListNode (type="list")
ordered: bool—Falsefor bullet list,Truefor numberedchildren: list[ListItemNode]— eachListItemNodecontainslist[ParagraphNode]
BlockquoteNode (type="blockquote")
children: list[ParagraphNode]
HrNode (type="hr")
- No fields. Horizontal rule.
TableNode (type="table")
children: list[TableRowNode]— each row containslist[TableCellNode]TableCellNodehasis_header: boolandchildren: list[BlockNode]
Inline Nodes
TextNode (type="text")
value: strmarks: list[Literal["bold", "italic", "underline", "code", "superscript", "subscript"]]
HyperlinkNode (type="hyperlink")
url: strchildren: list[TextNode]
Embed Block Nodes
AudiobookNode (type="audiobook")
Represents a single Audible product embed. You only need to provide asin and marketplace — the rest is auto-populated by scraping Audible during write.
| Field | Type | Required for render | Description |
|---|---|---|---|
asin |
str |
Yes | Audible ASIN |
marketplace |
str |
Yes | e.g. "FR", "US", "DE" |
source_id |
str | None |
— | Contentful entry ID (auto-set during write) |
title |
str | None |
Yes | Book title (auto-scraped) |
cover_url |
str | None |
Yes | Cover image URL (auto-scraped) |
pdp |
str | None |
Yes | Product detail page URL (auto-scraped) |
authors |
list[AudiobookAuthor] |
Yes (name + pdp) | Author names and links (auto-scraped) |
summary |
str | None |
No | Publisher summary HTML |
label |
str | None |
No | Display label |
release_date |
str | None |
No | YYYY-MM-DD format |
narrators |
list[AudiobookNarrator] |
No | Narrator names |
series |
list[AudiobookSeries] |
No | Series info |
AudiobookListNode (type="audiobook-list")
A list of audiobooks rendered as a grid. Maps to the asinsList content type.
| Field | Type | Default | Description |
|---|---|---|---|
asins |
list[str] |
[] |
ASINs to include |
asin_entry_ids |
list[str] |
[] |
Preserved Contentful entry IDs (used on read round-trip) |
asin_items |
list[AudiobookListItem] |
[] |
Per-item overrides for descriptions="Custom" mode |
title |
str | None |
None |
Section title |
label |
str | None |
None |
Display label |
body_copy |
str | None |
None |
Intro copy |
player_type |
str |
"Cover" |
Player display type |
asins_per_row |
int |
1 |
Items per row. Must be 1, 3, 4, or 5. |
descriptions |
str |
"Full" |
"Full", "Short", or "Custom" |
filters |
list[str] | None |
None |
Filter options |
options |
list[str] |
[] |
Display options |
AudiobookCarouselNode (type="audiobook-carousel")
A carousel of audiobooks. Maps to the asinsCarousel content type. Requires at least 4 ASINs.
| Field | Type | Default | Description |
|---|---|---|---|
asins |
list[str] |
— | ASINs to include (minimum 4) |
asin_entry_ids |
list[str] |
[] |
Preserved Contentful entry IDs |
items_per_slide |
int | None |
None |
Items visible per slide |
title |
str | None |
None |
Carousel title |
subtitle |
str | None |
None |
Subtitle |
body_copy |
str | None |
None |
Intro copy |
cta_text |
str | None |
None |
Call-to-action button text |
cta_url |
str | None |
None |
CTA link URL |
options |
list[str] |
[] |
Display options |
ContentImageNode (type="content-image")
An inline image embed. Maps to the contentImage content type.
| Field | Type | Default | Description |
|---|---|---|---|
source_id |
str | None |
None |
Contentful entry ID (required for write) |
image |
AssetRef | LocalAsset | None |
None |
The image asset |
href |
str | None |
None |
Link URL when image is clicked |
alignment |
str | None |
None |
Image alignment |
size |
str | None |
None |
Image size |
UnknownNode (type="unknown")
raw: dict— raw Contentful JSON for unrecognized content types. Written back as-is.
Assets
Two asset types:
AssetRef — references an existing Contentful asset (returned by reads and after upload).
| Field | Type | Description |
|---|---|---|
source_id |
str | None |
Contentful asset ID |
url |
str | None |
Public URL (always https://) |
title |
str | None |
Asset title |
alt |
str | None |
Alt text |
file_name |
str | None |
Original file name |
content_type |
str | None |
MIME type |
width |
int | None |
Image width in px |
height |
int | None |
Image height in px |
size |
int | None |
File size in bytes |
LocalAsset — a local file to upload during write.
| Field | Type | Description |
|---|---|---|
local_path |
str |
Absolute or relative path to the file on disk |
title |
str |
Asset title in Contentful |
alt |
str | None |
Alt text |
file_name |
str | None |
Override file name (defaults to basename of local_path) |
content_type |
str | None |
Override MIME type (auto-detected if omitted) |
During create_post / write_post, any LocalAsset on featured_image, seo.og_image, or ContentImageNode.image is automatically uploaded via upload_local_asset, which:
- Reads the file from disk
- Uploads bytes to Contentful's upload endpoint
- Creates an asset entry linking to the upload
- Processes the asset (Contentful server-side)
- Polls until processing completes
- Publishes the asset
- Returns an
AssetRefthat replaces theLocalAssetin-place
SEO Settings
SeoMeta — maps to the seoSettings content type.
| Field | Type | Default | Description |
|---|---|---|---|
source_id |
str | None |
None |
Contentful entry ID (auto-set after write) |
label |
str | None |
None |
Internal label (falls back to "SEO Settings: {post.title}") |
slug_replacement |
str | None |
None |
Override slug |
slug_redirect |
str | None |
None |
Redirect slug |
no_index |
bool | None |
None |
Set noindex meta tag |
meta_title |
str | None |
None |
<title> tag |
meta_description |
str | None |
None |
Meta description |
og_title |
str | None |
None |
Open Graph title |
og_description |
str | None |
None |
Open Graph description |
og_image |
AssetRef | LocalAsset | None |
None |
Open Graph image (LocalAsset auto-uploaded) |
schema_type |
str | None |
None |
Schema.org type |
json_ld_id |
str | None |
None |
Linked jsonLd entry ID |
similar_content_ids |
list[str] |
[] |
Entry IDs for similar content links |
external_links_source_code |
str | None |
None |
Tracking source code for external links |
write_seo creates a new seoSettings entry if seo.source_id is None, or updates the existing one. It publishes the entry and sets seo.source_id in-place.
Authors & Tags
AuthorRef and TagRef are lightweight references used on Post. Both require source_id to be set to an existing Contentful entry ID for writes.
from postulator import AuthorRef, TagRef
post.authors = [
AuthorRef(slug="fr-author", locale="fr-FR", name="FR Author", source_id="52621970-fr-author"),
]
post.tags = [
TagRef(slug="fr-tag", locale="fr-FR", name="FR Tag", source_id="2093616522-fr-tag"),
]
To discover existing author/tag IDs, use:
client.list_authors(country_code="FR")— returnslist[Author]client.list_tags(country_code="FR")— returnslist[TagRef]
Locale & Marketplace Mapping
Post.locale determines the countryCode written to Contentful and the Audible marketplace used for ASIN scraping.
| Locale | Country Code | Audible TLD |
|---|---|---|
de-DE |
DE |
audible.de |
en-GB |
UK |
audible.co.uk |
fr-FR |
FR |
audible.fr |
it-IT |
IT |
audible.it |
en-CA |
CA_EN |
audible.ca |
fr-CA |
CA_FR |
audible.ca |
es-ES |
ES |
audible.es |
en-US |
US |
audible.com |
en-AU |
AU |
audible.com.au |
ContentfulClient
Async HTTP client wrapping the Contentful CMA. Must be used as an async context manager.
from postulator.adapters.contentful import ContentfulClient
async with ContentfulClient(
space_id="<space_id>",
environment="master",
token="<token>",
on_progress=lambda e: print(e),
) as client:
...
Constructor Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
space_id |
str |
— | Contentful space ID |
environment |
str |
— | Environment name |
token |
str |
— | CMA access token |
batch_size |
int |
200 |
Max entries per batch request |
asset_poll_attempts |
int |
10 |
Polls before asset processing timeout |
asset_poll_interval |
float |
1.0 |
Seconds between asset processing polls |
on_progress |
Callable | None |
None |
Progress callback (receives dict with event, ts, and extra keys) |
High-Level Methods
Posts:
create_post(post, publish=False) -> Post— full pipeline: enrich ASINs, upload assets, create all entries, create post. Returns the round-trippedPost.write_post(post, publish=True) -> Post— same pipeline but updates an existing post (post.source_idrequired).read_post(entry_id, locale="en-US") -> Post— reads a post and all its linked entries/assets into aPostmodel.
Authors:
create_author(author, publish=False) -> Author— creates a new author entry.write_author(author, publish=True) -> Author— updates an existing author (author.source_idrequired).read_author(entry_id, locale="en-US") -> Author— reads an author entry.list_authors(country_code, locale="en-US") -> list[Author]— lists all authors for a country code.
Tags:
list_tags(country_code, locale="en-US") -> list[TagRef]— lists all tags for a country code.
Lookup:
find_entry_by_slug(slug, locale) -> dict | None— finds apostorcategoryentry by slug and country code.
SEO:
write_seo(seo, fallback_label) -> str— creates or updates aseoSettingsentry. Returns entry ID.
Assets:
upload_local_asset(asset: LocalAsset) -> AssetRef— uploads, processes, publishes a local file. Returns the resultingAssetRef.
Embeds (usually called automatically by the post pipeline):
write_asin(node: AudiobookNode) -> str— creates or reuses anasinentry. Returns entry ID.write_asin_list(node: AudiobookListNode, asin_nodes) -> str— creates or updates anasinsListentry.write_asin_carousel(node: AudiobookCarouselNode, asin_nodes) -> str— creates or updates anasinsCarouselentry.
Low-Level Methods
get_entry(entry_id) -> dictget_entries(entry_ids) -> dict[str, dict]— batch fetch, auto-paginatedcreate_entry(content_type, fields) -> dictcreate_entry_with_id(entry_id, content_type, fields) -> dictupdate_entry(entry_id, version, fields) -> dictpublish_entry(entry_id, version) -> dictdelete_entry(entry_id, version) -> Nonefind_entries(content_type, filters, limit=1) -> list[dict]— auto-paginatedget_asset(asset_id) -> dictget_assets(asset_ids) -> dict[str, dict]— batch fetchupload_file(data, content_type) -> str— returns upload IDcreate_asset(fields) -> dictprocess_asset(asset_id, locale) -> Nonepublish_asset(asset_id, version) -> dictget_content_type(content_type_id) -> dict
Retry Behaviour
All HTTP requests retry up to 3 times on status codes 429, 500, 502, 503, 504 with exponential backoff (2^attempt seconds). Non-retryable errors raise httpx.HTTPStatusError immediately.
Progress Events
The on_progress callback receives dicts with an event key. Events emitted:
| Event | When | Extra keys |
|---|---|---|
fetching_entries |
Before batch-fetching linked entries during read | count |
fetching_nested |
Before fetching nested linked entries | count |
parsing |
Before parsing raw Contentful data into models | — |
resolving_asins |
Before batch-resolving existing ASIN entries | count |
enriching_asins |
Before scraping Audible for missing ASINs | count |
writing_asin |
Before creating/reusing a single ASIN entry | asin, marketplace |
asin_publish_conflict |
When a uniqueKey conflict is detected and resolved | asin, entry_id |
asin_publish_failed |
When publishing an ASIN entry fails | asin, message |
uploading_asset |
Before uploading a local asset | title, file_name |
asset_upload_failed |
When asset upload fails | title, message |
asset_processing_timeout |
When asset processing polling times out | asset_id |
writing_post |
Before updating a post entry | entry_id |
creating_post |
Before creating a new post entry | slug, locale |
writing_author |
Before updating an author entry | entry_id |
creating_author |
Before creating a new author entry | slug |
post_invalid |
When post validation fails | slug, reason |
list_skipped |
When an AudiobookListNode is skipped (0 ASINs) | reason |
carousel_skipped |
When a carousel is skipped (<4 ASINs) | reason, asins |
request_failed |
When an HTTP request fails (non-retryable or after retries) | method, url, status_code |
Scraperator Adapter
The postulator.adapters.scraperator module wraps the scraperator library to batch-scrape Audible product pages and populate AudiobookNode fields.
enrich_audiobook_nodes(nodes, on_progress=None) fills in title, pdp, cover_url, summary, release_date, authors, and narrators on each node — only for fields that are None/empty (never overwrites manually-set data).
To configure caching:
from postulator.adapters.scraperator import configure
configure(
cache="local", # "local" or "dynamodb"
cache_directory="cache", # local cache dir
cache_table=None, # DynamoDB table name
scrape_cache="none", # raw scrape cache
)
Known Quirks
All fields are written under en-US
Contentful fields are always stored under the "en-US" locale key regardless of post.locale.
The locale field on Post controls countryCode (e.g. FR, UK) and determines which
Audible marketplace is used for ASIN scraping — it does not affect the Contentful field locale.
This is intentional given the current space setup but worth keeping in mind if multi-locale
field storage is ever needed.
asinDescriptions — hybrid inline overrides
The asinDescriptions field on an asinsList entry stores a hybrid structure: each item contains
both a sys link pointing to the underlying asin entry and inline field overrides (summary,
cover, title, editorBadge, etc.) that take precedence over what is stored on the linked entry.
The descriptions field controls which data the frontend uses:
"Full"/"Short"— reads summary from the linkedasinentry directly"Custom"— reads the inline overrides fromasinDescriptionsinstead
When writing an AudiobookListNode with custom per-item summaries, populate asin_items with
AudiobookListItem instances and set descriptions="Custom". write_asin_list will resolve the
underlying asin entry IDs automatically and embed them alongside the inline overrides.
ASIN deduplication
The write pipeline collects all ASINs across the entire post body (single embeds, lists, carousels),
deduplicates by {ASIN}-{MARKETPLACE} key, batch-resolves existing entries, and only scrapes/creates
missing ones. Duplicate AudiobookNodes referencing the same ASIN reuse the same source_id.
Carousel minimum
AudiobookCarouselNode requires at least 4 ASINs. Carousels with fewer are skipped during write
(emits carousel_skipped event).
asins_per_row validation
AudiobookListNode.asins_per_row must be one of 1, 3, 4, 5. Other values raise ValueError.
source_id requirements for write
write_postrequirespost.source_id(usecreate_postfor new posts)write_authorrequiresauthor.source_id(usecreate_authorfor new authors)ContentImageNoderequiressource_idfor write (must reference an existingcontentImageentry)AudiobookListNodeandAudiobookCarouselNodegetsource_idauto-set during the post pipeline; when callingwrite_asin_list/write_asin_carouseldirectly, setsource_idto update or leaveNoneto create
ASIN uniqueKey conflict resolution
When publishing an asin entry whose uniqueKey conflicts with an already-published entry,
the writer detects the conflict from the Contentful error response, deletes the duplicate,
and returns the ID of the existing entry.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file postulator-0.1.1.tar.gz.
File metadata
- Download URL: postulator-0.1.1.tar.gz
- Upload date:
- Size: 33.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
387e0f1464c91b128f3814ee553c3ba3898637c58aa299c8814bd9a9c52c5c0f
|
|
| MD5 |
476bf70a0442904cdc078a1d7077341b
|
|
| BLAKE2b-256 |
be41316a02801c81bf8f19961eb75ea43727e7fc5a7b359906dfced01118b07b
|
File details
Details for the file postulator-0.1.1-py3-none-any.whl.
File metadata
- Download URL: postulator-0.1.1-py3-none-any.whl
- Upload date:
- Size: 27.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e61e38f228bc743297fe13aee425722974847d0819e2e74df113949be618acea
|
|
| MD5 |
298e75862e39dd403567995f86cf3570
|
|
| BLAKE2b-256 |
ff685d52ec44f88492b85ba07675f3bb241a6e4d05182db8fac844ab7d03d58c
|