fix qr decoding, spool edit dialog, and opus 5 token budget

This commit is contained in:
Ian Gulliver
2026-07-25 09:15:32 -07:00
parent 700b53d50c
commit 82b652d7e4
3 changed files with 107 additions and 22 deletions
+80 -15
View File
@@ -32,6 +32,9 @@ func trace(op string) func() {
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
@@ -45,7 +48,6 @@ type Client struct {
type config struct {
chromePath string
headless bool
}
// Option configures a Client.
@@ -54,12 +56,9 @@ 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}
var cfg config
for _, o := range opts {
o(&cfg)
}
@@ -74,12 +73,8 @@ func New(opts ...Option) (*Client, error) {
// 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"),
)
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))
@@ -87,11 +82,22 @@ func New(opts ...Option) (*Client, error) {
// 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")()
if err := chromedp.Run(browserCtx, chromedp.Navigate("about:blank")); err != nil {
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: %w", err)
return nil, fmt.Errorf("start browser: timed out after %s", browserLaunchTimeout)
}
return &Client{
@@ -214,7 +220,7 @@ func (c *Client) openEdit(ctx context.Context, spoolID string) error {
const clickJS = `(() => {
if (document.getElementById('weight_with_spool')) return 'open';
const qr = document.querySelector('button[aria-label="barcode"]');
const qr = document.querySelector('button[aria-label="barcode" i]');
if (!qr) return 'loading';
const group = qr.parentElement;
const btns = [...group.querySelectorAll(':scope > button')];
@@ -224,11 +230,16 @@ func (c *Client) openEdit(ctx context.Context, spoolID string) error {
return 'clicked';
})()`
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
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
@@ -241,7 +252,61 @@ func (c *Client) openEdit(ctx context.Context, spoolID string) error {
case <-time.After(time.Second):
}
}
return errors.New("spool edit dialog did not open")
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.