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:]) }