// Package spooldb is an isolated client for 3dfilamentprofiles.com (the site // behind the spooldb.com QR codes). The site has no public API and sits behind // Vercel's bot challenge, so a plain HTTP client can't reach it. This package // drives a headless Chromium (via chromedp) which solves the challenge // naturally, logs in, and reads/writes spool data. All site- and // browser-specific details are confined here. package spooldb import ( "context" "errors" "fmt" "os" "path/filepath" "strings" "time" "github.com/chromedp/chromedp" ) const ( baseURL = "https://3dfilamentprofiles.com" challengeText = "Security Checkpoint" ) // Client is a logged-in session against 3dfilamentprofiles.com. It owns a // headless browser; call Close when done. Methods are not safe for concurrent // use — the underlying browser tab is single-threaded. type Client struct { browserCtx context.Context cancels []context.CancelFunc loggedIn bool } type config struct { chromePath string headless bool } // Option configures a Client. type Option func(*config) // WithChromePath sets an explicit Chrome/Chromium executable path. func WithChromePath(p string) Option { return func(c *config) { c.chromePath = p } } // WithHeadful runs a visible browser window (useful for debugging). func WithHeadful() Option { return func(c *config) { c.headless = false } } // New launches a browser and returns a Client. It does not log in yet. func New(opts ...Option) (*Client, error) { cfg := config{headless: true} for _, o := range opts { o(&cfg) } chromePath, err := resolveChrome(cfg.chromePath) if err != nil { return nil, err } execOpts := append(chromedp.DefaultExecAllocatorOptions[:], chromedp.ExecPath(chromePath), chromedp.Flag("disable-blink-features", "AutomationControlled"), // A real-looking UA; the default headless UA advertises "HeadlessChrome". chromedp.UserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"), chromedp.WindowSize(1280, 900), ) if cfg.headless { execOpts = append(execOpts, chromedp.Flag("headless", "new")) } else { execOpts = append(execOpts, chromedp.Flag("headless", false)) } allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), execOpts...) browserCtx, browserCancel := chromedp.NewContext(allocCtx) // Allocate the browser bound to the long-lived browserCtx. chromedp ties the // Chrome process lifetime to the context of the first Run, so this must not // be a short-lived per-call context (otherwise the browser dies after it). if err := chromedp.Run(browserCtx, chromedp.Navigate("about:blank")); err != nil { browserCancel() allocCancel() return nil, fmt.Errorf("start browser: %w", err) } return &Client{ browserCtx: browserCtx, cancels: []context.CancelFunc{browserCancel, allocCancel}, }, nil } // Close shuts down the browser. func (c *Client) Close() { for _, cancel := range c.cancels { cancel() } } // run executes actions against the browser, bounded by timeout, and also // cancels if the caller's ctx is done. func (c *Client) run(ctx context.Context, timeout time.Duration, actions ...chromedp.Action) error { runCtx, cancel := context.WithTimeout(c.browserCtx, timeout) defer cancel() stop := context.AfterFunc(ctx, cancel) defer stop() return chromedp.Run(runCtx, actions...) } // awaitChallenge waits until the Vercel checkpoint clears (the headless browser // solves it automatically by running the page's JavaScript). func (c *Client) awaitChallenge(ctx context.Context) error { deadline := time.Now().Add(45 * time.Second) for time.Now().Before(deadline) { var title string if err := c.run(ctx, 10*time.Second, chromedp.Title(&title)); err != nil { return err } if !strings.Contains(title, challengeText) { return nil } select { case <-ctx.Done(): return ctx.Err() case <-time.After(2 * time.Second): } } return errors.New("vercel challenge did not clear in time") } // Login authenticates with email/password. Safe to call once per Client. func (c *Client) Login(ctx context.Context, email, password string) error { if err := c.run(ctx, 45*time.Second, chromedp.Navigate(baseURL+"/login")); err != nil { return fmt.Errorf("navigate to login: %w", err) } if err := c.awaitChallenge(ctx); err != nil { return err } if err := c.run(ctx, 45*time.Second, chromedp.WaitVisible("#email", chromedp.ByID), chromedp.SendKeys("#email", email, chromedp.ByID), chromedp.SendKeys("#password", password, chromedp.ByID), chromedp.Click(`//button[normalize-space()='Sign In']`, chromedp.BySearch), ); err != nil { return fmt.Errorf("submit login form: %w", err) } // Login succeeds once we navigate away from /login. deadline := time.Now().Add(30 * time.Second) for time.Now().Before(deadline) { var loc string if err := c.run(ctx, 10*time.Second, chromedp.Location(&loc)); err != nil { return err } if !strings.Contains(loc, "/login") { c.loggedIn = true return nil } select { case <-ctx.Done(): return ctx.Err() case <-time.After(time.Second): } } return errors.New("login did not complete (check credentials)") } // SpoolLocation returns the storage location assigned to a spool (e.g. "H"), // or "" if the spool has no location. spoolID is the code from the QR URL // (spooldb.com/s/). func (c *Client) SpoolLocation(ctx context.Context, spoolID string) (string, error) { if !c.loggedIn { return "", errors.New("not logged in") } if err := c.run(ctx, 45*time.Second, chromedp.Navigate(baseURL+"/my/spool/"+spoolID)); err != nil { return "", fmt.Errorf("navigate to spool: %w", err) } if err := c.awaitChallenge(ctx); err != nil { return "", err } // The spool's status card has a panel header whose first line is the // location (a box icon followed by the location name). const js = `(() => { const hdr = [...document.querySelectorAll('div.bg-panel')].find(h => h.querySelector('div.font-medium')); if (!hdr) return null; return (hdr.querySelector('div.font-medium').textContent || '').trim(); })()` var loc *string deadline := time.Now().Add(20 * time.Second) for time.Now().Before(deadline) { if err := c.run(ctx, 10*time.Second, chromedp.Evaluate(js, &loc)); err != nil { return "", err } if loc != nil { return strings.TrimSpace(*loc), nil } select { case <-ctx.Done(): return "", ctx.Err() case <-time.After(time.Second): } } return "", errors.New("spool page did not render (wrong id or not logged in?)") } // resolveChrome finds a Chrome/Chromium binary: explicit path, then $CHROME, // then a Chrome-for-Testing install, then common installed browsers. func resolveChrome(explicit string) (string, error) { candidates := []string{explicit, os.Getenv("CHROME")} if home, err := os.UserHomeDir(); err == nil { pattern := filepath.Join(home, ".cache/chrome-for-testing/chrome/*/chrome-*/*.app/Contents/MacOS/*") if m, _ := filepath.Glob(pattern); len(m) > 0 { candidates = append(candidates, m[len(m)-1]) // newest-sorted last } } candidates = append(candidates, "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/usr/bin/google-chrome", "/usr/bin/chromium", ) for _, p := range candidates { if p == "" { continue } if fi, err := os.Stat(p); err == nil && !fi.IsDir() { return p, nil } } return "", errors.New("no Chrome/Chromium found; install Chrome for Testing or pass WithChromePath") }