feat: low-level interaction endpoints (mouse-wheel, init-script, capture-network, capture-requests)#4210
Open
nayrosk wants to merge 3 commits into
Open
feat: low-level interaction endpoints (mouse-wheel, init-script, capture-network, capture-requests)#4210nayrosk wants to merge 3 commits into
nayrosk wants to merge 3 commits into
Conversation
Some sites (notably Instagram DMs) virtualise nested scrollable containers and ignore both programmatic scrollTop and dispatched WheelEvents -- only a real OS-level wheel event at the container's coordinates triggers their lazy load. The existing /scroll endpoint dispatches at the page level via mouse.wheel without prior cursor positioning, which is too coarse for this case. This adds POST /tabs/:tabId/mouse-wheel with three coordinate modes: - ref: element ref resolved to its bounding-box centre - x, y: explicit page coordinates - default: viewport centre Mirrors the conventions of /scroll and /click (no auth middleware, withTabLock, refToLocator + refreshTabRefs fallback, StaleRefsError, pluginEvents.emit) and ships with the matching @openapi annotation so it appears in /docs and /openapi.json.
…ption
Modern SPA frameworks (React/Next/etc.) often capture `fetch` and
`XMLHttpRequest` references at bundle init time, before any user
script can run. In-page monkey-patches via /evaluate are bypassed
because the bundle holds its own references. Even page.addInitScript
hooks installed before the document loads can be defeated by
Service Workers that intercept fetches before they reach the page.
Two endpoints address these gaps:
POST /tabs/:tabId/init-script
Wraps Playwright's page.addInitScript(). The script is evaluated
before any other script on every navigation in the tab's page
context. Useful for hooks that must beat first-byte JS.
POST /tabs/:tabId/capture-network
Wraps page.on("response") for a bounded duration, returning every
response whose URL matches a regex (default /graphql/i). Operates
at the browser network layer, above the Service Worker and above
any in-page JS, so it cannot be bypassed by either. Bodies are
capped per-capture and per-count to keep responses manageable.
Both endpoints follow the existing conventions:
- authMiddleware (sensitive: arbitrary script / response bodies)
- withTabLock for the navigate-equivalent operations
- emit tab:init-script / tab:capture-network plugin events
- standard error handling via handleRouteError
…capture
Mirrors /capture-network but uses page.on("request") instead of
page.on("response"), exposing the request URL, method, POST body, and
headers. Useful when a page bundle captures fetch/XHR references in a
closure before any user JS runs (e.g. Instagram), bypassing window-level
hooks installed via evaluate.
Body params identical to /capture-network plus an includeHeaders flag.
Co-Authored-By: Claude Opus 4.7 (1M context) <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.
Summary
Four new endpoints that fill gaps in the existing interaction surface, all needed in practice to drive modern anti-automation web apps (e.g. Instagram DMs):
POST /tabs/:tabId/mouse-wheel— realpage.mouse.wheel()dispatched at a specific element or coordinatePOST /tabs/:tabId/init-script— wrapspage.addInitScript()so a hook runs on every navigation before any page scriptPOST /tabs/:tabId/capture-network— wrapspage.on("response")for a bounded duration, captures matching response bodies at the browser network layer (above the Service Worker, above any in-page closure)POST /tabs/:tabId/capture-requests— wrapspage.on("request")for the symmetric request side, exposing URL + method + POST body + headers (essential when the page bundle hides outgoing payloads behind closure-cached primitives)Each addresses a distinct interception level. Together they cover the full stack: DOM events → page lifecycle → network (both directions).
1.
/tabs/:tabId/mouse-wheelThe existing
/scrollendpoint callsmouse.wheel()without prior cursor positioning, so it targets wherever the cursor happens to be — too coarse for nested scrollable containers. Some sites (notably Instagram DMs) virtualise their message lists and ignore both programmaticscrollTopand JS-dispatchedWheelEvents; only a real wheel at the inner container's coordinates triggers their lazy load.Three coordinate modes (priority):
ref>(x, y)> viewport centre./click—refToLocator, falls back torefreshTabRefswithpre_wheelreason, throwsStaleRefsErrorif still unresolvablewithTabLocktab:mouse-wheel2.
/tabs/:tabId/init-script(authMiddleware)Wraps
page.addInitScript({ content: script }). The script is evaluated in the page world before any other script on every navigation in the tab. Useful for hooks that must beat first-byte JS (e.g. install afetchwrapper before the bundle imports it).3.
/tabs/:tabId/capture-network(authMiddleware)Attaches
page.on("response")fordurationMs, then detaches and returns every matching response. Operates at the browser network layer, so it captures:window.fetchreferences the page bundle cached before any in-page hook had a chanceIn practice this is the only reliable way to observe outgoing API traffic of SPAs that aggressively cache primitives at bundle init time.
4.
/tabs/:tabId/capture-requests(authMiddleware)Symmetric counterpart to
/capture-network:page.on("request")instead ofpage.on("response"). Returns POST body + headers for each matching request, captured at the browser network layer.Motivation:
/capture-networkreveals what data the page receives, but is silent on what the page sends. Without the outgoing payload (CSRF tokens, doc IDs, pagination cursors, signed query params), it's impossible to replay or extend a captured GraphQL operation from outside the page. Hook-based approaches (window.fetchoverride,XMLHttpRequest.prototype.sendoverride) fail against bundles that cache the original references at module init — verified empirically against Instagram's web client. Only a Playwright-level listener catches every outbound request.headersreturns the resolved request headers includingcookie,x-csrftoken,x-fb-lsd,x-fb-friendly-name, etc. SetincludeHeaders: falseto omit them/capture-network(same handler shape, same lifecycle, same cleanup) for review symmetryVerification
All four endpoints exercised on an Instagram DM thread that:
/scroll's page-level wheel)fetchreference captured at module init (defeats any in-page hook on eitherwindow.fetchorXMLHttpRequest.prototype)/api/graphql(defeats most network observers)Results:
/mouse-wheelat the container's centre coords withdeltaY=-1500:scrollHeightgrew from 1230 → 5382 in 8 batches (lazy load triggered)/init-scriptinstalled afetchwrapper that captured a manualfetch("/api/graphql")call (verified hook installation), but missed all 35 of the page's own GraphQL fetches (confirms the closure-cache problem)/capture-networkfor 25 s while navigating to the thread: captured all 35 GraphQL responses (827 KB), including the one carrying the message list/capture-requestsfor 15 s while refreshing the thread: captured 36 GraphQL requests with full POST bodies + headers, including oneIGDMessageListOffMsysQuerywith itsdoc_id,fb_dtsg,lsdand paginationvariables. The captured body was then used as a template — replacing only thevariables.aftercursor — to drive a full 14-batch pagination loop through the same/api/graphqlendpoint, yielding 286 unique messages (the complete thread history). With only/capture-network, the same extraction would have stopped at the first 20 messages.Also verified:
node --check server.jspassesuserIdtabId