diff --git a/backend/app/services/subscribestar_client.py b/backend/app/services/subscribestar_client.py
index 7cd7043..6fe7ce5 100644
--- a/backend/app/services/subscribestar_client.py
+++ b/backend/app/services/subscribestar_client.py
@@ -85,6 +85,33 @@ _NEXT_PAGE_RE = re.compile(
# age cookie missing) rather than a real — possibly empty — creator feed.
_LOGIN_MARKERS = ("/session/new", 'data-role="sign_in"', "age_confirmation_warning")
+# An interstitial served INSTEAD of the feed — characterized so a drift error
+# reports the actual cause (bot challenge / age gate / login) rather than a bare
+# "markup changed". (name, substrings to look for, case-insensitive.)
+_INTERSTITIAL_MARKERS = (
+ ("cloudflare/bot-challenge", (
+ "just a moment", "cf-challenge", "challenge-platform", "cf_chl",
+ "attention required", "enable javascript and cookies",
+ )),
+ ("age-gate", (
+ "18 or older", "adult content", "age_confirmation", "i am over",
+ "confirm your age", "must be 18",
+ )),
+ ("login", ("/session/new", 'data-role="sign_in"', "sign in", "log in")),
+ ("captcha", ("g-recaptcha", "hcaptcha", "captcha")),
+)
+
+
+def _describe_page(html: str) -> str:
+ """A short, log-safe description of an unexpected page: its
+ which
+ known interstitial it resembles (bot challenge / age gate / login / captcha)."""
+ m = re.search(r"]*>(.*?)", html, re.IGNORECASE | re.DOTALL)
+ title = unescape(m.group(1).strip())[:120] if m else "(no )"
+ low = html.lower()
+ hits = [name for name, needles in _INTERSTITIAL_MARKERS
+ if any(n.lower() in low for n in needles)]
+ return f"title={title!r}; resembles: {'/'.join(hits) if hits else 'unrecognized'}"
+
class SubscribeStarAPIError(NativeIngestError):
"""Base for native SubscribeStar client failures. status_code / retry_after
@@ -393,16 +420,15 @@ class SubscribeStarClient:
# body canary catches systematic emptiness across a populated feed;
# here we only raise if the feed container itself is missing.
if 'data-role="posts_container-list"' not in html:
- # Surface what we actually got (length + a JSON hint) — the
- # first live run tripped this because an XHR-flagged GET
- # returned a non-HTML body, which is invisible from a bare
- # "markup changed?" message.
+ # Report what we actually got — length + the page's identity —
+ # so a served interstitial (bot challenge / age gate / login)
+ # is named instead of mislabeled "markup changed".
looks_json = html.lstrip()[:1] in ("{", "[")
raise SubscribeStarDriftError(
f"SubscribeStar feed for {slug!r} had no posts and no "
f"recognizable feed container ({len(html)} bytes"
- f"{', looks like JSON' if looks_json else ''}) — "
- "markup changed, or the feed wasn't served as a full page?"
+ f"{', looks like JSON' if looks_json else ''}; "
+ f"{_describe_page(html)})"
)
for post in posts:
post["_base"] = base