// Copyright (c) 2026 Probo Inc . // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. package browser import ( "context" "fmt" "strings" "github.com/chromedp/chromedp" "go.probo.inc/probo/pkg/agent" ) // waitForPage returns chromedp actions that wait for the page to fully load, // including SPA content rendered by JavaScript. It first waits for the body to // be ready, then polls until the page content stabilizes (innerText stops // changing) with a short debounce. After stabilization, it attempts to dismiss // common cookie consent banners so they don't interfere with content // extraction. func waitForPage() chromedp.Action { return chromedp.ActionFunc(func(ctx context.Context) error { if err := chromedp.WaitReady("body").Do(ctx); err != nil { return err } // Wait for SPA content to stabilize by checking if innerText // length stops changing over a 500ms window. Gives up after 5s. // EvaluateAsDevTools is required to await the Promise. if err := chromedp.EvaluateAsDevTools(` new Promise((resolve) => { let lastLen = -1; let stableCount = 0; const interval = setInterval(() => { const curLen = document.body.innerText.length; if (curLen === lastLen && curLen > 0) { stableCount++; } else { stableCount = 0; } lastLen = curLen; if (stableCount >= 2) { clearInterval(interval); resolve(true); } }, 250); setTimeout(() => { clearInterval(interval); resolve(true); }, 5000); }) `, nil).Do(ctx); err != nil { return err } // Dismiss common cookie consent banners. This is best-effort; // failures are silently ignored because not every page has a // banner and the selectors may not match. return chromedp.Evaluate(` (() => { const selectors = [ "#onetrust-accept-btn-handler", "#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll", "#CybotCookiebotDialogBodyButtonAccept", ".cky-btn-accept", "[data-testid='cookie-policy-dialog-accept-button']", "button.accept-cookies", "#cookie-accept", "#accept-cookies", ".cc-accept", ".cc-btn.cc-dismiss", ]; for (const sel of selectors) { const btn = document.querySelector(sel); if (btn) { btn.click(); return; } } const buttons = document.querySelectorAll( "button, a[role='button'], [role='button']" ); const patterns = /^(accept all|accept|agree|i agree|allow all|allow|got it|ok|okay|consent)$/i; for (const btn of buttons) { if (patterns.test(btn.innerText.trim())) { btn.click(); return; } } })() `, nil).Do(ctx) }) } // checkPDF returns an error tool result if the URL points to a PDF file, // which cannot be rendered by the headless browser. func checkPDF(rawURL string) *agent.ToolResult { if strings.HasSuffix(strings.ToLower(rawURL), ".pdf") { return &agent.ToolResult{ Content: fmt.Sprintf("cannot load %s: PDF files are not supported by the browser", rawURL), IsError: true, } } return nil } func withToolTimeout(ctx context.Context) (context.Context, context.CancelFunc) { return context.WithTimeout(ctx, defaultToolTimeout) }