diff --git a/backend/build.sh b/backend/build.sh index 768318f..e69fef1 100755 --- a/backend/build.sh +++ b/backend/build.sh @@ -4,7 +4,7 @@ set -euo pipefail cd "$(dirname "$0")" mkdir -p dist -for fn in api sync enrich; do +for fn in api sync enrich emailsync; do GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -tags lambda.norpc -o dist/bootstrap "./cmd/$fn" (cd dist && zip -q "$fn.zip" bootstrap && rm bootstrap) echo "built dist/$fn.zip" diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 47904f0..b006445 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -30,6 +30,7 @@ func main() { AdminClerkID: config.Get(ctx, "ADMIN_CLERK_ID"), Season: season, Session: config.Get(ctx, "LEETCODE_SESSION"), + GithubToken: config.Get(ctx, "GITHUB_TOKEN"), } lambda.Start(handler.Handle) } diff --git a/backend/cmd/emailsync/main.go b/backend/cmd/emailsync/main.go new file mode 100644 index 0000000..5c0c395 --- /dev/null +++ b/backend/cmd/emailsync/main.go @@ -0,0 +1,329 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "log" + "os" + "regexp" + "strings" + "time" + + "github.com/aws/aws-lambda-go/lambda" + _ "github.com/jackc/pgx/v5/stdlib" +) + +/* +This file contains a Lambda function for the job email sync feature that: +- Opens a Postgres database from PG_DSN. +- Loads active email accounts for users. +- Fetches recent emails for each account (currently a stub). +- Detects job-related emails and classifies them. +- Extracts company and due date information from Online Assessment emails. +- Writes application status rows into the database. + +Main functions to read in order: +- main -> handler -> openDB -> loadActiveEmailAccounts -> processEmailAccount -> saveApplicationStatus + +This Lambda is meant to run on a schedule from EventBridge, enrich email data, and support the Kronos job dashboard. +*/ + +// EmailMessage is the simple shape of an email that the Lambda processes. +// It contains only the fields we need for job detection and status extraction. +type EmailMessage struct { + ID string // The email provider's unique message identifier. + From string // The sender address or display name. + Subject string // The email subject line. + Body string // The plain text body of the email. + Date time.Time // When the email was sent. +} + +// EmailAccount represents a connected mail account for a user. +type EmailAccount struct { + UserID int // Local Kronos user identifier. + Provider string // e.g. gmail, outlook. + AccessToken string // OAuth token used to read email. + Active bool // Only active accounts are processed. +} + +// ApplicationStatus is the row we want to write into Postgres. +type ApplicationStatus struct { + UserID int + Company string + Role string + Status string + DueDate sql.NullTime + SourceEmailID string +} + +const ( + StatusOaReceived = "OA_RECEIVED" + StatusInterviewInvite = "INTERVIEW_INVITE" + StatusRejection = "REJECTION" + StatusOther = "OTHER" +) + +func main() { + lambda.Start(handler) +} + +// handler is the Lambda entry point. +// EventBridge invokes this on a schedule. The event payload is ignored here +// because this Lambda only needs to run periodically and scan user emails. +func handler(ctx context.Context, event interface{}) error { + // ctx is the context to timeout database operations if they take too long + // step 1: open database connection + db, err := openDB(ctx) // connects to Postgres using PG_DSN + if err != nil { + return fmt.Errorf("open db: %w", err) // if it fails, return an error to Lambda so it can retry later + } + defer db.Close() // no matter what happens, close the database connection when we're done + + // step 2: load active email accounts from the database + accounts, err := loadActiveEmailAccounts(ctx, db) // fetches email_accounts rows where active = true + if err != nil { + return fmt.Errorf("load email accounts: %w", err) // if it fails, return an error to Lambda so it can retry later + } + + // step 3: process each active email account + for _, account := range accounts { // for each active email account, process it + if err := processEmailAccount(ctx, db, account); err != nil { // fetches new emails, checks if job-related, classifies them, and saves application statuses + log.Printf("user %d: %v", account.UserID, err) // if an error occurs, log it but continue processing other accounts + } + } + + return nil // no error, Lambda will consider this invocation successful +} + +// openDB opens a connection pool to Postgres using PG_DSN. +// sql.DB is a pool of connections, not a single database connection. +func openDB(ctx context.Context) (*sql.DB, error) { + // step 1: get connection string from environment variable + dsn := os.Getenv("PG_DSN") // read env variable + if dsn == "" { // check if it's empty + return nil, fmt.Errorf("PG_DSN is not set") // this prevents trying to connect with no credentials + } + + // step 2: open a database connection pool object that will be used to connect when needed. This does not actually connect yet. + db, err := sql.Open("pgx", dsn) // creates a connection pool to Postgres using the pgx driver (dsn is the connection string: username, pass, host, db name, etc.) + if err != nil { // if opening fails, return an error + return nil, err + } + + // step 3: Verify the connection works / verify the database is reachable. + if err := db.PingContext(ctx); err != nil { // sends a ping to the database to check if it's reachable + db.Close() + return nil, err + } + + // step 4: return the database connection pool to the caller. The caller is responsible for closing it when done. + return db, nil +} + +// loadActiveEmailAccounts fetches the email_accounts rows that are active. +// It returns only the fields we need to process email. +func loadActiveEmailAccounts(ctx context.Context, db *sql.DB) ([]EmailAccount, error) { + rows, err := db.QueryContext(ctx, ` + select user_id, email_provider, access_token, active + from email_accounts + where active = true + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var accounts []EmailAccount + for rows.Next() { + var account EmailAccount + if err := rows.Scan(&account.UserID, &account.Provider, &account.AccessToken, &account.Active); err != nil { + return nil, err + } + accounts = append(accounts, account) + } + return accounts, rows.Err() +} + +// processEmailAccount handles one user's email account. +// It fetches new emails, finds job-related messages, and saves statuses. +func processEmailAccount(ctx context.Context, db *sql.DB, account EmailAccount) error { + emails, err := fetchNewEmailsForUser(ctx, account) + if err != nil { + return fmt.Errorf("fetch emails: %w", err) + } + + for _, email := range emails { + if !isJobEmail(email) { + continue + } + + status := classifyEmail(email) + if status == StatusOther { + continue + } + + company := extractCompany(email) + dueDate, _ := extractDueDate(email) + + st := ApplicationStatus{ + UserID: account.UserID, + Company: company, + Role: "", + Status: status, + DueDate: dueDate, + SourceEmailID: email.ID, + } + + if err := saveApplicationStatus(ctx, db, st); err != nil { + log.Printf("save status user %d email %s: %v", account.UserID, email.ID, err) + } + } + + return nil +} + +// fetchNewEmailsForUser is a placeholder stub that returns sample emails. +// In a real implementation, this would call Gmail or Outlook APIs using OAuth tokens. +func fetchNewEmailsForUser(ctx context.Context, account EmailAccount) ([]EmailMessage, error) { + _ = ctx + _ = account + + // TODO: Replace this stub with real API calls. + // The real function should use account.AccessToken and account.Provider to + // list recent messages, fetch their subject/body/from/date/id, and return them. + return []EmailMessage{ + { + ID: "sample-1", + From: "no-reply@company.com", + Subject: "Your Online Assessment is ready", + Body: "Hi, please complete your Online Assessment by 08/15/2026.", + Date: time.Now().Add(-2 * time.Hour), + }, + { + ID: "sample-2", + From: "recruiter@anotherco.com", + Subject: "Interview Invitation for Software Engineer", + Body: "We would like to invite you to interview next week.", + Date: time.Now().Add(-24 * time.Hour), + }, + }, nil +} + +// isJobEmail checks whether the email looks related to a job application. +// It uses simple keyword checks in the subject and body. +func isJobEmail(email EmailMessage) bool { + text := strings.ToLower(email.Subject + " " + email.Body) + keywords := []string{ + "online assessment", + "oa", + "interview", + "rejection", + "offer", + "application", + } + + for _, keyword := range keywords { + if strings.Contains(text, keyword) { + return true + } + } + return false +} + +// classifyEmail returns one of the top-level job email categories. +// This is a simple keyword-based classifier and can be upgraded later. +func classifyEmail(email EmailMessage) string { + text := strings.ToLower(email.Subject + " " + email.Body) + + if strings.Contains(text, "online assessment") || strings.Contains(text, "oa") { + return StatusOaReceived + } + if strings.Contains(text, "interview") || strings.Contains(text, "panel") { + return StatusInterviewInvite + } + if strings.Contains(text, "rejected") || strings.Contains(text, "regret") || strings.Contains(text, "not selected") { + return StatusRejection + } + return StatusOther +} + +// extractCompany uses a simple heuristic to guess the company name. +// It checks the sender address and the subject line. +func extractCompany(email EmailMessage) string { + cleanedFrom := strings.TrimSpace(strings.Split(email.From, "<")[0]) + if cleanedFrom != "" && cleanedFrom != email.From { + return cleanedFrom + } + + subject := strings.ToLower(email.Subject) + patterns := []string{" at ", " from ", " for "} + for _, pat := range patterns { + if idx := strings.Index(subject, pat); idx != -1 { + candidate := strings.TrimSpace(email.Subject[idx+len(pat):]) + if candidate != "" { + return candidate + } + } + } + + // Fallback to the raw sender or subject when we cannot parse a company. + if email.From != "" { + return email.From + } + return email.Subject +} + +var datePattern = regexp.MustCompile(`(?i)(\b\d{1,2}/\d{1,2}/\d{2,4}\b)|(\b\d{4}-\d{1,2}-\d{1,2}\b)`) // MM/DD/YYYY or YYYY-MM-DD + +// extractDueDate looks for a simple date pattern in the email text. +// It returns sql.NullTime so we can store NULL in the database when no date is found. +func extractDueDate(email EmailMessage) (sql.NullTime, error) { + text := email.Subject + " " + email.Body + match := datePattern.FindString(text) + if match == "" { + return sql.NullTime{Valid: false}, nil + } + + layouts := []string{"1/2/2006", "01/02/2006", "2006-01-02"} + for _, layout := range layouts { + if due, err := time.Parse(layout, match); err == nil { + return sql.NullTime{Time: due, Valid: true}, nil + } + } + + return sql.NullTime{Valid: false}, nil +} + +// saveApplicationStatus inserts or updates a row in application_statuses. +// It uses ON CONFLICT to avoid duplicate rows for the same email message. +func saveApplicationStatus(ctx context.Context, db *sql.DB, status ApplicationStatus) error { + _, err := db.ExecContext(ctx, ` + insert into application_statuses + (user_id, company, role, status, due_date, source_email_id, created_at, updated_at) + values ($1, $2, $3, $4, $5, $6, now(), now()) + on conflict (user_id, source_email_id) do update set + company = excluded.company, + role = excluded.role, + status = excluded.status, + due_date = excluded.due_date, + updated_at = now() + `, + status.UserID, + status.Company, + nullString(status.Role), + status.Status, + status.DueDate, + status.SourceEmailID, + ) + return err +} + +// nullString converts a string to sql.NullString. +// This makes it easier to store nullable text columns in Postgres. +func nullString(value string) sql.NullString { + if strings.TrimSpace(value) == "" { + return sql.NullString{Valid: false} + } + return sql.NullString{String: value, Valid: true} +} diff --git a/backend/cmd/scrapetest/main.go b/backend/cmd/scrapetest/main.go new file mode 100644 index 0000000..9046553 --- /dev/null +++ b/backend/cmd/scrapetest/main.go @@ -0,0 +1,207 @@ +// scrapetest is a manual, local-only debug tool - NOT a deployed Lambda. +// It is not built by backend/build.sh and not referenced anywhere in +// terraform, so running it never touches AWS or Postgres. All it does is +// call the same two functions GET /jobs calls (jobs.FetchSpeedyApplyJobs and +// jobs.FetchSimplifyJobsNewGrad) and print what they found, so you can check +// the GitHub scraping + Markdown/HTML parsing actually works before wiring +// up anything else. +// +// Run it from the backend/ directory with: +// +// go run ./cmd/scrapetest +package main + +import ( + "context" + "fmt" + "os" + "strings" + + "kronos/internal/jobs" +) + +// sample prints the first n jobs from a slice (or all of them if there are +// fewer than n) so the terminal output stays readable even though a real +// scrape can return hundreds of rows. +func sample(label string, all []jobs.Job, n int) { + fmt.Printf("\n=== %s: %d jobs found ===\n", label, len(all)) + for i, j := range all { + if i >= n { + fmt.Printf("... and %d more\n", len(all)-n) + break + } + closed := "" + if j.Closed { + closed = " [CLOSED]" + } + link := j.PostingURL + if link == "" { + link = "(no link)" // e.g. a closed SimplifyJobs row - its Application cell is just a 🔒 emoji + } + fmt.Printf("%2d. %-22s %-45s %-20s age=%-6s %-22s%s\n -> %s\n", + i+1, truncate(j.Company, 22), truncate(j.Position, 45), truncate(j.Location, 20), j.Age, j.SourceSection, closed, link) + } +} + +// truncate is rune-aware (not byte-aware) since company names sometimes +// contain emoji, e.g. "🔥 TikTok" - slicing by byte could cut a multi-byte +// character in half and print garbage. +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n-1]) + "…" +} + +func main() { + ctx := context.Background() + // Optional: set GITHUB_TOKEN in your shell first if you hit GitHub's + // unauthenticated rate limit (unlikely for a single manual run). + token := os.Getenv("GITHUB_TOKEN") + + speedyJobs, err := jobs.FetchSpeedyApplyJobs(ctx, token) + if err != nil { + fmt.Println("speedyapply error:", err) + } + sample("SpeedyApply (speedyapply/2027-SWE-College-Jobs)", speedyJobs, 8) + + simplifyJobs, err := jobs.FetchSimplifyJobsNewGrad(ctx, token) + if err != nil { + fmt.Println("simplifyjobs error:", err) + } + sample("SimplifyJobs (SimplifyJobs/New-Grad-Positions)", simplifyJobs, 8) + + combined := append(speedyJobs, simplifyJobs...) + fmt.Printf("\nTotal: %d jobs scraped\n", len(combined)) + + // Section counts matter because both scrapers locate sections by matching + // heading text. If an upstream README renames a heading, that section + // quietly yields zero rows instead of erroring - a count of 0 here is the + // only way to notice. + reportSections("SpeedyApply", speedyJobs) + reportSections("SimplifyJobs", simplifyJobs) + + // Lookalikes are measured on the deduped list on purpose: it's the list a + // user actually sees, so the count reflects duplication still visible in + // the dashboard rather than duplication the rules already handled. + reportLookalikes(reportDedupe(combined, 12), 12) +} + +// reportSections prints how many jobs came out of each section heading, in +// first-seen order. A section listed with 0 rows - or missing entirely - means +// the heading match in the scraper no longer lines up with the README. +func reportSections(label string, all []jobs.Job) { + counts := map[string]int{} + var order []string + for _, j := range all { + if _, seen := counts[j.SourceSection]; !seen { + order = append(order, j.SourceSection) + } + counts[j.SourceSection]++ + } + + fmt.Printf("\n=== %s sections ===\n", label) + for _, s := range order { + name := s + if name == "" { + name = "(no section)" + } + fmt.Printf(" %-34s %3d\n", truncate(name, 34), counts[s]) + } +} + +// reportLookalikes finds rows a human would call duplicates - same company, +// title and location - that URL-first identity deliberately keeps apart +// because their posting links genuinely differ. This is the other half of the +// picture: reportDedupe says what the rules DID merge, this says what a user +// would still see listed twice. +func reportLookalikes(all []jobs.Job, maxGroups int) { + key := func(j jobs.Job) string { + norm := func(s string) string { return strings.Join(strings.Fields(strings.ToLower(s)), " ") } + return norm(j.Company) + "|" + norm(j.Position) + "|" + norm(j.Location) + } + + groups := map[string][]jobs.Job{} + var order []string + for _, j := range all { + k := key(j) + if _, seen := groups[k]; !seen { + order = append(order, k) + } + groups[k] = append(groups[k], j) + } + + total, shown := 0, 0 + for _, k := range order { + g := groups[k] + if len(g) < 2 { + continue + } + total += len(g) - 1 + if shown >= maxGroups { + continue + } + shown++ + fmt.Printf("\n[%d rows] %s\n", len(g), truncate(k, 90)) + for _, j := range g { + link := j.PostingURL + if link == "" { + link = "(no link)" + } + fmt.Printf(" %-14s -> %s\n", truncate(j.SourceRepo, 14), link) + } + } + + fmt.Printf("\n=== lookalikes: %d rows share company+title+location with an earlier row ===\n", total) +} + +// reportDedupe runs the real dedupe pass and prints what collapsed, so the +// canonicalization rules can be eyeballed against live data. The counts alone +// aren't enough to judge calibration: what matters is whether the rows that +// merged really are the same posting, which needs seeing them side by side. +func reportDedupe(combined []jobs.Job, maxGroups int) []jobs.Job { + deduped := jobs.Dedupe(combined) + + fmt.Printf("\n=== dedupe: %d -> %d (%d collapsed) ===\n", + len(combined), len(deduped), len(combined)-len(deduped)) + + // Regroup by ID to find which rows merged. Iterate the slice (not a map) + // so the report is stable between runs. + groups := map[string][]jobs.Job{} + var order []string + for _, j := range combined { + if _, seen := groups[j.ID]; !seen { + order = append(order, j.ID) + } + groups[j.ID] = append(groups[j.ID], j) + } + + shown := 0 + for _, id := range order { + g := groups[id] + if len(g) < 2 { + continue + } + if shown >= maxGroups { + fmt.Printf("... and more collapsed groups\n") + break + } + shown++ + fmt.Printf("\n[%s] %d rows merged:\n", id, len(g)) + for _, j := range g { + link := j.PostingURL + if link == "" { + link = "(no link)" + } + fmt.Printf(" %-18s | %-40s | %-18s | %s\n", + truncate(j.Company, 18), truncate(j.Position, 40), truncate(j.Location, 18), truncate(link, 60)) + } + } + + if shown == 0 { + fmt.Println("(nothing collapsed)") + } + + return deduped +} diff --git a/backend/go.mod b/backend/go.mod index 299ecd7..b8ea345 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,7 +4,7 @@ go 1.24.0 require ( github.com/aws/aws-lambda-go v1.47.0 - github.com/aws/aws-sdk-go-v2 v1.41.12 + github.com/aws/aws-sdk-go-v2 v1.43.5 github.com/aws/aws-sdk-go-v2/config v1.32.23 github.com/aws/aws-sdk-go-v2/service/ssm v1.69.2 github.com/clerk/clerk-sdk-go/v2 v2.6.0 @@ -12,18 +12,22 @@ require ( ) require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.17 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.22 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.28 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.28 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.28 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.29 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.28 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.36 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.36 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.37 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.16 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.29 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.36 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.37 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.107.1 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.1.4 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.31.2 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.5 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.43.2 // indirect - github.com/aws/smithy-go v1.27.1 // indirect + github.com/aws/smithy-go v1.27.7 // indirect github.com/go-jose/go-jose/v3 v3.0.4 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/backend/go.sum b/backend/go.sum index 448fafa..71be66f 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -2,6 +2,10 @@ github.com/aws/aws-lambda-go v1.47.0 h1:0H8s0vumYx/YKs4sE7YM0ktwL2eWse+kfopsRI1s github.com/aws/aws-lambda-go v1.47.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= github.com/aws/aws-sdk-go-v2 v1.41.12 h1:DIKX2c31ekm9RA2D9FBj1EWXx++9AdAqRw+e78Tq2Ck= github.com/aws/aws-sdk-go-v2 v1.41.12/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg= +github.com/aws/aws-sdk-go-v2 v1.43.5 h1:yKT5GYnFWhuDo+DqKvE5ZPwVn3RjC4MAeBtZGlh6AVM= +github.com/aws/aws-sdk-go-v2 v1.43.5/go.mod h1:wZjAJppCntyOGgVSmgVTfDyRJK5PHOasO6Wsy8U7Axk= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.17 h1:mn+Vxb9zgz/FE/yDTcFim3DZ1qpcrxR+qBQkBrl6bzA= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.17/go.mod h1:eDfmEFxu+BSVsUGLbzJhWjpOurv1mqczClS97yI8wdk= github.com/aws/aws-sdk-go-v2/config v1.32.23 h1:PYDobtcsJXK6bQe9I8RQk6s19Bz3xa3xRU08Hy1Em3Y= github.com/aws/aws-sdk-go-v2/config v1.32.23/go.mod h1:QID4dqUQVgEOYPKsPWd1sNWCCR2c5g7o3jeEtIXPOZU= github.com/aws/aws-sdk-go-v2/credentials v1.19.22 h1:SHfH6wyPsEgG7fVsi5rQxWEt7tuIcN2PGhb1mTFv6tE= @@ -10,14 +14,30 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.28 h1:b+kcDejJrXc30zU/w8Tc9k github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.28/go.mod h1:LnI62O9GnSv6GcuLXxOYqlq0C8EmxMcgnF6m7LdYuOY= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.28 h1:Xf2j7NdVcUKomlZ4iihOP4AZ3Fzlr8h4yKpXeP+OFPg= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.28/go.mod h1:O8cDo1dW63jU7ki//kRe1z+tLGcpnD1jrouitsQddDw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.36 h1:5CrzwxDqf4w3x1Vs3/NiZ0nsC34Hbm3pIDMWbsLebOE= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.36/go.mod h1:A3gHdKZIvG/QXERzZwcxNS3RNDFcRCuhhTFBYp+V/nw= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.28 h1:KqIfN9kpkKkcBqBbNpNGTIrXO6ExTUvFKvXkC+YAzVo= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.28/go.mod h1:uxtQiKvLtNS4iXVsH2McVD/ls8FKN/uUhe1hGxPjrw0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.36 h1:A4N2f4YPcST0v+dWtX+xrpPPCL9VTBhoIFFUWYqbacE= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.36/go.mod h1:B/Qr859uxWUEfZeGotK5KAEoof4Q9YWgNtPSwV6jcyk= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.29 h1:VkE9FuzTQVjBBrnj4+oCdxCLFIz7aqLYKUCjtvxVcOs= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.29/go.mod h1:H32Z2Qth9b+9LqjyBsCnozMQ8H2N7YBUDVXwbs0iggg= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.37 h1:oyd3ke4V9AhKcRR7rRgxk1VyI+DjK2CBQtbxh3OkdaA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.37/go.mod h1:aA9D7SqfG9IC1b7FLD7Iyc8Q4JN0a8gHhNjN4zPlIaI= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.16 h1:iE4NGbvqUZnHDqddQAauZzCILYtFjOHwRM5MOOKLB5A= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.16/go.mod h1:VsjEgrP+ibcou8TlWA4tYaB+0OojuhirsmCe+U60hTA= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.29 h1:E65Hj648dOV6FuUfI0mYXXhQRHbsi7n+B9h6fZPJO/E= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.29/go.mod h1:xLrF9yNTCs92VZSpdEd68EJbgcdw3SMR74RO6QDzWHE= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.28 h1:axj4mEDletwKmTm/9jR+DkIMmCfcn5vE4jBMAAN+3Vg= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.28/go.mod h1:3Aaz69M0jqfSHLKqxgolgUBFT4hpwSNc7DzC95orEi8= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.36 h1:fx2ujmozWn+C/GtfXfz5k6Ckzza40ElOpIW7d92fLWQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.36/go.mod h1:QT2ufGVJ+xTRxtXPHTQ1kHkAdWIKPCmD+BqYAXWv8/4= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.37 h1:KGHa9iZCrgtkOsFfXb0S4ywsjostA/hau7WE9aSb43E= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.37/go.mod h1:FV79f0DSnZIEGsQjWenENGtUycrasyAaJZO+zRanLHA= +github.com/aws/aws-sdk-go-v2/service/s3 v1.107.1 h1:VUTtUJMuRNMkb/7NIKmd8NQaeQLPGCMoTJxkYKre4qM= +github.com/aws/aws-sdk-go-v2/service/s3 v1.107.1/go.mod h1:WvUaO0lP5GNMs1R6cs6qvB3mqo16GLta8yfOuf55Rpc= github.com/aws/aws-sdk-go-v2/service/signin v1.1.4 h1:YcpVyIPLCbiypN6KSphijN5fC7DDjX114SqA7prnnxg= github.com/aws/aws-sdk-go-v2/service/signin v1.1.4/go.mod h1:5ZICS++oFTRPfa1GsBqFDWX/8WamZ/QQOcCzIuU/zLw= github.com/aws/aws-sdk-go-v2/service/ssm v1.69.2 h1:1tt+wXv6kWIgHdFd4ehWtBdURIMVYl68ipwljOsR3k4= @@ -30,6 +50,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.43.2 h1:RTO7mmGyedgnNmcPh3yQizNfc6GK github.com/aws/aws-sdk-go-v2/service/sts v1.43.2/go.mod h1:fBhUZXDin9YYqhcpOMjIcpdik25rVwWyxLdPH1RZd9s= github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8= github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/smithy-go v1.27.7 h1:Zgj5z4LfcDYoQIVk+n/yGdTkP/2y6ZT5vYxe0fp7bqE= +github.com/aws/smithy-go v1.27.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/clerk/clerk-sdk-go/v2 v2.6.0 h1:OgxysA1iHcL9mZp3ie/F8e5sBmX4xKRXR7p0BVbut98= github.com/clerk/clerk-sdk-go/v2 v2.6.0/go.mod h1:ncFmsPwmD5WpGCNW5bJve862j/HQfpkzsshXYV/quJ8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 428b1ae..9144a40 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -21,6 +21,14 @@ type API struct { AdminClerkID string Season int64 Session string + + // Job Board: GET /jobs scrapes the two public GitHub READMEs on demand + // (see backend/internal/api/jobs.go). There is no server-side store for + // this feature at all - no bucket, no table - because the data is + // derived from a public source that can be re-fetched at any time, and + // is cached in process memory here plus localStorage in the browser. + // The token just lifts GitHub's rate limit from 60/hr to 5000/hr. + GithubToken string } type request = events.APIGatewayV2HTTPRequest @@ -162,6 +170,9 @@ func (a *API) member(ctx context.Context, method, path string, user store.User, rows, err := a.Store.MySolution(ctx, user.ID, parts[2], query["recent"] == "1") return dataOrError(rows, err) + case method == "GET" && path == "/jobs": + return a.getJobs(ctx, query) + case method == "GET" && path == "/leaderboard": rows, err := a.Store.Leaderboard(ctx, 100) return dataOrError(rows, err) diff --git a/backend/internal/api/jobs.go b/backend/internal/api/jobs.go new file mode 100644 index 0000000..2821550 --- /dev/null +++ b/backend/internal/api/jobs.go @@ -0,0 +1,147 @@ +package api + +import ( + "context" + "log" + "strconv" + "sync" + "time" + + "kronos/internal/jobs" +) + +// memTTL is how long one scrape is reused before the next request re-fetches +// from GitHub. This is the only thing standing between "every dashboard load +// scrapes two READMEs" and a sane request rate, so it's deliberately longer +// than the browser's own 2-minute localStorage TTL in src/lib/jobsCache.ts. +const memTTL = 5 * time.Minute + +// The job list is cached in plain process memory - no S3, no database, no +// file on disk. AWS keeps a Lambda container alive between invocations when +// requests keep coming, and package-level variables survive for the life of +// that container, so consecutive requests reuse one scrape for free. When +// the container is eventually recycled these reset to their zero values and +// the next request just scrapes again, which is exactly what a cache should +// do. Nothing here needs to persist: the source of truth is the two public +// GitHub READMEs, which can be re-read at any moment. +var ( + jobsMu sync.Mutex + jobsCached []jobs.Job + jobsAt time.Time +) + +// cachedJobs returns the scraped job list, re-fetching from GitHub only when +// the in-memory copy is missing or older than memTTL. +// +// The mutex is held across the network call on purpose. It means two +// simultaneous cold requests don't both scrape - the second one blocks, then +// finds the first one's result already cached and returns it. Serializing a +// slow path is the right trade here, since the alternative is a burst of +// duplicate scrapes every time the cache expires. +func (a *API) cachedJobs(ctx context.Context) ([]jobs.Job, time.Time, error) { + jobsMu.Lock() + defer jobsMu.Unlock() + + if !jobsAt.IsZero() && time.Since(jobsAt) < memTTL { + return jobsCached, jobsAt, nil + } + + // Scrape each source independently so one failing (GitHub briefly down, + // a README's table format changed) doesn't cost us the other one. + speedyJobs, errSpeedy := jobs.FetchSpeedyApplyJobs(ctx, a.GithubToken) + if errSpeedy != nil { + log.Printf("speedyapply: %v", errSpeedy) + } + simplifyJobs, errSimplify := jobs.FetchSimplifyJobsNewGrad(ctx, a.GithubToken) + if errSimplify != nil { + log.Printf("simplifyjobs: %v", errSimplify) + } + + if errSpeedy != nil && errSimplify != nil { + // Both sources failed. Rather than caching an empty list (which + // would wedge the board as empty for the whole TTL), keep serving + // the last good scrape if we still have one. + if jobsCached != nil { + return jobsCached, jobsAt, nil + } + return nil, time.Time{}, errSpeedy + } + + // Source order is load-bearing: whichever record Dedupe sees first wins + // every field it has a value for. speedyapply goes first because it's + // the source carrying salary data. + combined := append(speedyJobs, simplifyJobs...) + jobsCached = jobs.Dedupe(combined) + jobsAt = time.Now().UTC() + + // Log both counts. If the collapsed number ever jumps sharply after a + // canonicalization rule changes, that's the signal a rule is merging too + // aggressively and is quietly hiding postings - there's no other way to + // notice from production. + log.Printf("scraped %d jobs (%d speedyapply, %d simplifyjobs), %d after dedupe (%d collapsed)", + len(combined), len(speedyJobs), len(simplifyJobs), len(jobsCached), len(combined)-len(jobsCached)) + + return jobsCached, jobsAt, nil +} + +// getJobs serves GET /jobs for the Job Board dashboard card, one page at a +// time. It scrapes the two public GitHub READMEs directly (via the fetchers +// in kronos/internal/jobs) and slices out just the page that was asked for. +// +// There is no server-side store behind this route. The job data is derived +// from a public source rather than owned by us, so it's re-fetchable at any +// time, which makes any copy we keep a cache rather than a record. Two +// caches already cover that: cachedJobs above (process memory, 5 min) and +// the browser's localStorage in src/lib/jobsCache.ts (2 min). +// +// Pagination is plain offset-based (?limit=20&offset=20, ...) rather than +// the SQL cursor version we tried earlier: the full list is already sitting +// in memory and stable for the life of one request, so there's no "another +// request snuck a row in before yours" problem to design around. +func (a *API) getJobs(ctx context.Context, query map[string]string) (response, error) { + all, scrapedAt, err := a.cachedJobs(ctx) + if err != nil { + return serverError(err) + } + + limit := 20 + if v := query["limit"]; v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + limit = n + } + } + offset := 0 + if v := query["offset"]; v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + offset = n + } + } + + page := []jobs.Job{} + var nextOffset *int // nil means "no more pages" - encoded as JSON null + + if offset < len(all) { + end := offset + limit + if end > len(all) { + end = len(all) + } + page = all[offset:end] + if end < len(all) { + n := end + nextOffset = &n + } + } + + return reply(200, jobsPage{ + Jobs: page, + ScrapedAt: scrapedAt, + NextOffset: nextOffset, + }) +} + +// jobsPage is the shape of every GET /jobs response. +type jobsPage struct { + Jobs []jobs.Job `json:"jobs"` + ScrapedAt time.Time `json:"scrapedAt"` + NextOffset *int `json:"nextOffset"` +} diff --git a/backend/internal/jobs/canonical.go b/backend/internal/jobs/canonical.go new file mode 100644 index 0000000..8b39a0f --- /dev/null +++ b/backend/internal/jobs/canonical.go @@ -0,0 +1,339 @@ +package jobs + +// Canonicalization: turning the many ways one job posting can be written into +// a single normalized form, so that two scrapes of the same job produce the +// same fingerprint. +// +// The problem this solves: the same posting appears in both source READMEs, +// written slightly differently each time - "Acme, Inc." vs "Acme", a link +// with ?utm_source=... vs one without, a trailing slash vs none. Hashing +// those raw strings gives different IDs, so the dashboard shows one job +// several times. +// +// Everything here feeds the ID hash ONLY. The Job struct keeps the original +// human-readable Company/Position/PostingURL for display - we normalize to +// decide "are these the same job", never to decide what the user reads. +// +// The guiding rule throughout is that the two failure modes are NOT equally +// bad. Under-merging shows one job twice, which is mildly annoying. +// Over-merging makes a real job silently disappear, which is much worse. So +// every rule below is written to err toward leaving things separate. + +import ( + "net/url" + "regexp" + "strings" + "unicode" +) + +// schemePattern matches a leading URL scheme like "https://" so we can tell +// whether a link already has one. Without a scheme, url.Parse reads the whole +// string as a path and the host ends up empty. +var schemePattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.\-]*://`) + +// workdayDisambiguator matches the "-1"/"-2"/"-3" suffix Workday adds to a +// requisition ID when the same req is published to more than one career +// site, e.g. "_R55736-3". The leading underscore-plus-ID is required so a +// job slug that merely ends in a number isn't truncated. +var workdayDisambiguator = regexp.MustCompile(`(_[A-Za-z0-9]+)-\d+$`) + +// textFolder collapses characters that look identical to a human but differ +// in bytes. Scraped Markdown is full of these: editors silently substitute +// curly quotes and en-dashes, and zero-width characters ride along invisibly +// from copy-pasted HTML. Note U+00A0 (non-breaking space) is NOT here - it's +// already handled upstream by cleanText, since strings.Fields treats it as +// whitespace. +// Written as escapes rather than literal characters on purpose: several of +// these are invisible in an editor, so a literal would be unreviewable, and +// a literal U+FEFF is outright illegal in Go source. +var textFolder = strings.NewReplacer( + "\u200b", "", // zero-width space + "\u200c", "", // zero-width non-joiner + "\u200d", "", // zero-width joiner + "\ufeff", "", // byte-order mark + "\u2018", "'", // left single curly quote + "\u2019", "'", // right single curly quote + "\u201c", `"`, // left double curly quote + "\u201d", `"`, // right double curly quote + "\u2013", "-", // en dash + "\u2014", "-", // em dash +) + +// punctFolder drops punctuation that varies between spellings of the same +// company name: "Acme, Inc." vs "Acme Inc", "Macy's" vs "Macys". +var punctFolder = strings.NewReplacer( + ".", "", + ",", "", + "'", "", +) + +// trackingParams are query parameters that identify where a click came from +// rather than which job it points at, so two links differing only in these +// are the same posting. +// +// This is a denylist (strip what we know is tracking) rather than an +// allowlist (keep what we know is an ID) on purpose: an unrecognized +// parameter is more likely to be meaningful than not, and leaving it in errs +// toward under-merging, which is the safe direction. +// +// DANGER: note gh_src is here but gh_jid is NOT. They differ by three +// characters and mean opposite things - gh_src is Greenhouse's source token, +// gh_jid is the job ID itself. Stripping gh_jid would merge every Greenhouse +// posting at a company into a single entry. +var trackingParams = map[string]bool{ + // Google Analytics + "utm_source": true, + "utm_medium": true, + "utm_campaign": true, + "utm_term": true, + "utm_content": true, + "utm_id": true, + "_ga": true, + "_gl": true, + // Ad-network click IDs + "gclid": true, + "fbclid": true, + "msclkid": true, + "mc_cid": true, + "mc_eid": true, + // Applicant tracking systems + "gh_src": true, + "lever-source": true, + "lever-origin": true, + "trk": true, + "trackingid": true, + "refid": true, + "jobsearchtk": true, + "iis": true, + "iisn": true, + "from": true, + // Generic referral tagging + "ref": true, + "refsrc": true, + "referrer": true, + "source": true, + "src": true, + "campaign_id": true, +} + +// legalSuffixes are company-name endings that carry no identifying +// information: "Acme Inc" and "Acme" are the same employer. +// +// Deliberately English-only. Foreign forms (gmbh, ag, sa, nv, bv, pty, pte) +// are likelier to be part of a real name and barely appear in these two +// repos, so stripping them risks over-merging for no practical gain. +// +// "co" is the riskiest entry - it's a real word fragment. It's only ever +// removed as a whole trailing token, and stripLegalSuffix refuses to empty +// a name entirely, so a company genuinely named "Co" survives intact. +var legalSuffixes = map[string]bool{ + "inc": true, + "incorporated": true, + "corp": true, + "corporation": true, + "llc": true, + "ltd": true, + "limited": true, + "plc": true, + "llp": true, + "lp": true, + "co": true, + "company": true, +} + +// canonicalURL normalizes a posting link so that the cosmetic differences +// between two copies of the same link disappear. +// +// This is the most valuable of the three canonicalizers by a wide margin. +// Both scrapers pull the employer's real applicant-tracking link (Greenhouse, +// Lever, Workday), and when the same job appears in both READMEs they almost +// always point at the identical ATS URL. That makes a canonical URL a far +// stronger identity than any amount of normalizing prose - it sidesteps the +// "same role, different wording" problem entirely. +// +// Returns "" for an empty or whitespace-only input, which signals the caller +// to fall back to a company/position/location identity instead. +func canonicalURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + + // A protocol-relative link ("//example.com/j/1") needs only the scheme + // prepended; anything else missing "://" needs the whole prefix. + switch { + case strings.HasPrefix(raw, "//"): + raw = "https:" + raw + case !schemePattern.MatchString(raw): + raw = "https://" + raw + } + + u, err := url.Parse(raw) + if err != nil { + // One malformed link shouldn't cost us the whole scrape. Fall back to + // something deterministic so the job still gets a stable ID. + return strings.ToLower(raw) + } + + // http and https serve the same posting, so fold them together rather + // than letting the scheme split one job into two. + u.Scheme = "https" + + // Only scheme and host are case-insensitive (RFC 3986). The path is left + // exactly as found - Workday in particular uses case-sensitive segments, + // and lowercasing them could collide two genuinely different postings. + u.Host = strings.TrimPrefix(strings.ToLower(u.Host), "www.") + + // Workday publishes one requisition through several career-site front + // doors, so the same job reaches us under several URLs: + // + // https://cadence.wd1.myworkdayjobs.com/External_Careers/job/LIVONIA-01/Adams-... + // https://cadence.wd1.myworkdayjobs.com/University_Talent/job/LIVONIA-01/Adams-... + // https://cadence.wd1.myworkdayjobs.com/Univ_Careers/job/LIVONIA-01/Adams-... + // + // The leading segments are the site name (and sometimes a locale, e.g. + // "/fr-CA/Private_Posting_No_TMP/job/..."); everything from "/job/" + // onward is the requisition itself. Keeping only that part merges the + // front doors while still separating genuinely different postings, since + // two different requisitions have different job paths. + // + // This stays deterministic - it's the same kind of rule as stripping a + // trailing slash, not fuzzy matching - and it's scoped to Workday hosts + // so it can't affect any other ATS. + // Workday also appends a per-site disambiguator to the requisition ID + // when one req is published to several career sites, so the same job + // ends up as _R55736, _R55736-1, _R55736-2 and _R55736-3. Dropping that + // suffix is what actually merges them. + // + // The pattern is anchored on the underscore that precedes every Workday + // requisition ID, which keeps it from mangling a title that legitimately + // ends in a number - "Software-Engineer-2" has no "_reqid" before the + // trailing digits and is left alone. + if strings.HasSuffix(u.Host, ".myworkdayjobs.com") { + if i := strings.Index(u.Path, "/job/"); i >= 0 { + u.Path = u.Path[i:] + } + u.Path = workdayDisambiguator.ReplaceAllString(u.Path, "$1") + } + + // A trailing slash is cosmetic, but "/" as the entire path is just the + // site root and is cleaner represented as empty. + if u.Path == "/" { + u.Path = "" + } else { + u.Path = strings.TrimSuffix(u.Path, "/") + } + + // Fragments are pure client-side navigation ("#apply") and never change + // which posting is served. + u.Fragment = "" + u.RawFragment = "" + + q := u.Query() + for key := range q { + if trackingParams[strings.ToLower(key)] { + q.Del(key) + } + } + // Encode() sorts by key, so two links carrying the same parameters in a + // different order come out identical. It also omits "?" when nothing is + // left, which is what we want. + u.RawQuery = q.Encode() + + return u.String() +} + +// canonicalCompany normalizes an employer name for fingerprinting. +// +// Only used when a posting has no link at all (SimplifyJobs renders closed +// applications as a lock emoji with no href), so this is the fallback path +// rather than the common one. +func canonicalCompany(s string) string { + s = textFolder.Replace(s) + s = strings.ToLower(s) + + // "Johnson & Johnson" and "Johnson and Johnson" are one employer. Padding + // with spaces keeps "J&J" from collapsing into a single token. + s = strings.ReplaceAll(s, "&", " and ") + + s = punctFolder.Replace(s) + s = trimNonAlphanumeric(s) + s = collapseSpaces(s) + s = stripLegalSuffix(s) + + return collapseSpaces(s) +} + +// canonicalPosition normalizes a job title for fingerprinting. +// +// This one is deliberately minimal - it lowercases, folds lookalike +// characters, and strips decoration from the ends. That's all. +// +// It explicitly does NOT map "SWE" to "Software Engineer" or "Intern" to +// "Internship". Those are fuzzy matching, not canonicalization, and they're +// exactly where over-merging starts hiding real jobs. Since canonicalURL +// handles the overwhelming majority of duplicates, title text only decides +// identity for the small tail of postings with no link - a weak reason to +// take that risk. +// +// It also does not strip a leading "New": in this domain "New Grad" is a +// meaningful part of the title, not a badge. +func canonicalPosition(s string) string { + // Trimming non-alphanumerics from the ends removes decorative markers + // (lock, star, sparkle emoji) and stray trailing punctuation. Years and + // parenthetical qualifiers in the middle are left alone, since + // "Summer 2026" and "(Backend)" genuinely distinguish jobs. + return foldMinimal(s) +} + +// canonicalLocation normalizes a location for fingerprinting. +// +// Only reached on the fallback identity path, where it matters a great deal: +// without it, every closed listing sharing a company and title collapses into +// one entry regardless of city, hiding real openings. Multi-location cells +// arrive pre-joined as "Seattle, WA, Austin, TX" (see lineBreakPattern in +// html.go), and are treated as a single opaque string - two postings listing +// the same cities in a different order stay separate, which is the safe +// direction. +func canonicalLocation(s string) string { + return foldMinimal(s) +} + +// foldMinimal is the light-touch normalization shared by position and +// location: fold lookalike characters, lowercase, strip decoration from the +// ends, and collapse whitespace. Nothing semantic. +func foldMinimal(s string) string { + s = textFolder.Replace(s) + s = strings.ToLower(s) + s = trimNonAlphanumeric(s) + return collapseSpaces(s) +} + +// trimNonAlphanumeric removes leading and trailing runes that are neither +// letters nor digits - emoji, asterisks, stray brackets, whitespace. +func trimNonAlphanumeric(s string) string { + return strings.TrimFunc(s, func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) + }) +} + +// collapseSpaces squeezes runs of whitespace down to single spaces and trims +// the ends, the same normalization cleanText applies to scraped cells. +func collapseSpaces(s string) string { + return strings.Join(strings.Fields(s), " ") +} + +// stripLegalSuffix removes trailing corporate-form tokens, repeatedly, so +// "acme co inc" reduces to "acme". +// +// The len(fields) > 1 guard is what keeps this safe: it will never consume +// the last remaining token, so a company actually named "Co" or "Limited" +// keeps its name instead of canonicalizing to the empty string (which would +// merge it with every other suffix-only name). +func stripLegalSuffix(s string) string { + fields := strings.Fields(s) + for len(fields) > 1 && legalSuffixes[fields[len(fields)-1]] { + fields = fields[:len(fields)-1] + } + return strings.Join(fields, " ") +} diff --git a/backend/internal/jobs/canonical_test.go b/backend/internal/jobs/canonical_test.go new file mode 100644 index 0000000..57695b4 --- /dev/null +++ b/backend/internal/jobs/canonical_test.go @@ -0,0 +1,437 @@ +package jobs + +// Tests for canonical.go. +// +// These are written as three kinds of table: +// +// - exact-output tests, pinning down what a canonicalizer actually returns; +// - "must merge" pairs, where two spellings of one job have to collapse to +// the same canonical form; +// - "must NOT merge" pairs, where two genuinely different jobs have to stay +// apart. +// +// The third kind is the important one. Any canonicalizer can be made to merge +// more by adding rules; the failure that actually hurts users is a rule that +// merges too much and makes a real posting vanish from the dashboard. Those +// cases are cheap to write and are the regression net for every future rule. + +import "testing" + +func TestCanonicalURL(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"empty", "", ""}, + {"whitespace only", " ", ""}, + { + "already canonical", + "https://boards.greenhouse.io/acme/jobs/123", + "https://boards.greenhouse.io/acme/jobs/123", + }, + { + "trailing slash removed", + "https://boards.greenhouse.io/acme/jobs/123/", + "https://boards.greenhouse.io/acme/jobs/123", + }, + { + "http folded to https", + "http://boards.greenhouse.io/acme/jobs/123", + "https://boards.greenhouse.io/acme/jobs/123", + }, + { + "www stripped and host lowercased", + "https://WWW.Lever.co/acme/123", + "https://lever.co/acme/123", + }, + { + // Only scheme and host are case-insensitive, so the path is left + // exactly as found. Uses a non-Workday host to keep this case + // isolated from the career-site trimming below. + "path case preserved", + "https://acme.com/en-US/Careers/job/SoftwareEngineer", + "https://acme.com/en-US/Careers/job/SoftwareEngineer", + }, + { + "workday career site and locale trimmed, case preserved", + "https://acme.wd1.myworkdayjobs.com/en-US/Careers/job/SoftwareEngineer", + "https://acme.wd1.myworkdayjobs.com/job/SoftwareEngineer", + }, + { + "fragment dropped", + "https://lever.co/acme/123#apply", + "https://lever.co/acme/123", + }, + { + "tracking params stripped", + "https://lever.co/acme/123?utm_source=github&utm_campaign=newgrad", + "https://lever.co/acme/123", + }, + { + "job id preserved while tracking stripped", + "https://boards.greenhouse.io/acme?gh_src=abcd&gh_jid=987", + "https://boards.greenhouse.io/acme?gh_jid=987", + }, + { + "remaining params sorted", + "https://lever.co/acme?z=1&a=2", + "https://lever.co/acme?a=2&z=1", + }, + { + "missing scheme defaulted", + "boards.greenhouse.io/acme/jobs/1", + "https://boards.greenhouse.io/acme/jobs/1", + }, + { + "protocol relative", + "//boards.greenhouse.io/acme/jobs/1", + "https://boards.greenhouse.io/acme/jobs/1", + }, + { + "bare root path emptied", + "https://acme.com/", + "https://acme.com", + }, + { + "surrounding whitespace trimmed", + " https://lever.co/acme/123 ", + "https://lever.co/acme/123", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := canonicalURL(tc.in); got != tc.want { + t.Errorf("canonicalURL(%q)\n got: %q\nwant: %q", tc.in, got, tc.want) + } + }) + } +} + +func TestCanonicalURLMustMerge(t *testing.T) { + pairs := []struct { + name string + a, b string + }{ + { + "trailing slash", + "https://boards.greenhouse.io/acme/jobs/123", + "https://boards.greenhouse.io/acme/jobs/123/", + }, + { + "scheme and www", + "http://www.acme.com/jobs/1", + "https://acme.com/jobs/1", + }, + { + "tracking params only difference", + "https://boards.greenhouse.io/acme?gh_jid=9&utm_source=gh", + "https://boards.greenhouse.io/acme?gh_jid=9", + }, + { + "param order", + "https://acme.com/j?a=1&b=2", + "https://acme.com/j?b=2&a=1", + }, + { + "fragment", + "https://acme.com/jobs/1#apply", + "https://acme.com/jobs/1", + }, + { + "ad click id", + "https://acme.com/jobs/1?gclid=xyz", + "https://acme.com/jobs/1", + }, + { + "lever source tag", + "https://jobs.lever.co/acme/abc?lever-source=LinkedIn", + "https://jobs.lever.co/acme/abc", + }, + { + // Observed live: Cadence lists one requisition under four + // different Workday career sites. + "workday career site segment", + "https://cadence.wd1.myworkdayjobs.com/External_Careers/job/LIVONIA-01/Adams-Application-Software-Developer", + "https://cadence.wd1.myworkdayjobs.com/University_Talent_NCG/job/LIVONIA-01/Adams-Application-Software-Developer", + }, + { + // Observed live: RTX, where one variant also carries a locale + // segment ahead of the site name. + "workday locale segment", + "https://globalhr.wd5.myworkdayjobs.com/fr-CA/Private_Posting_No_TMP/job/US-AZ-TUCSON/Software-Engineer", + "https://globalhr.wd5.myworkdayjobs.com/rec_rtx_ext_gateway/job/US-AZ-TUCSON/Software-Engineer", + }, + { + // Observed live: Salesforce publishes requisition JR355250 to + // both its external site and its new-grad portal, and the second + // copy carries a "-1" disambiguator. + "workday requisition disambiguator", + "https://salesforce.wd12.myworkdayjobs.com/External_Career_Site/job/California---San-Francisco/Software-Engineering-AMTS--College-Grad-_JR355250-1", + "https://salesforce.wd12.myworkdayjobs.com/Futureforce_NewGradRoles/job/California---San-Francisco/Software-Engineering-AMTS--College-Grad-_JR355250", + }, + { + // Observed live: Cadence publishes requisition R55736 to four + // career sites, numbered -1 through -3 plus a bare copy. + "workday four career sites, one requisition", + "https://cadence.wd1.myworkdayjobs.com/External_Careers/job/LIVONIA-01/Adams-Application-Software-Developer---Recent-Grad-2026-_R55736-3", + "https://cadence.wd1.myworkdayjobs.com/University_Talent_NCG/job/LIVONIA-01/Adams-Application-Software-Developer---Recent-Grad-2026-_R55736", + }, + } + + for _, tc := range pairs { + t.Run(tc.name, func(t *testing.T) { + if got, want := canonicalURL(tc.a), canonicalURL(tc.b); got != want { + t.Errorf("expected these to canonicalize the same:\n a: %q -> %q\n b: %q -> %q", + tc.a, got, tc.b, want) + } + }) + } +} + +func TestCanonicalURLMustNotMerge(t *testing.T) { + pairs := []struct { + name string + a, b string + }{ + { + "different greenhouse job ids", + "https://boards.greenhouse.io/acme?gh_jid=1", + "https://boards.greenhouse.io/acme?gh_jid=2", + }, + { + "different indeed job keys", + "https://indeed.com/viewjob?jk=aaa", + "https://indeed.com/viewjob?jk=bbb", + }, + { + "different paths", + "https://acme.com/jobs/1", + "https://acme.com/jobs/2", + }, + { + "different hosts", + "https://acme.com/jobs/1", + "https://globex.com/jobs/1", + }, + { + "workday path case is significant", + "https://acme.wd1.myworkdayjobs.com/job/SoftwareEngineer", + "https://acme.wd1.myworkdayjobs.com/job/softwareengineer", + }, + { + "different linkedin job ids", + "https://linkedin.com/jobs/view?currentJobId=111", + "https://linkedin.com/jobs/view?currentJobId=222", + }, + { + // The Workday rule must still separate real requisitions - this + // is the pair Cadence actually has, two distinct roles under the + // same career sites. + "workday different job slugs", + "https://cadence.wd1.myworkdayjobs.com/External_Careers/job/LIVONIA-01/Adams-Application-Software-Developer", + "https://cadence.wd1.myworkdayjobs.com/External_Careers/job/LIVONIA-01/Adams-Multibody-Dynamics-Developer", + }, + { + "workday different tenants", + "https://cadence.wd1.myworkdayjobs.com/External_Careers/job/X/Role", + "https://salesforce.wd12.myworkdayjobs.com/External_Career_Site/job/X/Role", + }, + { + // The rule is scoped to Workday hosts: a lookalike path segment + // elsewhere must not be stripped. + "non-workday host keeps full path", + "https://acme.com/careers/job/X/Role", + "https://acme.com/interns/job/X/Role", + }, + { + // Observed live: RTX has three genuinely distinct requisitions, + // two of them at different buildings. Different req IDs must + // survive the disambiguator rule. + "workday different requisition ids", + "https://globalhr.wd5.myworkdayjobs.com/rec_rtx_ext_gateway/job/US-AZ-TUCSON-801/Software-Engineer-I--Onsite-_01865026-1", + "https://globalhr.wd5.myworkdayjobs.com/fr-CA/Private_Posting_No_TMP/job/US-AZ-TUCSON-801/Software-Engineer-I--Onsite-_01866246", + }, + { + // A title ending in a level number has no "_reqid" before the + // digits, so the disambiguator rule must leave it intact. + "workday title ending in a number", + "https://acme.wd1.myworkdayjobs.com/Careers/job/X/Software-Engineer-2", + "https://acme.wd1.myworkdayjobs.com/Careers/job/X/Software-Engineer-3", + }, + } + + for _, tc := range pairs { + t.Run(tc.name, func(t *testing.T) { + if got, other := canonicalURL(tc.a), canonicalURL(tc.b); got == other { + t.Errorf("these are different jobs but both canonicalized to %q\n a: %q\n b: %q", + got, tc.a, tc.b) + } + }) + } +} + +func TestCanonicalCompany(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"plain", "Acme", "acme"}, + {"lowercased", "ACME", "acme"}, + {"trailing inc with comma", "Acme, Inc.", "acme"}, + {"trailing incorporated", "Acme Incorporated", "acme"}, + {"trailing corp", "Acme Corp.", "acme"}, + {"trailing corporation", "Acme Corporation", "acme"}, + {"trailing llc", "Acme LLC", "acme"}, + {"trailing ltd", "Acme Ltd.", "acme"}, + {"trailing company", "The Walt Disney Company", "the walt disney"}, + {"stacked suffixes", "Acme Co Inc", "acme"}, + {"ampersand expanded", "Johnson & Johnson", "johnson and johnson"}, + {"apostrophe removed", "Macy's", "macys"}, + {"leading emoji stripped", "⭐ Acme", "acme"}, + {"internal whitespace collapsed", "Acme Labs", "acme labs"}, + {"curly apostrophe folded", "Macy\u2019s", "macys"}, + {"zero width space removed", "Ac\u200bme", "acme"}, + {"en dash folded to hyphen", "Acme\u2013Globex", "acme-globex"}, + + // The guard in stripLegalSuffix: a name that is nothing but a suffix + // keeps its token rather than canonicalizing to "", which would merge + // every such company together. + {"suffix-only name survives", "Co", "co"}, + {"limited alone survives", "Limited", "limited"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := canonicalCompany(tc.in); got != tc.want { + t.Errorf("canonicalCompany(%q)\n got: %q\nwant: %q", tc.in, got, tc.want) + } + }) + } +} + +func TestCanonicalCompanyMustMerge(t *testing.T) { + pairs := []struct { + name string + a, b string + }{ + {"legal suffix", "Acme, Inc.", "Acme"}, + {"corp vs corporation", "Acme Corp", "Acme Corporation"}, + {"case", "ACME", "acme"}, + {"ampersand vs and", "Johnson & Johnson", "Johnson and Johnson"}, + {"curly vs straight apostrophe", "Macy\u2019s", "Macy's"}, + {"padded whitespace", " Acme ", "Acme"}, + {"internal double space", "Acme Labs", "Acme Labs"}, + {"decorative emoji", "⭐ Acme", "Acme"}, + } + + for _, tc := range pairs { + t.Run(tc.name, func(t *testing.T) { + if got, want := canonicalCompany(tc.a), canonicalCompany(tc.b); got != want { + t.Errorf("expected these to canonicalize the same:\n a: %q -> %q\n b: %q -> %q", + tc.a, got, tc.b, want) + } + }) + } +} + +func TestCanonicalCompanyMustNotMerge(t *testing.T) { + pairs := []struct { + name string + a, b string + }{ + {"different employers", "Acme", "Globex"}, + {"parent vs subsidiary naming", "Acme Labs", "Acme"}, + {"similar prefix", "Acme", "Acme Health"}, + } + + for _, tc := range pairs { + t.Run(tc.name, func(t *testing.T) { + if got, other := canonicalCompany(tc.a), canonicalCompany(tc.b); got == other { + t.Errorf("these are different employers but both canonicalized to %q\n a: %q\n b: %q", + got, tc.a, tc.b) + } + }) + } +} + +func TestCanonicalPosition(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"plain", "Software Engineer Intern", "software engineer intern"}, + {"lock emoji stripped", "🔒 Software Engineer Intern", "software engineer intern"}, + {"sparkle emoji stripped", "🆕 Backend Engineer", "backend engineer"}, + {"trailing period stripped", "Software Engineer Intern.", "software engineer intern"}, + {"whitespace collapsed", "Software Engineer", "software engineer"}, + {"em dash folded", "Engineer\u2014Backend", "engineer-backend"}, + {"zero width joiner removed", "Soft\u200dware Engineer", "software engineer"}, + + // "New Grad" is a meaningful title in this domain, not a badge - the + // leading word must survive. + {"new grad preserved", "New Grad Software Engineer", "new grad software engineer"}, + + // Years and parenthetical qualifiers distinguish real jobs and are + // left untouched in the middle of a title. + {"year preserved", "SWE Intern Summer 2027", "swe intern summer 2027"}, + {"qualifier preserved", "Software Engineer (Backend)", "software engineer (backend"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := canonicalPosition(tc.in); got != tc.want { + t.Errorf("canonicalPosition(%q)\n got: %q\nwant: %q", tc.in, got, tc.want) + } + }) + } +} + +func TestCanonicalPositionMustNotMerge(t *testing.T) { + pairs := []struct { + name string + a, b string + }{ + { + "different years are different jobs", + "SWE Intern Summer 2026", + "SWE Intern Summer 2027", + }, + { + "different specializations", + "Software Engineer (Backend)", + "Software Engineer (Frontend)", + }, + { + "seniority", + "Software Engineer", + "Senior Software Engineer", + }, + { + "new grad is not the same as intern", + "New Grad Software Engineer", + "Software Engineer Intern", + }, + { + // Deliberately unmerged: expanding SWE to Software Engineer is + // fuzzy matching, and canonicalURL already covers the duplicates + // that matter. If this ever starts failing, someone added a + // synonym rule - make sure that was intentional. + "abbreviation left alone by design", + "SWE Intern", + "Software Engineer Intern", + }, + } + + for _, tc := range pairs { + t.Run(tc.name, func(t *testing.T) { + if got, other := canonicalPosition(tc.a), canonicalPosition(tc.b); got == other { + t.Errorf("these are different roles but both canonicalized to %q\n a: %q\n b: %q", + got, tc.a, tc.b) + } + }) + } +} diff --git a/backend/internal/jobs/dedupe.go b/backend/internal/jobs/dedupe.go new file mode 100644 index 0000000..e76808f --- /dev/null +++ b/backend/internal/jobs/dedupe.go @@ -0,0 +1,88 @@ +package jobs + +// Deduplication: collapsing rows that newID decided are the same posting. +// +// Canonicalization alone changes nothing a user can see. It only makes two +// spellings of one job hash to the same ID; without this pass both rows are +// still in the slice, so the dashboard still renders the job twice - now with +// duplicate React keys as a bonus. This file is the half that actually +// removes them. + +// Dedupe collapses jobs sharing an ID into one record, merging their fields, +// and returns the result in first-seen order. +// +// Ordering is the subtle part. The obvious implementation - group everything +// into a map, then range over the map to build the output - is broken here, +// because Go deliberately randomizes map iteration order. The list would come +// out shuffled differently on every scrape, and GET /jobs pages it with plain +// offsets: a reader who fetched offset=0 before a re-scrape and offset=20 +// after would see some jobs twice and miss others entirely. So the map here +// only ever holds indexes into the output slice; the slice itself is built by +// walking the input in order, which makes the result stable as long as the +// sources are. +// +// Which record wins is therefore decided by input order. The caller +// concatenates speedyapply before simplifyjobs, so speedyapply is the primary +// source - a deliberate choice, since it's the one carrying salary data. +func Dedupe(all []Job) []Job { + out := make([]Job, 0, len(all)) + indexByID := make(map[string]int, len(all)) + + for _, job := range all { + // An empty ID means newID had nothing usable to work with. Keeping + // such rows separate is the safe reading: collapsing them all into + // one entry would hide real postings. + if job.ID == "" { + out = append(out, job) + continue + } + + if i, seen := indexByID[job.ID]; seen { + out[i] = mergeJobs(out[i], job) + continue + } + + indexByID[job.ID] = len(out) + out = append(out, job) + } + + return out +} + +// mergeJobs folds a duplicate into the record already kept for that ID. +// +// Merging rather than discarding the duplicate is worth the effort because +// the two sources carry different data: speedyapply lists salary, SimplifyJobs +// usually doesn't. Collapsing them produces a more complete row than either +// source had alone, so dedup doubles as enrichment. +// +// primary wins every field it has a value for; secondary only fills blanks. +// Closed is the one exception - see below. +func mergeJobs(primary, secondary Job) Job { + merged := primary + + merged.Company = firstNonEmpty(primary.Company, secondary.Company) + merged.Position = firstNonEmpty(primary.Position, secondary.Position) + merged.Location = firstNonEmpty(primary.Location, secondary.Location) + merged.Salary = firstNonEmpty(primary.Salary, secondary.Salary) + merged.PostingURL = firstNonEmpty(primary.PostingURL, secondary.PostingURL) + merged.Age = firstNonEmpty(primary.Age, secondary.Age) + merged.SourceRepo = firstNonEmpty(primary.SourceRepo, secondary.SourceRepo) + merged.SourceSection = firstNonEmpty(primary.SourceSection, secondary.SourceSection) + + // A posting counts as closed only when every source agrees it is. One + // repo lagging behind on marking a role closed is common; the cost of + // trusting the stale "open" is a dead link, while the cost of trusting a + // stale "closed" is hiding a job someone could still have applied to. + merged.Closed = primary.Closed && secondary.Closed + + return merged +} + +// firstNonEmpty returns a if it has content, otherwise b. +func firstNonEmpty(a, b string) string { + if a != "" { + return a + } + return b +} diff --git a/backend/internal/jobs/dedupe_test.go b/backend/internal/jobs/dedupe_test.go new file mode 100644 index 0000000..4677447 --- /dev/null +++ b/backend/internal/jobs/dedupe_test.go @@ -0,0 +1,199 @@ +package jobs + +import ( + "reflect" + "testing" +) + +// TestNewIDURLFirst pins down the identity scheme: when a link is present it +// alone decides identity, so differences in company and title wording stop +// mattering. +func TestNewIDURLFirst(t *testing.T) { + speedy := newID("Acme, Inc.", "Software Engineer Intern", "Seattle, WA", + "https://boards.greenhouse.io/acme/jobs/1?utm_source=github") + simplify := newID("Acme", "SWE Intern", "Seattle, WA", + "http://www.boards.greenhouse.io/acme/jobs/1/") + + if speedy != simplify { + t.Errorf("same posting from two sources produced different IDs: %q vs %q", speedy, simplify) + } +} + +func TestNewIDDifferentURLsStaySeparate(t *testing.T) { + a := newID("Acme", "SWE Intern", "Seattle, WA", "https://boards.greenhouse.io/acme?gh_jid=1") + b := newID("Acme", "SWE Intern", "Seattle, WA", "https://boards.greenhouse.io/acme?gh_jid=2") + + if a == b { + t.Errorf("two different Greenhouse postings collapsed to the same ID %q", a) + } +} + +// TestNewIDFallbackIncludesLocation guards the bug the old three-field key +// had: closed listings carry no URL, so without location in the key every +// closed role at one company with one title merged into a single entry. +func TestNewIDFallbackIncludesLocation(t *testing.T) { + seattle := newID("Acme", "SWE Intern", "Seattle, WA", "") + austin := newID("Acme", "SWE Intern", "Austin, TX", "") + + if seattle == austin { + t.Error("closed listings in different cities collapsed to one ID") + } +} + +func TestNewIDFallbackCanonicalizes(t *testing.T) { + a := newID("Acme, Inc.", "Software Engineer", "Seattle, WA", "") + b := newID("ACME", "Software Engineer", "seattle, wa", "") + + if a != b { + t.Errorf("same linkless posting produced different IDs: %q vs %q", a, b) + } +} + +// TestNewIDSchemesDoNotCollide checks the "u|" / "cpl|" namespacing. +func TestNewIDSchemesDoNotCollide(t *testing.T) { + withURL := newID("Acme", "SWE Intern", "Seattle, WA", "https://acme.com/j/1") + withoutURL := newID("Acme", "SWE Intern", "Seattle, WA", "") + + if withURL == withoutURL { + t.Error("URL identity collided with company/position/location identity") + } +} + +func TestDedupeCollapsesDuplicates(t *testing.T) { + all := []Job{ + {ID: "a", Company: "Acme", Position: "SWE"}, + {ID: "b", Company: "Globex", Position: "SWE"}, + {ID: "a", Company: "Acme", Position: "SWE"}, + } + + got := Dedupe(all) + if len(got) != 2 { + t.Fatalf("expected 2 jobs after dedupe, got %d: %+v", len(got), got) + } +} + +// TestDedupePreservesFirstSeenOrder is the regression test for the map +// iteration trap. Offset pagination slices this list, so a shuffled order +// between scrapes would make readers skip and repeat jobs across pages. +func TestDedupePreservesFirstSeenOrder(t *testing.T) { + all := []Job{ + {ID: "c", Company: "Third"}, + {ID: "a", Company: "First"}, + {ID: "b", Company: "Second"}, + {ID: "a", Company: "First"}, // duplicate, must not move "a" later + } + + got := Dedupe(all) + want := []string{"c", "a", "b"} + + if len(got) != len(want) { + t.Fatalf("expected %d jobs, got %d", len(want), len(got)) + } + for i, id := range want { + if got[i].ID != id { + t.Errorf("position %d: got ID %q, want %q", i, got[i].ID, id) + } + } +} + +// TestDedupeIsDeterministic runs the same input repeatedly, since a +// map-iteration bug would only show up intermittently. +func TestDedupeIsDeterministic(t *testing.T) { + all := []Job{} + for _, id := range []string{"e", "d", "c", "b", "a", "e", "d", "c"} { + all = append(all, Job{ID: id, Company: id}) + } + + first := Dedupe(all) + for i := 0; i < 50; i++ { + if got := Dedupe(all); !reflect.DeepEqual(got, first) { + t.Fatalf("run %d differed from the first run:\n got: %+v\nfirst: %+v", i, got, first) + } + } +} + +// TestDedupeEnriches covers the reason merging beats discarding: the sources +// carry complementary data. +func TestDedupeEnriches(t *testing.T) { + all := []Job{ + {ID: "a", Company: "Acme", Position: "SWE", SourceRepo: "speedyapply/x"}, + {ID: "a", Company: "Acme", Position: "SWE", Salary: "$120k", Age: "3d", Location: "Seattle, WA"}, + } + + got := Dedupe(all) + if len(got) != 1 { + t.Fatalf("expected 1 job, got %d", len(got)) + } + if got[0].Salary != "$120k" { + t.Errorf("salary should have been filled from the duplicate, got %q", got[0].Salary) + } + if got[0].Location != "Seattle, WA" { + t.Errorf("location should have been filled from the duplicate, got %q", got[0].Location) + } + if got[0].SourceRepo != "speedyapply/x" { + t.Errorf("primary's SourceRepo should win, got %q", got[0].SourceRepo) + } +} + +// TestDedupeClosedRequiresAgreement covers the deliberate bias toward showing +// jobs rather than hiding them. +func TestDedupeClosedRequiresAgreement(t *testing.T) { + tests := []struct { + name string + a, b bool + wantClosed bool + }{ + {"both open", false, false, false}, + {"primary open, duplicate closed", false, true, false}, + {"primary closed, duplicate open", true, false, false}, + {"both closed", true, true, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := Dedupe([]Job{ + {ID: "a", Closed: tc.a}, + {ID: "a", Closed: tc.b}, + }) + if len(got) != 1 { + t.Fatalf("expected 1 job, got %d", len(got)) + } + if got[0].Closed != tc.wantClosed { + t.Errorf("Closed = %v, want %v", got[0].Closed, tc.wantClosed) + } + }) + } +} + +// TestDedupeKeepsEmptyIDsSeparate - collapsing every unidentifiable row into +// one entry would hide real postings. +func TestDedupeKeepsEmptyIDsSeparate(t *testing.T) { + all := []Job{ + {ID: "", Company: "Acme"}, + {ID: "", Company: "Globex"}, + } + + if got := Dedupe(all); len(got) != 2 { + t.Errorf("expected both empty-ID jobs kept, got %d", len(got)) + } +} + +func TestDedupeEmptyInput(t *testing.T) { + if got := Dedupe(nil); len(got) != 0 { + t.Errorf("expected empty result, got %+v", got) + } +} + +// TestDedupeKeepsDistinctRolesAtOneCompany covers SimplifyJobs' "↳" rows, +// which share a company cell but are genuinely different roles. +func TestDedupeKeepsDistinctRolesAtOneCompany(t *testing.T) { + all := []Job{ + {ID: newID("Acme", "Software Engineer Intern", "Seattle, WA", ""), Company: "Acme"}, + {ID: newID("Acme", "Data Scientist Intern", "Seattle, WA", ""), Company: "Acme"}, + {ID: newID("Acme", "Product Manager Intern", "Seattle, WA", ""), Company: "Acme"}, + } + + if got := Dedupe(all); len(got) != 3 { + t.Errorf("three distinct roles at one company collapsed to %d", len(got)) + } +} diff --git a/backend/internal/jobs/github.go b/backend/internal/jobs/github.go new file mode 100644 index 0000000..c0cc0fe --- /dev/null +++ b/backend/internal/jobs/github.go @@ -0,0 +1,51 @@ +package jobs + +import ( + "context" + "fmt" + "io" + "net/http" +) + +// fetchReadme downloads one file's raw text from a public GitHub repo, using +// GitHub's REST "get repository content" endpoint. Passing the "raw" Accept +// header tells GitHub to hand back the file's plain text directly, instead +// of a JSON envelope with the content base64-encoded - one less decoding +// step for a beginner-friendly scraper. +// +// token is optional: an empty string still works, just at GitHub's lower +// unauthenticated rate limit (60 requests/hour per caller). These days the +// caller is the api Lambda's GET /jobs route, which fetches both files at +// most once every 5 minutes thanks to its in-memory cache - so a token +// isn't strictly required, but it's wired up (GITHUB_TOKEN_SSM, same +// pattern as LEETCODE_SESSION) to keep well clear of that limit. +func fetchReadme(ctx context.Context, token, owner, repo, ref, path string) (string, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/%s?ref=%s", owner, repo, path, ref) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + // GitHub rejects API requests that don't send a User-Agent. + req.Header.Set("User-Agent", "kronos-job-board") + req.Header.Set("Accept", "application/vnd.github.v3.raw") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("github api %s/%s@%s/%s: %d %s", owner, repo, ref, path, resp.StatusCode, body) + } + return string(body), nil +} diff --git a/backend/internal/jobs/html.go b/backend/internal/jobs/html.go new file mode 100644 index 0000000..38d2eea --- /dev/null +++ b/backend/internal/jobs/html.go @@ -0,0 +1,54 @@ +package jobs + +import ( + "html" + "regexp" + "strings" +) + +// Both READMEs are Markdown files, but the job tables inside them are +// written as raw HTML (