fix qr decoding, spool edit dialog, and opus 5 token budget
This commit is contained in:
+80
-15
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user