package browser import ( "context" "fmt" "log" "os" "path/filepath" "strings" "time" "github.com/chromedp/cdproto/network" "github.com/chromedp/chromedp" "git.davoryn.de/calic/claude-statusline/internal/config" ) // FetchViaChrome navigates to a URL using Chrome with a dedicated browser // profile and returns the response body. Uses stealth flags to avoid // Cloudflare's automation detection (navigator.webdriver, etc.). func FetchViaChrome(url string) ([]byte, error) { // Use a dedicated fetch profile separate from the login profile. profileDir := filepath.Join(config.ConfigDir(), "fetch-profile") if err := os.MkdirAll(profileDir, 0o755); err != nil { return nil, fmt.Errorf("create fetch profile dir: %w", err) } _ = os.Remove(filepath.Join(profileDir, "SingletonLock")) execPath := findBrowserExec() // Start with minimal options — NOT DefaultExecAllocatorOptions, which // includes flags that Cloudflare detects (--enable-automation, etc.). opts := []chromedp.ExecAllocatorOption{ chromedp.NoFirstRun, chromedp.NoDefaultBrowserCheck, chromedp.UserDataDir(profileDir), // Stealth: disable automation indicators chromedp.Flag("disable-blink-features", "AutomationControlled"), chromedp.Flag("enable-automation", false), chromedp.Flag("disable-infobars", true), // Window: off-screen so it doesn't flash chromedp.Flag("window-position", "-32000,-32000"), chromedp.Flag("window-size", "1,1"), chromedp.Flag("disable-gpu", true), chromedp.Flag("disable-extensions", true), chromedp.Flag("no-first-run", true), // Use non-headless — Cloudflare detects headless mode chromedp.Flag("headless", false), } if execPath != "" { opts = append(opts, chromedp.ExecPath(execPath)) } allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...) defer allocCancel() ctx, cancel := chromedp.NewContext(allocCtx) defer cancel() ctx, timeoutCancel := context.WithTimeout(ctx, 30*time.Second) defer timeoutCancel() // Inject the session key cookie before navigating sessionKey := config.GetSessionKey() if sessionKey != "" { err := chromedp.Run(ctx, chromedp.Navigate("about:blank"), setCookieAction("sessionKey", sessionKey, ".claude.ai"), ) if err != nil { log.Printf("chrome-fetch: cookie injection failed: %v", err) } } // Patch navigator.webdriver to false via CDP err := chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error { _, _, _, err := chromedp.Targets(ctx) _ = err return nil }), chromedp.Evaluate(`Object.defineProperty(navigator, 'webdriver', {get: () => undefined})`, nil), ) if err != nil { log.Printf("chrome-fetch: webdriver patch failed: %v", err) } log.Printf("chrome-fetch: navigating to %s (exec: %q)", url, execPath) if err := chromedp.Run(ctx, chromedp.Navigate(url)); err != nil { log.Printf("chrome-fetch: navigate failed: %v", err) return nil, fmt.Errorf("chromedp navigate: %w", err) } log.Printf("chrome-fetch: navigation complete, polling for JSON...") // Poll for JSON response — Cloudflare challenge takes a few seconds ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for { select { case <-ctx.Done(): return nil, fmt.Errorf("chromedp fetch timed out waiting for JSON response") case <-ticker.C: var body string err := chromedp.Run(ctx, chromedp.Text("pre", &body, chromedp.ByQuery), ) if err != nil || body == "" { _ = chromedp.Run(ctx, chromedp.Text("body", &body, chromedp.ByQuery), ) } body = strings.TrimSpace(body) if body == "" { log.Printf("chrome-fetch: page body empty, waiting...") continue } if body[0] == '[' || body[0] == '{' { log.Printf("chrome-fetch: got JSON response (%d bytes)", len(body)) _ = extractAndSaveCookies(ctx) cancel() return []byte(body), nil } snippet := body if len(snippet) > 200 { snippet = snippet[:200] } log.Printf("chrome-fetch: non-JSON body (%d bytes): %s", len(body), snippet) } } } // setCookieAction sets a cookie via the DevTools protocol. func setCookieAction(name, value, domain string) chromedp.Action { return chromedp.ActionFunc(func(ctx context.Context) error { expr := network.SetCookie(name, value). WithDomain(domain). WithPath("/"). WithHTTPOnly(true). WithSecure(true) return expr.Do(ctx) }) } // extractAndSaveCookies saves cf_clearance and other Cloudflare cookies // so plain HTTP requests can try them on subsequent polls. func extractAndSaveCookies(ctx context.Context) error { cookies, err := network.GetCookies().Do(ctx) if err != nil { return err } var parts []string for _, c := range cookies { if c.Domain == ".claude.ai" || c.Domain == "claude.ai" { if c.Name == "cf_clearance" || c.Name == "__cf_bm" || c.Name == "_cfuvid" { parts = append(parts, c.Name+"="+c.Value) } } } if len(parts) == 0 { return nil } cfPath := filepath.Join(config.ConfigDir(), "cf-cookies") return os.WriteFile(cfPath, []byte(strings.Join(parts, "\n")+"\n"), 0o600) }