// 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" "encoding/json" "errors" "fmt" "log/slog" "os" "path/filepath" "strconv" "strings" "time" "github.com/chromedp/chromedp" ) // trace logs the start of an operation and, via the returned func, its end and // duration. It is a no-op above debug level, so it only appears with -v. func trace(op string) func() { t0 := time.Now() slog.Debug("begin", "op", op) return func() { slog.Debug("end", "op", op, "dur", time.Since(t0)) } } const ( baseURL = "https://3dfilamentprofiles.com" challengeText = "Security Checkpoint" // browserLaunchTimeout bounds Chrome startup, which otherwise has no // deadline and would hang the caller indefinitely. browserLaunchTimeout = 30 * time.Second ) // 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 } // 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 } } // New launches a browser and returns a Client. It does not log in yet. func New(opts ...Option) (*Client, error) { var cfg config 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), chromedp.Flag("headless", "new"), ) allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), execOpts...) browserCtx, browserCancel := chromedp.NewContext(allocCtx, chromedp.WithErrorf(chromeErrorf)) // 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). // That rules out a WithTimeout wrapper here, so bound the wait instead: if // Chrome never comes up we tear the whole thing down rather than hang. defer trace("spooldb.launchBrowser")() launched := make(chan error, 1) go func() { launched <- chromedp.Run(browserCtx, chromedp.Navigate("about:blank")) }() select { case err := <-launched: if err != nil { browserCancel() allocCancel() return nil, fmt.Errorf("start browser: %w", err) } case <-time.After(browserLaunchTimeout): browserCancel() allocCancel() return nil, fmt.Errorf("start browser: timed out after %s", browserLaunchTimeout) } return &Client{ browserCtx: browserCtx, cancels: []context.CancelFunc{browserCancel, allocCancel}, }, nil } // chromeErrorf is chromedp's error logger. It drops the benign "unhandled ... // event" diagnostics (chromedp doesn't handle every CDP event the page emits, // e.g. DOM.topLayerElementsUpdated from the edit dialog) and forwards the rest. func chromeErrorf(format string, args ...any) { if strings.HasPrefix(format, "unhandled ") { return } slog.Warn("chromedp: " + fmt.Sprintf(format, args...)) } // 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 { defer trace("spooldb.awaitChallenge")() deadline := time.Now().Add(45 * time.Second) for polls := 0; time.Now().Before(deadline); polls++ { var title string if err := c.run(ctx, 10*time.Second, chromedp.Title(&title)); err != nil { return err } if !strings.Contains(title, challengeText) { slog.Debug("challenge cleared", "polls", polls) 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 { defer trace("spooldb.login")() 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)") } // SpoolInfo is the current state of a spool, read from its edit dialog. type SpoolInfo struct { Location string // location label, e.g. "H" (empty if unset) Description string // brand/material/type/color, e.g. "Bambu Lab ABS GF - Black (41101)" TotalGrams float64 // weight including the spool (what the scale reads) RemainingGrams float64 // remaining filament the site tracks EmptySpoolGrams float64 // empty-spool weight used to derive remaining } // openEdit navigates to a spool and opens its "Edit Spool" dialog. There is no // direct URL for the dialog; it is opened from the spool page by the pencil // button, which sits between the QR-code and delete buttons in the card header. func (c *Client) openEdit(ctx context.Context, spoolID string) error { defer trace("spooldb.openEdit")() 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 } const clickJS = `(() => { if (document.getElementById('weight_with_spool')) return 'open'; const qr = document.querySelector('button[aria-label="barcode" i]'); if (!qr) return 'loading'; const group = qr.parentElement; const btns = [...group.querySelectorAll(':scope > button')]; const pencil = btns[btns.indexOf(qr) + 1]; if (!pencil) return 'no-pencil'; pencil.click(); return 'clicked'; })()` deadline := time.Now().Add(30 * time.Second) lastState := "none" for poll := 0; time.Now().Before(deadline); poll++ { var state string if err := c.run(ctx, 10*time.Second, chromedp.Evaluate(clickJS, &state)); err != nil { return err } if state != lastState { slog.Debug("openEdit poll", "spool", spoolID, "poll", poll, "state", state) } lastState = state switch state { case "open": return nil case "no-pencil": return errors.New("edit button not found on spool page") } select { case <-ctx.Done(): return ctx.Err() case <-time.After(time.Second): } } return fmt.Errorf("spool edit dialog did not open (last state %q; %s)", lastState, c.diagnose(ctx, spoolID)) } // diagnose captures why a page interaction failed: where the browser actually // ended up, which landmark elements are present, and a screenshot plus page text // written to temp files. It returns a one-line summary for the error message. func (c *Client) diagnose(ctx context.Context, spoolID string) string { const js = `(() => JSON.stringify({ login: !!document.getElementById('email'), qr: !!document.querySelector('button[aria-label="barcode" i]'), dialog: !!document.getElementById('weight_with_spool'), buttons: [...document.querySelectorAll('button[aria-label]')].map(b => b.getAttribute('aria-label')).join(','), text: document.body ? document.body.innerText : '', }))()` var loc, title, out string if err := c.run(ctx, 15*time.Second, chromedp.Location(&loc), chromedp.Title(&title), chromedp.Evaluate(js, &out), ); err != nil { return fmt.Sprintf("diagnostics unavailable: %v", err) } var d struct { Login, QR, Dialog bool Buttons, Text string } if err := json.Unmarshal([]byte(out), &d); err != nil { return fmt.Sprintf("diagnostics unparsable: %v", err) } slog.Warn("spooldb: openEdit failed", "spool", spoolID, "url", loc, "title", title, "loginFormPresent", d.Login, "qrButtonPresent", d.QR, "dialogPresent", d.Dialog, "ariaButtons", d.Buttons) stem := filepath.Join(os.TempDir(), "spooldb-fail-"+spoolID) if err := os.WriteFile(stem+".txt", []byte("url: "+loc+"\ntitle: "+title+"\n\n"+d.Text), 0o600); err == nil { slog.Warn("spooldb: wrote page text", "path", stem+".txt") } var shot []byte if err := c.run(ctx, 15*time.Second, chromedp.CaptureScreenshot(&shot)); err == nil { if err := os.WriteFile(stem+".png", shot, 0o600); err == nil { slog.Warn("spooldb: wrote screenshot", "path", stem+".png") } } switch { case d.Login: return "browser is on the login page — session not established" case !d.QR: return "spool page never rendered (no barcode button); url " + loc default: return "clicked the edit button but the dialog never appeared; url " + loc } } // SpoolInfo opens a spool's edit dialog and reads its location and weights. func (c *Client) SpoolInfo(ctx context.Context, spoolID string) (SpoolInfo, error) { defer trace("spooldb.spoolInfo")() if err := c.openEdit(ctx, spoolID); err != nil { return SpoolInfo{}, err } const js = `(() => { const val = id => { const e = document.getElementById(id); return e ? e.value : ''; }; const ph = id => { const e = document.getElementById(id); return e ? e.placeholder : ''; }; const dialog = document.getElementById('weight_with_spool').closest('[role="dialog"]') || document.body; // The dialog header reads "Edit Spool" then a descriptor line like // "Bambu Lab ABS GF - Black (41101)" (brand, material, type, color). const lines = dialog.innerText.split('\n').map(s => s.trim()); const i = lines.indexOf('Edit Spool'); const desc = i >= 0 ? (lines.slice(i + 1).find(s => s.length) || '') : ''; return JSON.stringify({ location: val('location_label'), total: val('weight_with_spool'), remaining: val('remaining_grams'), empty: val('empty_spool_weight') || ph('empty_spool_weight'), description: desc, }); })()` var out string if err := c.run(ctx, 10*time.Second, chromedp.Evaluate(js, &out)); err != nil { return SpoolInfo{}, err } var raw struct{ Location, Total, Remaining, Empty, Description string } if err := json.Unmarshal([]byte(out), &raw); err != nil { return SpoolInfo{}, fmt.Errorf("parse spool fields: %w", err) } return SpoolInfo{ Location: strings.TrimSpace(raw.Location), Description: strings.TrimSpace(raw.Description), TotalGrams: parseGrams(raw.Total), RemainingGrams: parseGrams(raw.Remaining), EmptySpoolGrams: parseGrams(raw.Empty), }, nil } // SetTotalWeight opens a spool's edit dialog, sets the total (with-spool) weight // — the site recomputes remaining filament from the empty-spool weight — and // saves. grams is the weight read off the scale (filament plus spool). func (c *Client) SetTotalWeight(ctx context.Context, spoolID string, grams float64) error { defer trace("spooldb.setTotalWeight")() if err := c.openEdit(ctx, spoolID); err != nil { return err } // Set the value the way React expects so the form recomputes remaining_grams. setJS := fmt.Sprintf(`(() => { const el = document.getElementById('weight_with_spool'); if (!el) return false; const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; setter.call(el, %q); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); return true; })()`, fmt.Sprintf("%.0f", grams)) var ok bool if err := c.run(ctx, 10*time.Second, chromedp.Evaluate(setJS, &ok)); err != nil { return err } if !ok { return errors.New("weight field not found") } if err := c.run(ctx, 15*time.Second, chromedp.Click(`//button[normalize-space()='Save Changes']`, chromedp.BySearch), ); err != nil { return fmt.Errorf("click save: %w", err) } // Saving closes the dialog; wait for it to disappear to confirm success. deadline := time.Now().Add(15 * time.Second) for time.Now().Before(deadline) { var stillOpen bool if err := c.run(ctx, 10*time.Second, chromedp.Evaluate(`!!document.getElementById('weight_with_spool')`, &stillOpen)); err != nil { return err } if !stillOpen { return nil } select { case <-ctx.Done(): return ctx.Err() case <-time.After(time.Second): } } return errors.New("save did not complete") } func parseGrams(s string) float64 { v, _ := strconv.ParseFloat(strings.TrimSpace(strings.ReplaceAll(s, ",", "")), 64) return v } // 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") }