THE empty-body root cause. Patreon deprecated the flat `content` HTML field —
it returns null on the feed AND the detail endpoint, for every post type
(confirmed against the live API: all 135 StickySpoodge posts, text_only/
image_file/poll alike). The real body now lives in `content_json_string` (a
ProseMirror/TipTap doc), returned only under the DEFAULT post fieldset — a sparse
fields[post]=content request omits it. Not credential, not post_type: a request
shape gone stale.
- NEW utils/prosemirror.py: ProseMirror doc -> HTML (paragraphs, marks
bold/italic/underline/strike/code/link, hardBreak, inline images, lists,
headings; unknown nodes degrade to children). post_body_html(attrs) = the one
resolver: legacy content HTML else convert content_json_string.
- patreon_client: add content_json_string to the feed _FIELDS_POST; rewrite
fetch_post_detail_content to use the DEFAULT fieldset (no sparse fields[post])
and resolve via post_body_html (replaces the wrong sparse req + full-fetch
fallback).
- patreon_downloader._write_sidecar_data: resolve body via post_body_html
(feed content_json_string) before the detail-fetch; memoize resolved HTML.
- tests: prosemirror converter unit tests; client legacy + content_json_string
paths; contract pins content_json_string.
Inline <img> nodes carry the CDN filehash → bodies now feed Phase-2 localization.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>