메뉴
HN
Hacker News 43일 전

AI 서브루틴: 브라우저 내부에서 자동화 스크립트 실행

IMP
8/10
핵심 요약

이 글은 기존 웹 자동화 에이전트의 경제성과 인증(Auth) 문제를 해결하기 위해, 브라우저 확장 프로그램이 웹페이지 내부에서 직접 네트워크 요청을 녹화하고 재생하는 'AI 서브루틴(AI Subroutines)' 아키텍처를 소개합니다. 복잡한 인증 토큰이나 세션을 외부에서 강제로 재구성할 필요 없이 브라우저의 고유 실행 환경을 그대로 활용하여 안정성을 높이는 것이 핵심입니다. 또한 수많은 노이즈 요청 중 실제 의미 있는 API 호출만 추출하기 위해 요청을 평가하고 필터링하는 정교한 점수 기반 랭킹 시스템을 제공합니다.

번역된 본문

AI 서브루틴(AI Subroutines): 페이지 내부에서 실행되는 브라우저 자동화

대부분의 웹 에이전트는 문제의 잘못된 절반만을 해결합니다. LLM(대형 언어 모델)을 이용해 X(트위터)에 게시물을 올리거나, Instagram에서 DM을 보내거나, LinkedIn 연결 요청을 보내는 것은 한 번쯤은 가능합니다. 하지만 이를 1,000번 해야 할 때 경제성은 무너집니다. 호출당 토큰 비용, 호출당 지연 시간, 그리고 호출 시마다 발생하는 비결정성 때문입니다. 아웃리치(영업 및 외부 커뮤니케이션), CRM 업데이트, 대량 게시 작업에서 "이번에 에이전트가 잘못된 버튼을 클릭함"은 단순한 특이치(quirk)가 아닙니다. 그것은 시스템의 치명적인 실패입니다.

가장 명백한 해결책은 UI(사용자 인터페이스)를 건너뛰고 해당 사이트의 내부 API를 직접 호출하는 것입니다. 이 방향은 맞지만, 대부분의 '그냥 API를 호출해'라는 프로젝트가 여기서 좌초합니다. 진짜 어려운 문제는 엔드포인트(API 경로)가 아니라 '인증(Auth)'이기 때문입니다.

인증(Auth)이 진짜 어려운 문제입니다 인증된 웹 요청은 쿠키, 회전하는 CSRF 토큰, 세션 토큰, Bearer 헤더, 재생 방지 논스(nonce), 지문(Fingerprint) 기반 매개변수, 그리고 요청 시점에 사이트 자체의 JS(자바스크립트)에서 계산된 요청 서명 해시 등의 조합을 포함합니다. 일부는 서버에서 설정되고, 일부는 브라우저 내부에서 파생됩니다. 그중 일부는 요청할 때마다 값이 바뀝니다. 프로세스 외부에서 동작하는 스크래퍼(Node 워커, Playwright 워커, 클라우드 함수 등)는 이 모든 것을 외부에서 다시 구축해야만 합니다. 사이트가 헤더를 회전시키거나 새로운 서명 방식을 도입하는 순간 이 방식은 바로 망가집니다. 대부분의 HAR(HTTP Archive) 재생 도구의 수명은 바로 여기서 끝이 납니다.

핵심 트릭: 확장 프로그램에서 녹화하고, 웹페이지 내부에서 재생하기 rtrvr(해당 솔루션)에서는 녹화와 재생 모두 사용자의 브라우저 내부, 즉 웹페이지 자체에서 이루어집니다. 확장 프로그램은 사용자가 작업을 수행하는 동안 브라우저 탭이 만드는 네트워크 요청을 가로챕니다. 이는 두 가지 계층으로 이루어집니다. 첫째, 어떤 페이지 스크립트보다도 먼저 설치되는 메인 월드(Main-world)의 fetch/XHR 패치입니다. 둘째, 페이지 내부 패치가 감지할 수 없는 CORS 및 서비스 워커 경로에 대비하기 위해 Chrome의 webRequest API를 사용한 상관(correlated) 대체 수단입니다. 요청 본문(FormData, Blob, 원시 바이트, 단순 JSON 포함) 역시 캡처됩니다.

이후 스크립트가 실행될 때, 해당 요청들은 페이지 자체의 실행 컨텍스트(동일한 출처, 동일한 쿠키, 동일한 TLS 세션, 서명된 헤더를 계산하는 동일한 JS)에서 전송됩니다. Puppeteer 드라이버도, 헤드리스 워커도, 별도의 TLS 스택도 필요 없습니다. 브라우저는 평소에 하던 대로 쿠키를 첨부하고, 사이트 자체의 JS를 실행하여 헤더를 계산한 다음, 요청을 전송합니다. 인증, CSRF, 서명, 지문(Fingerprinting)이 모두 자연스럽게 전파됩니다. 에이전트는 이러한 복잡한 과정에 전혀 개입하지 않습니다. 키 추출, 세션 재구축, 프록시 회전이 필요 없습니다. 이는 단순한 각주처럼 들릴 수 있지만, 이 솔루션의 전체 아키텍처를 관통하는 핵심 원리입니다.

