Skip to content

fix(browser): extract recipes from pages behind a bot check - #521

Open
plusmobileapps wants to merge 1 commit into
mainfrom
claude/recipe-extraction-failure-408359
Open

fix(browser): extract recipes from pages behind a bot check#521
plusmobileapps wants to merge 1 commit into
mainfrom
claude/recipe-extraction-failure-408359

Conversation

@plusmobileapps

Copy link
Copy Markdown
Collaborator

Fixes #105.

Serious Eats has been failing to extract for a while, for two separate reasons that each independently sink it. Both are fixed here.

1. The fetch never gets the page

seriouseats.com sits behind a Cloudflare managed challenge. Every request comes back 403 with cf-mitigated: challenge, regardless of what it claims to be:

Request Result
Our current mobile-Safari UA 403
Full desktop Chrome header set (sec-ch-ua, Sec-Fetch-*, …) 403
Googlebot 460

No HTTP client gets past it — only a real browser does. And the user is already looking at the rendered page in the in-app WebView, past the challenge.

So extraction now reads the rendered WebView DOM and parses that, falling back to the HTTP fetch when the WebView can't answer. PlatformWebView gained a captureHtmlTrigger / onHtmlCaptured pair implemented on all three platforms — Android and iOS via WebViewNavigator.evaluateJavaScript, JVM via the JavaFX engine's executeScript.

The capture script clones the document before pruning it, so the live page is untouched, and strips the tags the parser never reads (script other than ld+json, style, svg, iframe, …). That isn't just tidiness — most of a modern recipe page's weight is ad and analytics script, and the result has to cross the platform's JS bridge in one piece.

2. The JSON-LD parser threw on the page's own markup

Even with the HTML in hand, extraction failed. Serious Eats publishes a range for its cook and total times, and schema.org models a range as a nested object rather than a string:

"cookTime": {"@type":"Duration","minValue":"PT90M","maxValue":"PT240M"}

Reading that through jsonPrimitive throws. Because the whole parse runs inside a runCatching, the throw was swallowed and the page was reported as having no recipe at all — even though its title, ingredients and directions were perfectly readable. Confirmed against the live markup: Element class JsonObject is not a JsonPrimitive.

Every schema.org field now goes through a stringOrNull() that returns null for anything that isn't a primitive, so one oddly-shaped field can no longer fail the whole recipe. A Duration object resolves to its lower bound — the same end of a range firstNumber() already takes for a "6 to 8" yield.

The same audit picked up three other shapes real sites publish, each now covered by a test:

  • a headline where name is absent
  • recipeIngredient as a single string rather than a list
  • instruction objects carrying their step text without a HowToStep type

Verification

Ran the full extractor over the real Serious Eats page:

TITLE=Sous Vide Burgers
SERVINGS=4 PREP=10 COOK=90 TOTAL=100
CALORIES=624
INGREDIENTS(6) DIRECTIONS(5)

134 tests pass in :client:browser:impl; Android, iOS and JVM targets all compile.

Reviewer notes

  • The two changes are one fix — neither works alone. Without the DOM capture you never see the HTML; without the parser fix the HTML you finally have still fails.
  • When_extract_recipe_with_blank_url_Then_does_nothing was vacuous and I replaced it. currentUrl defaults to https://www.google.com, so the blank-url guard it named was never reached — the test only passed because the unstubbed mock threw. It's now a real test of the in-flight guard (a second tap must not start a second capture).
  • Timeout, not a hang. The view model waits at most 5s for the WebView to answer, so a WebView that can't run the script falls through to the HTTP path instead of leaving the user on a spinner.
  • BrowserViewModelTest now shares a TestCoroutineScheduler with runTest(scheduler) so that timeout runs on virtual time rather than stalling the suite for real seconds.
  • Android vs iOS return shapes differ: Android hands back the script result JSON-encoded, iOS and JavaFX hand back the raw string. decodeCapturedHtml normalises both and treats Android's "null" as nothing captured.

Not fixed here: #513

#513 (ostarecipes.com) is a different failure and is not addressed. It's a Firebase-backed React SPA: the served HTML is an empty <div id="root">, and even after JS runs there is zero structured markup — no JSON-LD, no microdata, just styled divs. DOM capture brings the content into reach but the deterministic parsers have nothing to bind to, so it needs an AI fallback over the page text. Deliberately deferred.

🤖 Generated with Claude Code

Serious Eats has failed to extract for a while (#105), for two separate
reasons that each independently sink it.

First, the fetch never gets the page. seriouseats.com sits behind a
Cloudflare managed challenge — every request answers 403 with
`cf-mitigated: challenge`, whether it claims to be mobile Safari, a full
desktop Chrome header set, or Googlebot. No HTTP client passes it; only
a browser does. Since the user is already looking at the rendered page
in the in-app WebView, ask the WebView for its markup and parse that,
falling back to fetching the URL when it can't answer. `PlatformWebView`
gains a capture trigger on all three platforms (Android and iOS via
`evaluateJavaScript`, JVM via the JavaFX engine), and the view model
gives up waiting after five seconds so a WebView that can't run the
script doesn't strand the user on a spinner.

Second, the JSON-LD parser threw on the page's own markup. Serious Eats
publishes a range for its cook and total times, which schema.org models
as a nested object rather than a string:

    "cookTime": {"@type":"Duration","minValue":"PT90M","maxValue":"PT240M"}

Reading that through `jsonPrimitive` throws, and since the whole parse
runs inside a `runCatching`, the throw was swallowed and the page was
reported as having no recipe — even though its title, ingredients and
directions were all perfectly readable. Every schema.org field now goes
through a `stringOrNull()` that returns null for anything that isn't a
primitive, so one oddly-shaped field can't fail the whole recipe, and a
`Duration` object resolves to its lower bound — the same end of a range
`firstNumber()` already takes for a "6 to 8" yield. The same audit
picked up three other shapes real sites publish: a `headline` with no
`name`, `recipeIngredient` as a single string, and instruction objects
carrying step text without a `HowToStep` type.

Verified end to end against the real page: title, 6 ingredients, 5
directions, servings 4, prep 10 / cook 90 / total 100 min, 624 cal.

`When_extract_recipe_with_blank_url_Then_does_nothing` was vacuous —
`currentUrl` defaults to google.com so the blank-url guard was never
reached, and it only passed because the unstubbed mock threw. Replaced
with a real test of the in-flight guard.

Fixes #105.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

failed to extract recipe

1 participant