Skip to content

Fix: JSON based scrapers fail with Chrome CDP - #7137

Open
wraithfive wants to merge 5 commits into
stashapp:developfrom
wraithfive:fix/cdp-json-scraper-response
Open

Fix: JSON based scrapers fail with Chrome CDP#7137
wraithfive wants to merge 5 commits into
stashapp:developfrom
wraithfive:fix/cdp-json-scraper-response

Conversation

@wraithfive

@wraithfive wraithfive commented Aug 2, 2026

Copy link
Copy Markdown

Description

When a useCDP scraper navigates directly to a URL that Chrome identifies as JSON content, Chrome's own built-in JSON viewer wraps the raw JSON in an HTML pretty-printer document before it ever reaches the page DOM. urlFromCDP always extracted content via chromedp.OuterHTML, so scrapeJson-type scrapers using useCDP would receive this HTML wrapper instead of the JSON itself and fail with "not valid json".

Related Issue

Closes #7136

Testing

  • go build, go vet, and go test ./pkg/scraper/... all pass, including
    new unit tests for the MIME-type decision logic (url_test.go)
  • Built a full patched Stash image from source and ran it as a fully isolated instance (separate database,
    separate config, pointed at the same headless Chrome CDP sidecar) to verify against the real R18.dev scraper:
  • Before the fix: not valid json
  • After the fix: correctly returns real scene data (title, date, etc.)
  • Regression-checked an HTML-based useCDP scraper (JavLibrary) and a non-CDP scraper (JavBus) to confirm unaffected behavior

To verify this fix

  1. Configure a reachable CDP endpoint under Settings → Metadata Providers (Chrome CDP Path), pointing at any Chrome/chromedp instance with --remote-debugging-port exposed.
  2. To test the exact scraper I used, add driver: { useCDP: true } to R18.dev.yml. Any scraper using action: scrapeJson with driver: { useCDP: true } should work.
  3. Trigger a scrape against a real JSON endpoint via the scene Edit tab "Scrape with..." or in the GraphQL playground with
query {
  scrapeSingleScene(
    source: { scraper_id: "R18.dev" }
    input: { query: "SIMM-812" }
  ) {
    title
    date
    details
  }
}

Before this fix: fails with not valid json regardless of the endpoint actually returning valid JSON. After: succeeds normally.

Checklist

  • I have read and understood the Contributing document.
  • I have read and understood the AI Usage Policy document.
  • I have made corresponding changes to the documentation (if applicable).

AI Usage Disclosure

  • I have used AI tools to assist with this pull request, and I have disclosed the tools and how I used them below.

The root cause was diagnosed and this fix was drafted with Claude Sonnet 5.

