fix(browser): extract recipes from pages behind a bot check - #521
Open
plusmobileapps wants to merge 1 commit into
Open
fix(browser): extract recipes from pages behind a bot check#521plusmobileapps wants to merge 1 commit into
plusmobileapps wants to merge 1 commit into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.comsits behind a Cloudflare managed challenge. Every request comes back403withcf-mitigated: challenge, regardless of what it claims to be:sec-ch-ua,Sec-Fetch-*, …)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.
PlatformWebViewgained acaptureHtmlTrigger/onHtmlCapturedpair implemented on all three platforms — Android and iOS viaWebViewNavigator.evaluateJavaScript, JVM via the JavaFX engine'sexecuteScript.The capture script clones the document before pruning it, so the live page is untouched, and strips the tags the parser never reads (
scriptother thanld+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:
Reading that through
jsonPrimitivethrows. Because the whole parse runs inside arunCatching, 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. ADurationobject resolves to its lower bound — the same end of a rangefirstNumber()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:
headlinewherenameis absentrecipeIngredientas a single string rather than a listHowToSteptypeVerification
Ran the full extractor over the real Serious Eats page:
134 tests pass in
:client:browser:impl; Android, iOS and JVM targets all compile.Reviewer notes
When_extract_recipe_with_blank_url_Then_does_nothingwas vacuous and I replaced it.currentUrldefaults tohttps://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).BrowserViewModelTestnow shares aTestCoroutineSchedulerwithrunTest(scheduler)so that timeout runs on virtual time rather than stalling the suite for real seconds.decodeCapturedHtmlnormalises 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