All checks were successful
Release / build (push) Successful in 1m32s
Log navigation, polling state, and response snippets so we can diagnose whether the fallback fails due to Cloudflare challenge, login redirect, profile lock, or other issues. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
134 lines
4.2 KiB
Go
134 lines
4.2 KiB
Go
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 the persistent browser
|
|
// profile (which has Cloudflare clearance cookies) and returns the response
|
|
// body. Uses non-headless mode with a minimized/hidden window to avoid
|
|
// Cloudflare's headless detection, which causes infinite challenge loops.
|
|
func FetchViaChrome(url string) ([]byte, error) {
|
|
profileDir := filepath.Join(config.ConfigDir(), "browser-profile")
|
|
if err := os.MkdirAll(profileDir, 0o755); err != nil {
|
|
return nil, fmt.Errorf("create browser profile dir: %w", err)
|
|
}
|
|
|
|
// Remove stale lock file from unclean shutdown
|
|
_ = os.Remove(filepath.Join(profileDir, "SingletonLock"))
|
|
|
|
execPath := findBrowserExec()
|
|
|
|
// Use non-headless mode: Cloudflare detects headless Chrome and loops
|
|
// the JS challenge forever. A real (but hidden) browser window passes.
|
|
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
|
chromedp.Flag("headless", false),
|
|
chromedp.Flag("window-position", "-32000,-32000"), // off-screen
|
|
chromedp.Flag("window-size", "1,1"),
|
|
chromedp.Flag("disable-gpu", true),
|
|
chromedp.Flag("no-first-run", true),
|
|
chromedp.Flag("disable-extensions", true),
|
|
chromedp.UserDataDir(profileDir),
|
|
)
|
|
if execPath != "" {
|
|
opts = append(opts, chromedp.ExecPath(execPath))
|
|
}
|
|
|
|
allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
|
defer allocCancel()
|
|
|
|
ctx, cancel := chromedp.NewContext(allocCtx)
|
|
defer cancel()
|
|
|
|
// Total timeout for the operation
|
|
ctx, timeoutCancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer timeoutCancel()
|
|
|
|
// Navigate and wait for Cloudflare challenge to resolve.
|
|
log.Printf("chrome-fetch: navigating to %s (profile: %s)", url, profileDir)
|
|
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 to clear
|
|
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
|
|
// Try <pre> first (Chrome wraps JSON in <pre> tags)
|
|
err := chromedp.Run(ctx,
|
|
chromedp.Text("pre", &body, chromedp.ByQuery),
|
|
)
|
|
if err != nil || body == "" {
|
|
// Fallback: try body directly
|
|
_ = chromedp.Run(ctx,
|
|
chromedp.Text("body", &body, chromedp.ByQuery),
|
|
)
|
|
}
|
|
body = strings.TrimSpace(body)
|
|
if body == "" {
|
|
log.Printf("chrome-fetch: page body empty, waiting...")
|
|
continue
|
|
}
|
|
// Check if we got actual JSON (starts with [ or {), not a challenge page
|
|
if body[0] == '[' || body[0] == '{' {
|
|
log.Printf("chrome-fetch: got JSON response (%d bytes)", len(body))
|
|
// Also extract any fresh cookies for future plain HTTP attempts
|
|
_ = extractAndSaveCookies(ctx)
|
|
cancel() // graceful close, flushes cookies to profile
|
|
return []byte(body), nil
|
|
}
|
|
// Log a snippet of what we got (challenge page, login redirect, etc.)
|
|
snippet := body
|
|
if len(snippet) > 200 {
|
|
snippet = snippet[:200]
|
|
}
|
|
log.Printf("chrome-fetch: non-JSON body (%d bytes): %s", len(body), snippet)
|
|
}
|
|
}
|
|
}
|
|
|
|
// extractAndSaveCookies saves cf_clearance and other Cloudflare cookies
|
|
// alongside the session key, so plain HTTP requests can try them next time.
|
|
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
|
|
}
|
|
|
|
// Write Cloudflare cookies to a file the fetcher can read
|
|
cfPath := filepath.Join(config.ConfigDir(), "cf-cookies")
|
|
return os.WriteFile(cfPath, []byte(strings.Join(parts, "\n")+"\n"), 0o600)
|
|
}
|