|
|
|
@@ -3,6 +3,7 @@ package browser
|
|
|
|
import (
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"context"
|
|
|
|
"fmt"
|
|
|
|
"fmt"
|
|
|
|
|
|
|
|
"log"
|
|
|
|
"os"
|
|
|
|
"os"
|
|
|
|
"path/filepath"
|
|
|
|
"path/filepath"
|
|
|
|
"strings"
|
|
|
|
"strings"
|
|
|
|
@@ -14,32 +15,42 @@ import (
|
|
|
|
"git.davoryn.de/calic/claude-statusline/internal/config"
|
|
|
|
"git.davoryn.de/calic/claude-statusline/internal/config"
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// FetchViaChrome navigates to a URL using Chrome with the persistent browser
|
|
|
|
// FetchViaChrome navigates to a URL using Chrome with a dedicated browser
|
|
|
|
// profile (which has Cloudflare clearance cookies) and returns the response
|
|
|
|
// profile and returns the response body. Uses stealth flags to avoid
|
|
|
|
// body. Uses non-headless mode with a minimized/hidden window to avoid
|
|
|
|
// Cloudflare's automation detection (navigator.webdriver, etc.).
|
|
|
|
// Cloudflare's headless detection, which causes infinite challenge loops.
|
|
|
|
|
|
|
|
func FetchViaChrome(url string) ([]byte, error) {
|
|
|
|
func FetchViaChrome(url string) ([]byte, error) {
|
|
|
|
profileDir := filepath.Join(config.ConfigDir(), "browser-profile")
|
|
|
|
// 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 {
|
|
|
|
if err := os.MkdirAll(profileDir, 0o755); err != nil {
|
|
|
|
return nil, fmt.Errorf("create browser profile dir: %w", err)
|
|
|
|
return nil, fmt.Errorf("create fetch profile dir: %w", err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Remove stale lock file from unclean shutdown
|
|
|
|
|
|
|
|
_ = os.Remove(filepath.Join(profileDir, "SingletonLock"))
|
|
|
|
_ = os.Remove(filepath.Join(profileDir, "SingletonLock"))
|
|
|
|
|
|
|
|
|
|
|
|
execPath := findBrowserExec()
|
|
|
|
execPath := findBrowserExec()
|
|
|
|
|
|
|
|
|
|
|
|
// Use non-headless mode: Cloudflare detects headless Chrome and loops
|
|
|
|
// Start with minimal options — NOT DefaultExecAllocatorOptions, which
|
|
|
|
// the JS challenge forever. A real (but hidden) browser window passes.
|
|
|
|
// includes flags that Cloudflare detects (--enable-automation, etc.).
|
|
|
|
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
|
|
|
opts := []chromedp.ExecAllocatorOption{
|
|
|
|
chromedp.Flag("headless", false),
|
|
|
|
chromedp.NoFirstRun,
|
|
|
|
chromedp.Flag("window-position", "-32000,-32000"), // off-screen
|
|
|
|
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("window-size", "1,1"),
|
|
|
|
chromedp.Flag("disable-gpu", true),
|
|
|
|
chromedp.Flag("disable-gpu", true),
|
|
|
|
chromedp.Flag("no-first-run", true),
|
|
|
|
|
|
|
|
chromedp.Flag("disable-extensions", true),
|
|
|
|
chromedp.Flag("disable-extensions", true),
|
|
|
|
chromedp.UserDataDir(profileDir),
|
|
|
|
chromedp.Flag("no-first-run", true),
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// Use non-headless — Cloudflare detects headless mode
|
|
|
|
|
|
|
|
chromedp.Flag("headless", false),
|
|
|
|
|
|
|
|
}
|
|
|
|
if execPath != "" {
|
|
|
|
if execPath != "" {
|
|
|
|
opts = append(opts, chromedp.ExecPath(execPath))
|
|
|
|
opts = append(opts, chromedp.ExecPath(execPath))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@@ -50,17 +61,42 @@ func FetchViaChrome(url string) ([]byte, error) {
|
|
|
|
ctx, cancel := chromedp.NewContext(allocCtx)
|
|
|
|
ctx, cancel := chromedp.NewContext(allocCtx)
|
|
|
|
defer cancel()
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
|
|
|
|
// Total timeout for the operation
|
|
|
|
|
|
|
|
ctx, timeoutCancel := context.WithTimeout(ctx, 30*time.Second)
|
|
|
|
ctx, timeoutCancel := context.WithTimeout(ctx, 30*time.Second)
|
|
|
|
defer timeoutCancel()
|
|
|
|
defer timeoutCancel()
|
|
|
|
|
|
|
|
|
|
|
|
// Navigate and wait for Cloudflare challenge to resolve.
|
|
|
|
// Inject the session key cookie before navigating
|
|
|
|
// Poll the page content until we get valid JSON (not the challenge page).
|
|
|
|
sessionKey := config.GetSessionKey()
|
|
|
|
if err := chromedp.Run(ctx, chromedp.Navigate(url)); err != nil {
|
|
|
|
if sessionKey != "" {
|
|
|
|
return nil, fmt.Errorf("chromedp navigate: %w", err)
|
|
|
|
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)
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Poll for JSON response — Cloudflare challenge takes a few seconds to clear
|
|
|
|
// 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)
|
|
|
|
ticker := time.NewTicker(1 * time.Second)
|
|
|
|
defer ticker.Stop()
|
|
|
|
defer ticker.Stop()
|
|
|
|
|
|
|
|
|
|
|
|
@@ -70,33 +106,48 @@ func FetchViaChrome(url string) ([]byte, error) {
|
|
|
|
return nil, fmt.Errorf("chromedp fetch timed out waiting for JSON response")
|
|
|
|
return nil, fmt.Errorf("chromedp fetch timed out waiting for JSON response")
|
|
|
|
case <-ticker.C:
|
|
|
|
case <-ticker.C:
|
|
|
|
var body string
|
|
|
|
var body string
|
|
|
|
// Try <pre> first (Chrome wraps JSON in <pre> tags)
|
|
|
|
|
|
|
|
err := chromedp.Run(ctx,
|
|
|
|
err := chromedp.Run(ctx,
|
|
|
|
chromedp.Text("pre", &body, chromedp.ByQuery),
|
|
|
|
chromedp.Text("pre", &body, chromedp.ByQuery),
|
|
|
|
)
|
|
|
|
)
|
|
|
|
if err != nil || body == "" {
|
|
|
|
if err != nil || body == "" {
|
|
|
|
// Fallback: try body directly
|
|
|
|
|
|
|
|
_ = chromedp.Run(ctx,
|
|
|
|
_ = chromedp.Run(ctx,
|
|
|
|
chromedp.Text("body", &body, chromedp.ByQuery),
|
|
|
|
chromedp.Text("body", &body, chromedp.ByQuery),
|
|
|
|
)
|
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
body = strings.TrimSpace(body)
|
|
|
|
body = strings.TrimSpace(body)
|
|
|
|
if body == "" {
|
|
|
|
if body == "" {
|
|
|
|
|
|
|
|
log.Printf("chrome-fetch: page body empty, waiting...")
|
|
|
|
continue
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// Check if we got actual JSON (starts with [ or {), not a challenge page
|
|
|
|
|
|
|
|
if body[0] == '[' || body[0] == '{' {
|
|
|
|
if body[0] == '[' || body[0] == '{' {
|
|
|
|
// Also extract any fresh cookies for future plain HTTP attempts
|
|
|
|
log.Printf("chrome-fetch: got JSON response (%d bytes)", len(body))
|
|
|
|
_ = extractAndSaveCookies(ctx)
|
|
|
|
_ = extractAndSaveCookies(ctx)
|
|
|
|
cancel() // graceful close, flushes cookies to profile
|
|
|
|
cancel()
|
|
|
|
return []byte(body), nil
|
|
|
|
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
|
|
|
|
// extractAndSaveCookies saves cf_clearance and other Cloudflare cookies
|
|
|
|
// alongside the session key, so plain HTTP requests can try them next time.
|
|
|
|
// so plain HTTP requests can try them on subsequent polls.
|
|
|
|
func extractAndSaveCookies(ctx context.Context) error {
|
|
|
|
func extractAndSaveCookies(ctx context.Context) error {
|
|
|
|
cookies, err := network.GetCookies().Do(ctx)
|
|
|
|
cookies, err := network.GetCookies().Do(ctx)
|
|
|
|
if err != nil {
|
|
|
|
if err != nil {
|
|
|
|
@@ -116,7 +167,6 @@ func extractAndSaveCookies(ctx context.Context) error {
|
|
|
|
return nil
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Write Cloudflare cookies to a file the fetcher can read
|
|
|
|
|
|
|
|
cfPath := filepath.Join(config.ConfigDir(), "cf-cookies")
|
|
|
|
cfPath := filepath.Join(config.ConfigDir(), "cf-cookies")
|
|
|
|
return os.WriteFile(cfPath, []byte(strings.Join(parts, "\n")+"\n"), 0o600)
|
|
|
|
return os.WriteFile(cfPath, []byte(strings.Join(parts, "\n")+"\n"), 0o600)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|