add spooldb client and look up spool location alongside weight
This commit is contained in:
@@ -3,11 +3,19 @@ module spoolweight
|
|||||||
go 1.26.2
|
go 1.26.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/chromedp/chromedp v0.15.1
|
||||||
github.com/makiuchi-d/gozxing v0.1.1
|
github.com/makiuchi-d/gozxing v0.1.1
|
||||||
golang.org/x/image v0.41.0
|
golang.org/x/image v0.41.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/text v0.37.0 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 h1:xxqijhoedi+/lZlhINteGbywIrewVdVv2wl9r5O9S1I=
|
||||||
github.com/makiuchi-d/gozxing v0.1.1/go.mod h1:eRIHbOjX7QWxLIDJoQuMLhuXg9LAuw6znsUtRkNw9DU=
|
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 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||||
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
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 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
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=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
@@ -22,12 +23,14 @@ import (
|
|||||||
"github.com/makiuchi-d/gozxing"
|
"github.com/makiuchi-d/gozxing"
|
||||||
"github.com/makiuchi-d/gozxing/qrcode"
|
"github.com/makiuchi-d/gozxing/qrcode"
|
||||||
xdraw "golang.org/x/image/draw"
|
xdraw "golang.org/x/image/draw"
|
||||||
|
"spoolweight/spooldb"
|
||||||
)
|
)
|
||||||
|
|
||||||
type result struct {
|
type result struct {
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
SpoolID string `json:"spool_id,omitempty"`
|
SpoolID string `json:"spool_id,omitempty"`
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
|
Location string `json:"location,omitempty"`
|
||||||
Weight *float64 `json:"weight,omitempty"`
|
Weight *float64 `json:"weight,omitempty"`
|
||||||
Unit string `json:"unit,omitempty"`
|
Unit string `json:"unit,omitempty"`
|
||||||
Confidence *float64 `json:"confidence,omitempty"`
|
Confidence *float64 `json:"confidence,omitempty"`
|
||||||
@@ -61,9 +64,12 @@ func main() {
|
|||||||
log.Printf("auth: %s", auth.name)
|
log.Printf("auth: %s", auth.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
locator := &spoolLocator{}
|
||||||
|
defer locator.close()
|
||||||
|
|
||||||
results := make([]result, 0, flag.NArg())
|
results := make([]result, 0, flag.NArg())
|
||||||
for _, path := range flag.Args() {
|
for _, path := range flag.Args() {
|
||||||
results = append(results, processImage(path, auth))
|
results = append(results, processImage(path, auth, locator))
|
||||||
}
|
}
|
||||||
|
|
||||||
enc := json.NewEncoder(os.Stdout)
|
enc := json.NewEncoder(os.Stdout)
|
||||||
@@ -71,9 +77,66 @@ func main() {
|
|||||||
enc.Encode(results)
|
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
|
// processImage reads one photo, capturing any failure in the result's Error
|
||||||
// field so a single bad image doesn't abort the whole batch.
|
// 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}
|
r := result{Image: path}
|
||||||
|
|
||||||
img, err := loadImage(path)
|
img, err := loadImage(path)
|
||||||
@@ -94,7 +157,19 @@ func processImage(path string, auth authInfo) result {
|
|||||||
r.SpoolID = spoolID(url)
|
r.SpoolID = spoolID(url)
|
||||||
r.URL = 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)
|
w, err := readWeight(img, auth)
|
||||||
|
wg.Wait()
|
||||||
|
r.Location = loc
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.Error = fmt.Sprintf("read weight: %v", err)
|
r.Error = fmt.Sprintf("read weight: %v", err)
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -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/<id>).
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user