diff --git a/backend/app/services/patreon_client.py b/backend/app/services/patreon_client.py index f90f92c..84a4ffd 100644 --- a/backend/app/services/patreon_client.py +++ b/backend/app/services/patreon_client.py @@ -612,12 +612,39 @@ class PatreonClient: if isinstance(content, str) and content.strip(): log.info("post-detail: fetched %d chars (post %s)", len(content), post_id) return content - # 200 OK but content is null/empty — the common silent case (a tier-gated - # post serves content:null, or the post genuinely has no text body). Logged - # so a recapture that "caught nothing" is diagnosable, not invisible. + # Sparse `fields[post]=content` came back null/empty. Patreon serves null + # under the sparse fieldset for some post types (polls, embeds/films, + # body-only announcements) even when the body plainly exists. Re-fetch the + # FULL post resource ONCE — only the empty cases pay this extra GET — which + # both DIAGNOSES (logs post_type) and RECOVERS the body when the sparse + # fieldset was the cause (#842). + try: + full = self._session.get( + url, params={"json-api-version": "1.0"}, timeout=_TIMEOUT_SECONDS, + ) + except requests.RequestException as exc: + log.warning("post-detail full re-fetch failed (post %s): %s", post_id, exc) + return None + fa = None + if full.status_code == 200: + try: + fp = full.json() + except ValueError: + fp = None + fdata = fp.get("data") if isinstance(fp, dict) else None + fa = fdata.get("attributes") if isinstance(fdata, dict) else None + fcontent = fa.get("content") if isinstance(fa, dict) else None + ptype = fa.get("post_type") if isinstance(fa, dict) else None + if isinstance(fcontent, str) and fcontent.strip(): + log.warning( + "post-detail: sparse fieldset gave null but FULL fetch has %d chars " + "(post %s, post_type=%s) — using full body", + len(fcontent), post_id, ptype, + ) + return fcontent log.info( - "post-detail: empty/null content (post %s) — body unavailable " - "(tier-gated, or post has no text)", post_id, + "post-detail: empty/null content (post %s, post_type=%s) even on full " + "fetch — body not in the post resource", post_id, ptype, ) return None diff --git a/tests/test_patreon_client.py b/tests/test_patreon_client.py index dd40aa0..095d218 100644 --- a/tests/test_patreon_client.py +++ b/tests/test_patreon_client.py @@ -380,6 +380,28 @@ def test_fetch_post_detail_content_empty_body_is_none(monkeypatch): assert client.fetch_post_detail_content("42") is None +def test_fetch_post_detail_content_falls_back_to_full_fetch(monkeypatch): + """#842: when the sparse fields[post]=content request returns null (Patreon + does this for polls / embeds / body-only posts), a one-time FULL re-fetch + recovers the body — so inline-only / no-media posts still get their text.""" + client = PatreonClient(cookies_path=None) + seen_params: list = [] + + def _get(url, params=None, timeout=None): + seen_params.append(params or {}) + if "fields[post]" in (params or {}): + return _FakeResp(200, json_data={"data": {"attributes": {"content": None}}}) + return _FakeResp(200, json_data={ + "data": {"attributes": {"content": "
body via full
", "post_type": "poll"}} + }) + + monkeypatch.setattr(client._session, "get", _get) + assert client.fetch_post_detail_content("42") == "body via full
" + # Sparse request first, then a full re-fetch without the sparse fieldset. + assert "fields[post]" in seen_params[0] + assert "fields[post]" not in seen_params[1] + + def test_fetch_post_detail_content_blank_id_skips_request(monkeypatch): calls: list = [] client = PatreonClient(cookies_path=None)