Additional Context

  1. if Network.getResponseBody fails (e.g., the response got evicted from Chrome's cache before the fetch), the fix falls back to the original OuterHTML behavior rather than erroring out.

…r JSON content types

When a useCDP scraper navigates directly to a URL that Chrome identifies
as JSON content, Chrome's own JSON viewer wraps the raw JSON in an HTML
pretty-printer document before it reaches the page DOM. urlFromCDP always
extracted content via OuterHTML, so scrapeJson-type scrapers using useCDP
would receive this HTML wrapper instead of the JSON itself and fail with
"not valid json".

Detect when the main document response's MIME type is JSON and, in that
case, pull the raw body via Network.getResponseBody instead of reading
the rendered DOM. Non-JSON responses are unaffected and continue to use
OuterHTML as before.

@Gykes Gykes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quick static review.

Comment thread pkg/scraper/url.go Outdated
Comment on lines +262 to +266
var jsonRequestID network.RequestID
var isJSONDocument bool
chromedp.ListenTarget(ctx, func(ev interface{}) {
if ev, ok := ev.(*network.EventResponseReceived); ok {
if ev.Type == network.ResourceTypeDocument && !isJSONDocument {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are written on chromedps event processing georoute but read from chromedp.Run georoute and they're not synced. I think this could create a race.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch

Comment thread pkg/scraper/url.go Outdated
Comment on lines +266 to +267
if ev.Type == network.ResourceTypeDocument && !isJSONDocument {
if isJSONMimeType(ev.Response.MimeType) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These 2 conditions can be be condensed.

Comment thread pkg/scraper/url.go Outdated
if err != nil {
// fall back to OuterHTML if the response body is no
// longer available (e.g. evicted from Chrome's cache)
logger.Debugf("[scraper] could not get raw response body for JSON document, falling back to OuterHTML: %v", err)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would making this a warning rather than a debug be better so people can see it in the logs easier?

@Gykes Gykes added this to the Version 0.32.0 milestone Aug 2, 2026
@Gykes Gykes added the bug Fix for a reproduced bug label Aug 2, 2026
@Gykes

Gykes commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Meant to request changes instead of approve, sorry

…allback

- Guard jsonDoc's requestID/isJSON fields with a mutex — they were written
  from chromedp's event-processing goroutine and read from the action
  sequence without synchronization.
- Condense the nested resource-type/mime-type checks into a single condition.
- Log the OuterHTML fallback (when the CDP response body is no longer
  available) at Warn instead of Debug so it's visible without verbose logging.
Every other hand-written mutex in this codebase uses a named field
(e.g. mutex sync.Mutex) rather than an embedded one, so switch to
match rather than promoting Lock/Unlock onto jsonDoc itself.
Extract the racy state (requestID/isJSON) out of urlFromCDP into a small
named type, jsonDocumentTracker, with markJSON/get methods guarding access
with its mutex. This makes the concurrency behavior independently testable
without spinning up chromedp/a real browser.

TestJSONDocumentTrackerConcurrentAccess exercises markJSON and get from
separate goroutines concurrently and repeatedly, matching how urlFromCDP
actually uses it (one goroutine from chromedp's event-processing loop,
one from the action sequence passed to chromedp.Run). Verified this test
reliably fails under `go test -race` if the mutex is removed, and passes
cleanly with it in place.

Also adds TestJSONDocumentTrackerMarksOnlyFirstJSONMatch to cover the
"only track the first JSON document" behavior directly.
network.ResourceTypeDocument fires for iframe navigations too, not just
the top-level one. The tracker previously latched onto the first Document
response that happened to be JSON, so an HTML scraper whose page embeds an
iframe that loads JSON (an embed widget, an ad frame) could have its
extraction hijacked by that iframe's response instead of the page's own
HTML - a regression risk for the population this fix was never meant to
touch.

Since redirects don't produce a separate responseReceived event for the
pre-redirect URL (that arrives via requestWillBeSent's redirectResponse on
the same request instead), the first Document response chromedp sees
reliably corresponds to the top-level navigation. So rather than plumbing
through frame IDs, jsonDocumentTracker now records the *first* Document
response unconditionally - JSON or not - and ignores every one after it.

Renamed markJSON/get to markDocument/mainDocument to match the new
semantics, and reworded the OuterHTML-fallback log line to say the scrape
will likely still fail as a result, so it doesn't read as an unrelated
warning next to the downstream "not valid json" error.

TestJSONDocumentTrackerOnlyRecordsFirstDocument replaces the old
first-JSON-match test with two cases, including the exact iframe
scenario above; verified it fails under the previous logic and passes
with this one.
@wraithfive

Copy link
Copy Markdown
Author

Pushed a fix for all three in 253f27e. Then thought to do what I probably should have done in the first place and checked the style of other mutex's in the project and extracted it to a named field.

After that I wanted to make sure a test would actually catch this kind of race if it ever came back. The tracked state was living in an anonymous struct scoped inside urlFromCDP itself so I pulled it out into its own small type first (jsonDocumentTracker) so it could be tested without needing chromedp at all. Then added a test that hammers its write and read methods concurrently — confirmed it fails without the mutex and passes with it.

A review by Fable pointed out that Network.responseReceived fires for iframe navigations too, not just the top-level one, so the tracker could in theory latch onto an iframe's JSON response and hijack extraction for an unrelated HTML scraper. Fixed that by only ever tracking the first Document response instead of the first JSON one, and added a test covering it.

@wraithfive
wraithfive requested a review from Gykes August 2, 2026 20:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Fix for a reproduced bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JSON based scrapers fail with Chrome CDP

2 participants