diff --git a/go.mod b/go.mod index 84d41c8..c850e74 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,19 @@ module spoolweight go 1.26.2 require ( + github.com/chromedp/chromedp v0.15.1 github.com/makiuchi-d/gozxing v0.1.1 golang.org/x/image v0.41.0 ) require ( + github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc // indirect + github.com/chromedp/sysutil v1.1.0 // indirect + github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.4.0 // indirect + golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect ) diff --git a/go.sum b/go.sum index 905ea1a..7cf2663 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,28 @@ +github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU= +github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag= +github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ= +github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= +github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao= +github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/makiuchi-d/gozxing v0.1.1 h1:xxqijhoedi+/lZlhINteGbywIrewVdVv2wl9r5O9S1I= github.com/makiuchi-d/gozxing v0.1.1/go.mod h1:eRIHbOjX7QWxLIDJoQuMLhuXg9LAuw6znsUtRkNw9DU= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= diff --git a/main.go b/main.go index 8ffac3d..716bf35 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "context" "encoding/base64" "encoding/json" "flag" @@ -22,12 +23,14 @@ import ( "github.com/makiuchi-d/gozxing" "github.com/makiuchi-d/gozxing/qrcode" xdraw "golang.org/x/image/draw" + "spoolweight/spooldb" ) type result struct { Image string `json:"image"` SpoolID string `json:"spool_id,omitempty"` URL string `json:"url,omitempty"` + Location string `json:"location,omitempty"` Weight *float64 `json:"weight,omitempty"` Unit string `json:"unit,omitempty"` Confidence *float64 `json:"confidence,omitempty"` @@ -61,9 +64,12 @@ func main() { log.Printf("auth: %s", auth.name) } + locator := &spoolLocator{} + defer locator.close() + results := make([]result, 0, flag.NArg()) for _, path := range flag.Args() { - results = append(results, processImage(path, auth)) + results = append(results, processImage(path, auth, locator)) } enc := json.NewEncoder(os.Stdout) @@ -71,9 +77,66 @@ func main() { enc.Encode(results) } +// spoolLocator lazily logs into spooldb on first use, so its (slow) browser +// login overlaps with the first image's weight read rather than blocking it. +// Location lookup is best-effort: any failure is logged and the location is +// simply omitted from the output. +type spoolLocator struct { + once sync.Once + client *spooldb.Client + err error +} + +func (s *spoolLocator) login() (*spooldb.Client, error) { + s.once.Do(func() { + user, pass := os.Getenv("SPOOLDB_USER"), os.Getenv("SPOOLDB_PASS") + if user == "" || pass == "" { + s.err = fmt.Errorf("SPOOLDB_USER/SPOOLDB_PASS not set") + return + } + c, err := spooldb.New() + if err != nil { + s.err = err + return + } + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + if err := c.Login(ctx, user, pass); err != nil { + c.Close() + s.err = fmt.Errorf("login: %w", err) + return + } + s.client = c + }) + return s.client, s.err +} + +// location returns the spool's storage location, or "" if it can't be looked up. +func (s *spoolLocator) location(spoolID string) string { + client, err := s.login() + if err != nil { + log.Printf("spooldb: skipping location lookup: %v", err) + return "" + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + loc, err := client.SpoolLocation(ctx, spoolID) + if err != nil { + log.Printf("spooldb: location lookup for %s: %v", spoolID, err) + return "" + } + return loc +} + +func (s *spoolLocator) close() { + if s.client != nil { + s.client.Close() + } +} + // processImage reads one photo, capturing any failure in the result's Error // field so a single bad image doesn't abort the whole batch. -func processImage(path string, auth authInfo) result { +func processImage(path string, auth authInfo, locator *spoolLocator) result { r := result{Image: path} img, err := loadImage(path) @@ -94,7 +157,19 @@ func processImage(path string, auth authInfo) result { r.SpoolID = spoolID(url) r.URL = url + // Look up the spool's location concurrently with the (slower) weight read. + var loc string + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + loc = locator.location(r.SpoolID) + }() + w, err := readWeight(img, auth) + wg.Wait() + r.Location = loc + if err != nil { r.Error = fmt.Sprintf("read weight: %v", err) return r diff --git a/spooldb/client.go b/spooldb/client.go new file mode 100644 index 0000000..c94db68 --- /dev/null +++ b/spooldb/client.go @@ -0,0 +1,232 @@ +// 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") +} diff --git a/spooldb/client_test.go b/spooldb/client_test.go new file mode 100644 index 0000000..bdd9f01 --- /dev/null +++ b/spooldb/client_test.go @@ -0,0 +1,40 @@ +package spooldb + +import ( + "context" + "os" + "testing" + "time" +) + +// TestSpoolLocation is a live integration test against 3dfilamentprofiles.com. +// It is skipped unless SPOOLDB_USER and SPOOLDB_PASS are set. The spool to look +// up defaults to the sample spool but can be overridden with SPOOL_ID. +func TestSpoolLocation(t *testing.T) { + email, pass := os.Getenv("SPOOLDB_USER"), os.Getenv("SPOOLDB_PASS") + if email == "" || pass == "" { + t.Skip("set SPOOLDB_USER and SPOOLDB_PASS to run the live integration test") + } + spoolID := os.Getenv("SPOOL_ID") + if spoolID == "" { + spoolID = "fU9zkaRWB" + } + + c, err := New() + if err != nil { + t.Fatalf("new client: %v", err) + } + defer c.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + if err := c.Login(ctx, email, pass); err != nil { + t.Fatalf("login: %v", err) + } + loc, err := c.SpoolLocation(ctx, spoolID) + if err != nil { + t.Fatalf("spool location: %v", err) + } + t.Logf("spool %s location: %q", spoolID, loc) +}