네트워크 캡처의 순위 매기기 및 필터링(Trimming) "네트워크를 그냥 녹화하라"는 개념 안에는 두 번째 문제가 숨겨져 있습니다. 일반적인 1분간의 브라우징만으로도 탭당 수십에서 수백 개의 요청이 발생합니다. 여기에는 분석 비콘, RUM 핑, 기능 플래그 폴링, 서드파티 픽셀, 사전 가져오기(Prefetch), 미디어 청크, 핫 모듈 리로드(HMR) 요청 등이 포함됩니다. 정작 우리가 신경 쓰는 실제 API 호출은 300개 중 3개뿐일 수 있습니다. 이 모든 걸 LLM에 던져 어떤 것이 필요한 도구인지 파악하게 할 수는 없습니다. 이는 컨텍스트 윈도우(Context Window)에 들어가지도 않으며, 억지로 넣는다 한들 과금이 발생하는 데다 중요한 신호가 노이즈에 묻혀버립니다.

따라서 스크립트 생성기가 데이터를 확인하기 전에, 우리는 캡처된 데이터의 순위를 매기고 필터링합니다. 요청은 소수의 가중치가 적용된 신호를 기반으로 점수를 받습니다.

  • 퍼스트파티(First-party) 대 서드파티(Third-party) 출처 (+20 / −15). Sentry, Segment, Hotjar, RUM 등과 같이 알려진 원격 측정(Telemetry) 호스트는 무조건 −80점을 받습니다. 클릭과 얼마나 잘 상관관계가 있는지는 중요하지 않으며, 이는 우리가 원하는 도구가 아닙니다.
  • DOM 이벤트와의 시간적 상관관계 (800ms 이내 +28, 2.5초 이내 +16). '전송' 버튼을 클릭한 지 40ms 후에 발생한 POST 요청은 거의 확실히 전송 기능일 것입니다.
  • 메서드 및 페이로드 형태 (데이터를 변경하는 POST/PUT/PATCH/DELETE: +35, GET: +5, 요청 본문 포함 시: +8, OPTIONS/HEAD/성능 항목: −40).
  • 응답 품질 (2xx: +12, 4xx 이상: −25, 비어있지 않은 본문: +4).
  • 휘발성 작업 식별자 (−18). URL이나 본문에 GraphQL의 queryId, doc_id, operationHash 또는 빌드별 해시가 포함된 요청들입니다. 이들은 오늘 당장에는 정상적으로 보이지만, 사이트가 다시 배포(redeploy)되는 순간 바로 망가집니다.

구체적인 예시로, 시스템은 퍼스트파티(First-party)이며 데이터를 변경하는...

