Files
spoolweight/spooldb/client.go
T

433 lines
15 KiB
Go

// 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")
}