344 lines
11 KiB
Go
344 lines
11 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"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"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, 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).
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
log.Printf("chromedp: "+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 {
|
|
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)")
|
|
}
|
|
|
|
// 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)
|
|
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 {
|
|
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"]');
|
|
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)
|
|
for time.Now().Before(deadline) {
|
|
var state string
|
|
if err := c.run(ctx, 10*time.Second, chromedp.Evaluate(clickJS, &state)); err != nil {
|
|
return err
|
|
}
|
|
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 errors.New("spool edit dialog did not open")
|
|
}
|
|
|
|
// SpoolInfo opens a spool's edit dialog and reads its location and weights.
|
|
func (c *Client) SpoolInfo(ctx context.Context, spoolID string) (SpoolInfo, error) {
|
|
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 : ''; };
|
|
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'),
|
|
});
|
|
})()`
|
|
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 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),
|
|
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 {
|
|
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")
|
|
}
|