Files
apex-public/internal/scan/discover.go
T
2026-07-06 11:05:50 -04:00

170 lines
5.6 KiB
Go

package scan
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
// WorkdayDiscovery is the result of resolving a company careers URL into
// the Workday tenant/site pair that can be plugged into adapters.yaml.
type WorkdayDiscovery struct {
Input string `json:"input"`
FinalURL string `json:"finalUrl"`
Host string `json:"host"` // e.g. https://acme.wd3.myworkdayjobs.com
Tenant string `json:"tenant"` // e.g. acme
Site string `json:"site"` // e.g. Careers
Source string `json:"source"` // "url" (direct parse) or "playwright" (shell-out)
}
// YAMLSnippet renders a ready-to-paste adapters.yaml block for the result.
func (d WorkdayDiscovery) YAMLSnippet(name string) string {
if name == "" {
name = d.Tenant
}
return fmt.Sprintf(`- name: %s
careers_url: %s/%s
workday:
host: %s
tenant: %s
site: %s
enabled: true`, name, d.Host, d.Site, d.Host, d.Tenant, d.Site)
}
// workdayURLRE matches the canonical tenant-hosted Workday URL so we can
// short-circuit the Playwright shell-out when the input already IS the
// direct Workday URL.
var workdayURLRE = regexp.MustCompile(`^(https?://([a-zA-Z0-9_-]+)\.wd[0-9]+\.myworkdayjobs\.com)/(?:en-US/)?([a-zA-Z0-9_-]+)`)
// DiscoverWorkday resolves a careers URL into Workday host/tenant/site.
// Order of attempts:
// 1. Parse the URL itself — handles the common case where the user already
// pasted the direct *.myworkdayjobs.com/site URL.
// 2. If DiscoverScript is non-empty and node is on PATH, invoke the
// career-ops discover-workday.mjs helper which launches headless
// Playwright and inspects XHR calls.
//
// Returns ErrNoDiscovery if neither path resolves.
type Discoverer struct {
// DiscoverScript is the absolute path to career-ops/discover-workday.mjs
// (shell-out mode). Empty disables Playwright fallback.
DiscoverScript string
// Timeout caps the Playwright shell-out. Default 60s.
Timeout time.Duration
}
func (d *Discoverer) Discover(ctx context.Context, input string) (WorkdayDiscovery, error) {
if strings.TrimSpace(input) == "" {
return WorkdayDiscovery{}, errors.New("discover: empty input")
}
if _, err := url.ParseRequestURI(input); err != nil {
return WorkdayDiscovery{}, fmt.Errorf("discover: invalid url: %w", err)
}
// Fast path: direct Workday URL.
if m := workdayURLRE.FindStringSubmatch(input); len(m) == 4 {
return WorkdayDiscovery{
Input: input,
FinalURL: input,
Host: m[1],
Tenant: m[2],
Site: m[3],
Source: "url",
}, nil
}
// Fallback: Playwright via career-ops shell-out.
if d.DiscoverScript == "" {
return WorkdayDiscovery{}, ErrNoDiscovery
}
if _, err := os.Stat(d.DiscoverScript); err != nil {
return WorkdayDiscovery{}, fmt.Errorf("discover: script not found at %s: %w", d.DiscoverScript, err)
}
return d.runPlaywright(ctx, input)
}
// ErrNoDiscovery is returned when the careers URL isn't a direct Workday URL
// and no fallback script is configured. Callers can decide to prompt the user.
var ErrNoDiscovery = errors.New("discover: unable to resolve (no direct Workday URL, no script configured)")
func (d *Discoverer) runPlaywright(ctx context.Context, input string) (WorkdayDiscovery, error) {
timeout := d.Timeout
if timeout == 0 {
timeout = 60 * time.Second
}
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// --json is a flag we rely on the script to implement or ignore; either
// way we parse stdout defensively. For now we scan the text output for
// the final YAML snippet and regex the Workday fields back out of it.
// Absolute path so CWD mismatches don't matter — fall back to the original
// path if resolution fails (e.g. broken CWD) instead of silently exec'ing
// with an empty argv[1].
abs, err := filepath.Abs(d.DiscoverScript)
if err != nil {
abs = d.DiscoverScript
}
cmd := exec.CommandContext(runCtx, "node", abs, input)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
return WorkdayDiscovery{}, fmt.Errorf("discover: node run: %w (output: %s)", err, tailString(out.String(), 400))
}
result := parseDiscoverOutput(input, out.String())
if result.Host == "" {
// Fallback: attempt to parse JSON blob if the script printed one.
var j WorkdayDiscovery
if err := json.Unmarshal(out.Bytes(), &j); err == nil && j.Host != "" {
j.Source = "playwright"
return j, nil
}
return WorkdayDiscovery{}, fmt.Errorf("discover: no workday fields in output (tail: %s)", tailString(out.String(), 200))
}
result.Source = "playwright"
return result, nil
}
// Package-level regex patterns for parseDiscoverOutput — compiling once at
// init time avoids reallocating on every Playwright shell-out.
var (
reDiscHost = regexp.MustCompile(`(?m)^\s*host:\s*(\S+)`)
reDiscTenant = regexp.MustCompile(`(?m)^\s*tenant:\s*(\S+)`)
reDiscSite = regexp.MustCompile(`(?m)^\s*site:\s*(\S+)`)
)
// parseDiscoverOutput walks the discover-workday.mjs stdout looking for the
// YAML snippet it prints and extracts host/tenant/site back out.
func parseDiscoverOutput(input, output string) WorkdayDiscovery {
d := WorkdayDiscovery{Input: input}
if m := reDiscHost.FindStringSubmatch(output); len(m) > 1 {
d.Host = m[1]
}
if m := reDiscTenant.FindStringSubmatch(output); len(m) > 1 {
d.Tenant = m[1]
}
if m := reDiscSite.FindStringSubmatch(output); len(m) > 1 {
d.Site = m[1]
}
return d
}
// tailString caps log snippets used in error messages so we don't dump
// hundreds of KB of Playwright chatter into the user's terminal.
func tailString(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return "…" + string(r[len(r)-n:])
}