170 lines
5.6 KiB
Go
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:])
|
|
}
|