원문 보기
원문 보기 (영어)
AI Subroutines: Browser Automations That Run Inside the Page Most web agents solve the wrong half of the problem. You can get an LLM to post on X, DM on Instagram, or send a LinkedIn connection request — once. The moment you need to do it a thousand times, the economics break: tokens per invocation, latency per invocation, non-determinism per invocation. On outreach, CRM updates, and bulk posting, "the agent clicked the wrong button this time" is not a quirk. It's a failure mode. The obvious fix is to skip the UI and call the site's internal API directly. That's correct, and it's where most "just call the API" projects die. Because the hard problem isn't the endpoint. It's auth. Auth is the actual hard problem Authenticated web requests carry some combination of cookies, rotating CSRF tokens, session tokens, bearer headers, anti-replay nonces, fingerprint-bound parameters, and request-signing hashes computed in the site's own JS at request time. Some are set by the server. Some are derived in the browser. Some rotate per request. Out-of-process scrapers — Node workers, Playwright workers, cloud functions — have to rebuild all of that out of band. That's the thing that breaks the moment a site rotates a header or ships a new signing scheme. Most HAR-replay tooling ends its useful life right here. The trick: record in the extension, replay inside the webpage In rtrvr, both the recording and the replay happen inside the user's browser, from within the webpage itself. The extension intercepts the network requests the tab makes while you perform the task. Two layers: a MAIN-world fetch / XHR patch installed before any page script runs, with Chrome's webRequest API as a correlated fallback for the CORS and service-worker paths the in-page patch can't see. Request bodies — FormData, Blob, raw bytes, not just JSON — are captured too. When the script runs later, those requests are dispatched from the page's own execution context — same origin, same cookies, same TLS session, same JS that computes the signed headers. No Puppeteer driver. No headless worker. No separate TLS stack. The browser does what it always does: attach the cookies, run the site's own JS to compute the headers, ship the request. Auth, CSRF, signing, and fingerprinting all propagate for free. The agent never touches any of it. No key extraction, no session rebuild, no proxy rotation. This sounds like a footnote. It's the whole architecture. Ranking and trimming the network capture There's a second problem hiding inside "just record the network." A typical minute of browsing fires dozens to hundreds of requests per tab — analytics beacons, RUM pings, feature-flag polls, third-party pixels, prefetches, media chunks, hot-module reload pokes. The actual API call you care about is often 3 requests out of 300. You cannot hand all of that to an LLM to figure out which one is the tool. It does not fit in the context window, and even if you paid to stretch it, the signal drowns in the noise. So before the generator sees anything, we rank and trim the capture. Requests are scored on a handful of weighted signals: First-party vs. third-party origin (+20 / −15). A known telemetry host — Sentry, Segment, Hotjar, RUM, the usual suspects — is a flat −80. It does not matter how well it correlates with a click; it is not the tool. Temporal correlation to the DOM event (+28 within 800ms, +16 within 2.5s). A POST that fires 40ms after you click "Send" is almost certainly the send. Method and payload shape (mutating POST/PUT/PATCH/DELETE: +35; GET: +5; with a request body: +8; OPTIONS/HEAD/perf entries: −40). Response quality (2xx: +12; 4xx+: −25; non-empty body: +4). Volatile operation identifiers (−18). Requests that carry a GraphQL queryId , doc_id , operationHash , or any build-specific hash in the URL or body. They look correct today and break the moment the site redeploys. Concretely: a first-party mutating POST that fires 80ms after a click with a 200 response and a body lands around +83. A generic analytics beacon is −80. Everything in between gets ordered and the top five survive. Those five plus the DOM interactions around them get rendered into a 12 000-character context for the generator; if it overruns, we drop visited URLs first, then network candidates, then DOM hints, and re-render until it fits. Even after ranking, a strong candidate is not automatically replay-worthy. If the top request carries a volatile operation identifier — X's queryId , Meta's doc_id , any GraphQL operation hash pinned to the current deploy — the planner forces a DOM-only tool regardless of score, and the generator is instructed not to surface those values in the first place ( Do NOT expose or discover queryId/doc_id/operationHash values ). This is the single most useful failure case to catch early: network replay looks great in a demo and breaks quietly a week later when the site ships. The docs go deeper on how the DOM / network / hybrid decision is made and the rtrvr.* helper namespace the generated code uses. This is the unglamorous step that makes recording→Subroutine actually work. In-page execution solves auth for free; ranked trimming — with the volatile-ID circuit breaker — is what lets the generator reliably pick the right request to templatize. Subroutines are tool calls, not macros A recorded task — a Subroutine — is registered as a callable tool in the agent's tool set, next to search and fetch : Sheet of Instagram URLs → sendInstagramDirectMessage({ url, message }) Daily content queue → createXPost({ text, mediaUrl }) List of profiles → sendLinkedInConnectionRequest({ url, note }) Point the agent at a sheet of 500 rows. It picks parameters per row. The Subroutine runs. The LLM is invoked exactly once per row — for parameter selection — and the action itself is a script. Zero token cost on the hot path. The replay is a fetch , not an inference. Deterministic. Same input, same output, every time. Low detection surface. Requests come from the same origin, with the same headers, in the same user session that sent the original. LLM-callable from natural language. The agent reaches for a Subroutine the same way it reaches for any other tool, inferring parameters from whatever tab is open. Inside a Subroutine: the rtrvr helpers A Subroutine is a small async JavaScript function that runs in the tab. The parameters the agent passes — the row from the sheet, the target URL, the message body — are injected as const declarations above your code. Inside the body, an rtrvr.* helper namespace covers the common moves you need on real sites without dropping down to brittle selectors or hand-rolled fetch scaffolding: Helper Use rtrvr.find({ role, name, text, placeholder }) Find a semantic page target and return an opaque handle rtrvr.click(handleOrTarget) Click a previously found handle or a semantic target rtrvr.type(handleOrTarget, value, { clear, submit }) Type into inputs or rich contenteditable editors rtrvr.waitFor(targetOrFn, { timeoutMs }) Wait for the next UI state, modal, composer, or control rtrvr.waitForUrl(match, { timeoutMs }) Wait for navigation or route changes rtrvr.request(url, init) Make authenticated in-page requests using the page context rtrvr.requestJson(url, init) Same as request , but parses JSON when available rtrvr.getCsrfToken() Read the current-page CSRF token rtrvr.getCookie(name) Read a cookie from the current page A minimal LinkedIn "connect" Subroutine: const button = await rtrvr.find({ role: "button", name: /Connect/i, }); if (!button) { return { success: false, error: "Connect button not found." }; } await rtrvr.click(button); const csrfToken = rtrvr.getCsrfToken(); return await rtrvr.requestJson("/voyager/api/example", { method: "POST", headers: { "content-type": "application/json", "x-csrf-token": csrfToken, }, body: JSON.stringify({ ok: true }), }); DOM when the UI is the stable contract, rtrvr.re