initial public release
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
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:])
|
||||
}
|
||||
Reference in New Issue
Block a user