From 6e197bfa552f7cc7a1c96c3907039af1566fd84d Mon Sep 17 00:00:00 2001 From: gersondiaz12 Date: Sat, 8 Aug 2026 22:47:39 -0500 Subject: [PATCH 1/8] feat: job board scraping pipeline + email-sync lambda scaffold Adds a new jobsync Lambda (EventBridge, every minute) that scrapes new-grad and internship postings from the SpeedyApply and SimplifyJobs GitHub READMEs and upserts them into a new `jobs` Postgres table, keyed by a stable hash of company+position+link so re-scrapes update in place instead of duplicating rows (and so a future email-sync pass can match a job by that same ID). The api Lambda serves them via GET /jobs, rendered on a new Job Board dashboard card/modal with search and section filtering. Also stages the emailsync Lambda scaffold (Gmail/Outlook OA + interview + rejection detection stub) and its terraform wiring, laying the groundwork to attach application-status updates to jobs on the dashboard. --- backend/build.sh | 2 +- backend/cmd/emailsync/main.go | 329 +++++++++++++++++++++++ backend/cmd/jobsync/main.go | 97 +++++++ backend/cmd/scrapetest/main.go | 75 ++++++ backend/db/schema.sql | 22 ++ backend/internal/api/api.go | 3 + backend/internal/api/jobs.go | 13 + backend/internal/jobs/github.go | 51 ++++ backend/internal/jobs/html.go | 54 ++++ backend/internal/jobs/job.go | 50 ++++ backend/internal/jobs/sections.go | 70 +++++ backend/internal/jobs/simplifyjobs.go | 107 ++++++++ backend/internal/jobs/speedyapply.go | 105 ++++++++ backend/internal/store/jobs.go | 93 +++++++ src/App.tsx | 5 + src/lib/api.ts | 15 ++ src/modals/JobBoardModal.tsx | 137 ++++++++++ src/sections/JobBoardCard.tsx | 63 +++++ terraform/main.tf | 51 +++- terraform/modules/scheduler/main.tf | 36 +++ terraform/modules/scheduler/variables.tf | 28 ++ terraform/variables.tf | 10 + 22 files changed, 1409 insertions(+), 7 deletions(-) create mode 100644 backend/cmd/emailsync/main.go create mode 100644 backend/cmd/jobsync/main.go create mode 100644 backend/cmd/scrapetest/main.go create mode 100644 backend/internal/api/jobs.go create mode 100644 backend/internal/jobs/github.go create mode 100644 backend/internal/jobs/html.go create mode 100644 backend/internal/jobs/job.go create mode 100644 backend/internal/jobs/sections.go create mode 100644 backend/internal/jobs/simplifyjobs.go create mode 100644 backend/internal/jobs/speedyapply.go create mode 100644 backend/internal/store/jobs.go create mode 100644 src/modals/JobBoardModal.tsx create mode 100644 src/sections/JobBoardCard.tsx diff --git a/backend/build.sh b/backend/build.sh index 768318f..c99a856 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 jobsync; 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/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/jobsync/main.go b/backend/cmd/jobsync/main.go new file mode 100644 index 0000000..c171edd --- /dev/null +++ b/backend/cmd/jobsync/main.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "log" + + "github.com/aws/aws-lambda-go/lambda" + + "kronos/internal/config" + "kronos/internal/jobs" + "kronos/internal/store" +) + +/* +jobsync is a scheduled Lambda for the Job Board dashboard item. EventBridge +invokes it on a cadence (see terraform/modules/scheduler, default hourly) +with an empty event - this job doesn't read anything from the event, it just +re-scrapes both READMEs from scratch every time it runs and saves the result +to Postgres. + +Pipeline for one run: + main -> run -> jobs.FetchSpeedyApplyJobs + jobs.FetchSimplifyJobsNewGrad -> toRows -> store.UpsertJobs + +The api Lambda's GET /jobs route (backend/internal/api/jobs.go) never talks +to GitHub itself - it only reads whatever is currently in the `jobs` table, +the same way it reads `problems`/`solves` for the LeetCode side of the +dashboard. jobsync is the only thing that writes to that table. +*/ + +// run does one full scrape-and-store pass. +func run(ctx context.Context, db *store.Postgres, githubToken string) error { + // Step 1: scrape each source independently, so one source failing (e.g. + // GitHub is briefly down) doesn't block the other from being saved. + speedyJobs, err := jobs.FetchSpeedyApplyJobs(ctx, githubToken) + if err != nil { + log.Printf("speedyapply: %v", err) + } + simplifyJobs, err := jobs.FetchSimplifyJobsNewGrad(ctx, githubToken) + if err != nil { + log.Printf("simplifyjobs: %v", err) + } + + all := append(speedyJobs, simplifyJobs...) + log.Printf("scraped %d jobs (%d speedyapply, %d simplifyjobs)", len(all), len(speedyJobs), len(simplifyJobs)) + + // Step 2: upsert into Postgres. See store.UpsertJobs - a job that's + // already in the table gets its last_seen_at refreshed instead of being + // inserted a second time. + n, err := db.UpsertJobs(ctx, toRows(all)) + if err != nil { + return err + } + log.Printf("upserted %d jobs", n) + return nil +} + +// toRows converts the scrape layer's Job structs (backend/internal/jobs) +// into the store layer's JobRow structs (backend/internal/store). Keeping +// these as two separate types - even though their fields line up 1:1 today - +// means the DB schema and the GitHub-scraping logic can change independently +// of each other; this function is the only place that has to know about both. +func toRows(items []jobs.Job) []store.JobRow { + rows := make([]store.JobRow, len(items)) + for i, j := range items { + rows[i] = store.JobRow{ + ID: j.ID, + Company: j.Company, + Position: j.Position, + Location: j.Location, + Salary: j.Salary, + PostingURL: j.PostingURL, + Age: j.Age, + Closed: j.Closed, + SourceRepo: j.SourceRepo, + SourceSection: j.SourceSection, + } + } + return rows +} + +func main() { + ctx := context.Background() + + db, err := store.NewPostgres(ctx, config.Get(ctx, "DATABASE_URL")) + if err != nil { + log.Fatal(err) + } + + lambda.Start(func(ctx context.Context) error { + // Optional: config.Get checks the plain GITHUB_TOKEN env var first, + // then a GITHUB_TOKEN_SSM secret if one is configured. Neither is + // set today - fetchReadme works fine unauthenticated too, see its + // comment in backend/internal/jobs/github.go. + githubToken := config.Get(ctx, "GITHUB_TOKEN") + return run(ctx, db, githubToken) + }) +} diff --git a/backend/cmd/scrapetest/main.go b/backend/cmd/scrapetest/main.go new file mode 100644 index 0000000..b3ca57a --- /dev/null +++ b/backend/cmd/scrapetest/main.go @@ -0,0 +1,75 @@ +// 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 jobsync 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" + + "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) + + fmt.Printf("\nTotal: %d jobs scraped\n", len(speedyJobs)+len(simplifyJobs)) +} diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 8b1d171..eb3dbfa 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -161,6 +161,28 @@ create table if not exists sd_solves ( primary key (user_id, slug) ); +-- Job Board: one row per posting scraped from public GitHub job-list READMEs +-- (see backend/internal/jobs and backend/cmd/jobsync, a scheduled Lambda). +-- id is a stable hash of company+position+posting_url (see jobs.newID) so +-- re-scraping the same posting on the next run updates the row instead of +-- duplicating it, and so a future email-sync Lambda can recompute the same +-- hash from a parsed email to match it back to a specific job. +create table if not exists jobs ( + id text primary key, + company text not null, + position text not null, + location text not null default '', + salary text not null default '', + posting_url text not null default '', + age text not null default '', -- raw "3d" / "1mo" label copied from the README, not parsed into a real date + closed boolean not null default false, + source_repo text not null, + source_section text not null, + first_seen_at timestamptz not null default now(), + last_seen_at timestamptz not null default now() +); +create index if not exists idx_jobs_last_seen on jobs(last_seen_at desc); + create index if not exists idx_solves_user on solves(user_id); create index if not exists idx_submissions_pending on submissions(enriched) where not enriched; create index if not exists idx_solutions_user_problem on solutions(user_id, problem_id); diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 428b1ae..61968e3 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -162,6 +162,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) + 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..7d6024d --- /dev/null +++ b/backend/internal/api/jobs.go @@ -0,0 +1,13 @@ +package api + +import "context" + +// getJobs serves GET /jobs for the Job Board dashboard card. It reads +// straight from the `jobs` table in Postgres, the same way every other GET +// route reads its own table - jobsync (a separate scheduled Lambda) is the +// only thing that ever writes to it. See backend/cmd/jobsync and +// backend/internal/store/jobs.go. +func (a *API) getJobs(ctx context.Context) (response, error) { + rows, err := a.Store.Jobs(ctx, 500) + return dataOrError(rows, err) +} diff --git a/backend/internal/jobs/github.go b/backend/internal/jobs/github.go new file mode 100644 index 0000000..ce0d7d7 --- /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). jobsync fetches +// two files roughly once an hour (see terraform/modules/scheduler), so it +// runs fine without a token. Pass one via the GITHUB_TOKEN env var (or a +// GITHUB_TOKEN_SSM parameter, same pattern as LEETCODE_SESSION) if that ever +// changes. +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 (// rows (the just has column labels). +var ( + tbodyPattern = regexp.MustCompile(`(?is)(.*?)`) + trPattern = regexp.MustCompile(`(?is)(.*?)`) + tdPattern = regexp.MustCompile(`(?is)`) +) + +// parseSimplifyHTMLTable extracts every job row out of section's HTML table. +// Each row has 5 cells: Company, Role, Location, Application, Age. +func parseSimplifyHTMLTable(section, sectionLabel string) []Job { + body := tbodyPattern.FindStringSubmatch(section) + if body == nil { + return nil + } + + var jobs []Job + lastCompany := "" // see the "↳" handling below + for _, tr := range trPattern.FindAllStringSubmatch(body[1], -1) { + cells := tdPattern.FindAllStringSubmatch(tr[1], -1) + if len(cells) < 5 { + continue + } + + // SimplifyJobs collapses repeat roles at the same company: the first + // row has the company name, and every following row for that same + // company just has "↳" in the Company cell. Reuse the last real + // company name whenever we see that marker. + company := cleanText(stripTags(cells[0][1])) + if company == "↳" || company == "" { + company = lastCompany + } else { + lastCompany = company + } + if company == "" { + continue + } + + position := cleanText(stripTags(cells[1][1])) + location := cleanText(stripTags(cells[2][1])) + applicationCell := cells[3][1] + age := cleanText(stripTags(cells[4][1])) + + // A closed application's cell is just a 🔒 emoji with no link. + closed := strings.Contains(applicationCell, "🔒") + postingURL := firstHref(applicationCell) + + jobs = append(jobs, Job{ + ID: newID(company, position, postingURL), + Company: company, + Position: position, + Location: location, + PostingURL: postingURL, + Age: age, + Closed: closed, + SourceRepo: simplifyOwner + "/" + simplifyRepo, + SourceSection: sectionLabel, + }) + } + return jobs +} diff --git a/backend/internal/jobs/speedyapply.go b/backend/internal/jobs/speedyapply.go new file mode 100644 index 0000000..516fbd8 --- /dev/null +++ b/backend/internal/jobs/speedyapply.go @@ -0,0 +1,105 @@ +package jobs + +import ( + "context" + "regexp" + "strings" +) + +const ( + speedyApplyOwner = "speedyapply" + speedyApplyRepo = "2027-SWE-College-Jobs" + speedyApplyRef = "main" + speedyApplyPath = "README.md" +) + +// FetchSpeedyApplyJobs downloads the SpeedyApply internship README and turns +// its Markdown tables into Job structs. +func FetchSpeedyApplyJobs(ctx context.Context, githubToken string) ([]Job, error) { + md, err := fetchReadme(ctx, githubToken, speedyApplyOwner, speedyApplyRepo, speedyApplyRef, speedyApplyPath) + if err != nil { + return nil, err + } + return parseSpeedyApplyMarkdown(md), nil +} + +// pipeRowPattern matches one Markdown table row: a line that starts and ends +// with "|", e.g. "| Roblox | SWE Intern | SF | $60/hr | | 1d |". +// This also matches the header row ("| Company | Position | ... |") and the +// separator row ("|---|---|...|") - parseSpeedyApplyMarkdown filters those +// out below, since only real data rows contain an link. +var pipeRowPattern = regexp.MustCompile(`(?m)^\|(.+)\|\s*$`) + +// parseSpeedyApplyMarkdown walks the "USA SWE Internships" section of the +// README - the one linked in the issue - and extracts every job row from +// every subsection's table (FAANG+, Quant, Other). +func parseSpeedyApplyMarkdown(md string) []Job { + section := sectionBounds(md, "## 2027 USA SWE Internships", "## ") + if section == "" { + return nil + } + + var jobs []Job + for _, sub := range splitSubsections(section, "### ") { + for _, m := range pipeRowPattern.FindAllStringSubmatch(sub.Body, -1) { + inner := m[1] + if !strings.Contains(inner, " now() - interval '6 hours' + order by first_seen_at desc + limit $1`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []JobRow{} + for rows.Next() { + var r JobRow + if err := rows.Scan(&r.ID, &r.Company, &r.Position, &r.Location, &r.Salary, &r.PostingURL, &r.Age, &r.Closed, &r.SourceRepo, &r.SourceSection); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} diff --git a/src/App.tsx b/src/App.tsx index 9267e0e..bd24cbd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,6 +28,8 @@ import { LeaderboardCard } from "./sections/LeaderboardCard"; import { MyFriendsCard } from "./sections/MyFriendsCard"; import { CurrentStreakCard } from "./sections/CurrentStreakCard"; import { RecentActivityCard } from "./sections/RecentActivityCard"; +import { JobBoardCard } from "./sections/JobBoardCard"; +import { JobBoardModal } from "./modals/JobBoardModal"; import { ProgressModal } from "./modals/ProgressModal"; import { MySolutionModal } from "./modals/MySolutionModal"; import { CalendarModal } from "./modals/CalendarModal"; @@ -91,6 +93,7 @@ function App({ const [sdComponents, setSdComponents] = useState(false); const [cloudOpen, setCloudOpen] = useState(false); const [networkingOpen, setNetworkingOpen] = useState(false); + const [jobBoardOpen, setJobBoardOpen] = useState(false); // Admin-only: warn when the LeetCode session token is near/at expiry. const [sessionExpiry, setSessionExpiry] = useState(""); useEffect(() => { @@ -178,6 +181,7 @@ function App({ setCloudOpen(true)} /> setNetworkingOpen(true)} /> + setJobBoardOpen(true)} /> @@ -214,6 +218,7 @@ function App({ {sdComponents && setSdComponents(false)} />} {cloudOpen && setCloudOpen(false)} />} {networkingOpen && setNetworkingOpen(false)} />} + {jobBoardOpen && setJobBoardOpen(false)} />} {modal === "calendar" && ( call("/me/theme", t, { method: "POST", body: JSON.stringify({ theme }) }), + jobs: (t: TokenFn) => call("/jobs", t), progress: (t: TokenFn) => call("/me/progress", t), leaderboard: (t: TokenFn) => call("/leaderboard", t), recent: (t: TokenFn) => call("/recent", t), @@ -102,6 +103,20 @@ export type MeResponse = { season: number; requestedUsername?: string; }; +// One job posting, as returned by GET /jobs. Matches store.JobRow on the Go +// side (backend/internal/store/jobs.go) field for field. +export type ApiJob = { + id: string; + company: string; + position: string; + location: string; + salary?: string; + postingUrl?: string; + age?: string; + closed: boolean; + sourceRepo: string; + sourceSection: string; +}; export type ApiProblem = { slug: string; title: string; diff --git a/src/modals/JobBoardModal.tsx b/src/modals/JobBoardModal.tsx new file mode 100644 index 0000000..c220da4 --- /dev/null +++ b/src/modals/JobBoardModal.tsx @@ -0,0 +1,137 @@ +import { useEffect, useMemo, useState } from "react"; +import { Search, ExternalLink, Lock } from "lucide-react"; +import { Modal } from "../components/Modal"; +import { useData } from "../data/source"; +import { api, type ApiJob } from "../lib/api"; + +// JobBoardModal is the full-list view behind the Job Board card: every job +// currently in Postgres (from GET /jobs), with a text search and a +// section filter (e.g. "FAANG+", "Software Engineering"). It fetches its +// own data independently of JobBoardCard's preview fetch, the same way +// FriendsModal/LeaderboardModal fetch their own full lists. +export function JobBoardModal({ onClose }: { onClose: () => void }) { + const { getToken } = useData(); + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(true); + const [query, setQuery] = useState(""); + const [section, setSection] = useState(null); + + useEffect(() => { + api + .jobs(getToken) + .then((rows) => setJobs(rows ?? [])) + .catch(() => setJobs([])) + .finally(() => setLoading(false)); + }, [getToken]); + + // The chip bar: one chip per distinct sourceSection seen in the data + // (e.g. "FAANG+", "Quant", "Other", "Software Engineering", "Product + // Management"), in the order they first appear. + const sections = useMemo(() => { + const seen: string[] = []; + for (const j of jobs) if (!seen.includes(j.sourceSection)) seen.push(j.sourceSection); + return seen; + }, [jobs]); + + const q = query.trim().toLowerCase(); + const shown = jobs.filter((j) => { + if (section && j.sourceSection !== section) return false; + if (!q) return true; + return ( + j.company.toLowerCase().includes(q) || + j.position.toLowerCase().includes(q) || + j.location.toLowerCase().includes(q) + ); + }); + + return ( + +
+ + setQuery(e.target.value)} + placeholder="Search company, role, or location…" + className="w-full bg-transparent text-sm placeholder:text-muted-foreground focus:outline-none" + /> +
+ + {sections.length > 0 && ( +
+ + {sections.map((s) => ( + + ))} +
+ )} + +
+ + ); +} diff --git a/src/sections/JobBoardCard.tsx b/src/sections/JobBoardCard.tsx new file mode 100644 index 0000000..bcb77c3 --- /dev/null +++ b/src/sections/JobBoardCard.tsx @@ -0,0 +1,63 @@ +import { useEffect, useState } from "react"; +import { Briefcase } from "lucide-react"; +import { Card } from "../components/Card"; +import { useData } from "../data/source"; +import { api, type ApiJob } from "../lib/api"; + +// JobBoardCard is the dashboard tile for the Job Board feature. It shows a +// short preview (top 5) of new-grad/internship postings that the jobsync +// Lambda scraped from public GitHub job-list repos and saved to Postgres - +// this component never talks to GitHub, only to our own GET /jobs route. +// Clicking the card opens JobBoardModal for the full, searchable list. +export function JobBoardCard({ onOpen }: { onOpen: () => void }) { + const { getToken } = useData(); + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + api + .jobs(getToken) + .then((rows) => setJobs(rows ?? [])) + .catch(() => setJobs([])) + .finally(() => setLoading(false)); + }, [getToken]); + + return ( + +
+
Job Board
+ +
+

+ New grad & internship roles, scraped from GitHub every minute. +

+
    + {loading && ( +
  • Loading…
  • + )} + {!loading && jobs.length === 0 && ( +
  • + No jobs yet — check back soon. +
  • + )} + {jobs.slice(0, 5).map((j) => ( +
  • +
    +
    + {j.company} — {j.position} +
    +
    + {j.location} +
    +
    + {j.age && ( + + {j.age} + + )} +
  • + ))} +
+
+ ); +} diff --git a/terraform/main.tf b/terraform/main.tf index 834e25c..6cffa75 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -68,12 +68,25 @@ data "aws_ssm_parameter" "leetcode_session" { name = "/${var.project}/LEETCODE_SESSION" } +# Job Board scraping (backend/cmd/jobsync) calls the GitHub API once a +# minute for two repos. GitHub's unauthenticated rate limit is only 60 +# requests/hour, so at that cadence a token is required (5,000 req/hour +# authenticated) - create it once with: +# aws ssm put-parameter --name /kronos/GITHUB_TOKEN --type SecureString --value +data "aws_ssm_parameter" "github_token" { + name = "/${var.project}/GITHUB_TOKEN" +} + locals { secret_arns = [ module.ssm.arn, data.aws_ssm_parameter.clerk_secret.arn, data.aws_ssm_parameter.leetcode_session.arn, ] + jobsync_secret_arns = [ + module.ssm.arn, + data.aws_ssm_parameter.github_token.arn, + ] } module "api" { @@ -113,6 +126,28 @@ module "enrich" { } } +module "emailsync" { + source = "./modules/lambda" + name = "${var.project}-emailsync" + zip_path = var.emailsync_zip + ssm_parameter_arns = local.secret_arns + environment = { + DATABASE_URL_SSM = module.ssm.name + LEETCODE_SESSION_SSM = data.aws_ssm_parameter.leetcode_session.name + } +} + +module "jobsync" { + source = "./modules/lambda" + name = "${var.project}-jobsync" + zip_path = var.jobsync_zip + ssm_parameter_arns = local.jobsync_secret_arns + environment = { + DATABASE_URL_SSM = module.ssm.name + GITHUB_TOKEN_SSM = data.aws_ssm_parameter.github_token.name + } +} + module "apigateway" { source = "./modules/apigateway" name = "${var.project}-http" @@ -121,12 +156,16 @@ module "apigateway" { } module "scheduler" { - source = "./modules/scheduler" - name = var.project - sync_function_arn = module.sync.arn - sync_function_name = module.sync.function_name - enrich_function_arn = module.enrich.arn - enrich_function_name = module.enrich.function_name + source = "./modules/scheduler" + name = var.project + sync_function_arn = module.sync.arn + sync_function_name = module.sync.function_name + enrich_function_arn = module.enrich.arn + enrich_function_name = module.enrich.function_name + emailsync_function_arn = module.emailsync.arn + emailsync_function_name = module.emailsync.function_name + jobsync_function_arn = module.jobsync.arn + jobsync_function_name = module.jobsync.function_name } module "frontend" { diff --git a/terraform/modules/scheduler/main.tf b/terraform/modules/scheduler/main.tf index 9f1513f..e331b1f 100644 --- a/terraform/modules/scheduler/main.tf +++ b/terraform/modules/scheduler/main.tf @@ -16,7 +16,43 @@ resource "aws_lambda_permission" "sync" { source_arn = aws_cloudwatch_event_rule.sync.arn } +resource "aws_cloudwatch_event_rule" "emailsync" { + name = "${var.name}-emailsync" + schedule_expression = var.emailsync_schedule +} + +resource "aws_cloudwatch_event_target" "emailsync" { + rule = aws_cloudwatch_event_rule.emailsync.name + arn = var.emailsync_function_arn +} + +resource "aws_lambda_permission" "emailsync" { + statement_id = "AllowEmailsyncSchedule" + action = "lambda:InvokeFunction" + function_name = var.emailsync_function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.emailsync.arn +} + # The standalone enrich cron is intentionally removed: the sync Lambda already # runs the enricher inline every pass (enricher.Run), so a separate per-minute # enrich invocation was duplicate work (extra Lambda + KMS decrypts). The enrich # Lambda itself is left defined but un-triggered. + +resource "aws_cloudwatch_event_rule" "jobsync" { + name = "${var.name}-jobsync" + schedule_expression = var.jobsync_schedule +} + +resource "aws_cloudwatch_event_target" "jobsync" { + rule = aws_cloudwatch_event_rule.jobsync.name + arn = var.jobsync_function_arn +} + +resource "aws_lambda_permission" "jobsync" { + statement_id = "AllowJobsyncSchedule" + action = "lambda:InvokeFunction" + function_name = var.jobsync_function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.jobsync.arn +} diff --git a/terraform/modules/scheduler/variables.tf b/terraform/modules/scheduler/variables.tf index d427fb7..b8c4510 100644 --- a/terraform/modules/scheduler/variables.tf +++ b/terraform/modules/scheduler/variables.tf @@ -18,6 +18,19 @@ variable "enrich_function_name" { type = string } +variable "emailsync_function_arn" { + type = string +} + +variable "emailsync_function_name" { + type = string +} + +variable "emailsync_schedule" { + type = string + default = "rate(15 minutes)" +} + variable "sync_schedule" { type = string default = "rate(1 minute)" @@ -29,3 +42,18 @@ variable "enrich_schedule" { type = string default = "rate(1 minute)" } + +variable "jobsync_function_arn" { + type = string +} + +variable "jobsync_function_name" { + type = string +} + +# Every minute, same cadence as sync - see main.tf's comment on the +# GITHUB_TOKEN SSM parameter this cadence requires. +variable "jobsync_schedule" { + type = string + default = "rate(1 minute)" +} diff --git a/terraform/variables.tf b/terraform/variables.tf index 8587544..d175052 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -33,6 +33,16 @@ variable "enrich_zip" { default = "../backend/dist/enrich.zip" } +variable "emailsync_zip" { + type = string + default = "../backend/dist/emailsync.zip" +} + +variable "jobsync_zip" { + type = string + default = "../backend/dist/jobsync.zip" +} + variable "admin_clerk_id" { type = string default = "user_3EmSENtZcQZXU9q9ptLa7uedUGK" From 5f378b31352b3fb15f03d865c68c6eb3c2151cd2 Mon Sep 17 00:00:00 2001 From: gersondiaz12 Date: Mon, 10 Aug 2026 00:58:22 -0500 Subject: [PATCH 2/8] feat: paginate job board with keyset cursors GET /jobs now returns pages via a cursor (the last row's first_seen_at + id) instead of a flat LIMIT. jobsync writes to the jobs table every minute, so plain OFFSET pagination would let a newly-inserted row shift every later page by one slot, skipping or duplicating jobs between loads - anchoring to an actual row instead of a row count avoids that. JobBoardModal gets a Load more button that fetches and appends pages; JobBoardCard now asks the API for exactly 5 rows instead of fetching everything and slicing client-side. --- backend/internal/api/api.go | 2 +- backend/internal/api/jobs.go | 69 ++++++++++++++++++++++----- backend/internal/store/jobs.go | 86 +++++++++++++++++++--------------- src/lib/api.ts | 11 ++++- src/modals/JobBoardModal.tsx | 39 +++++++++++++-- src/sections/JobBoardCard.tsx | 4 +- 6 files changed, 156 insertions(+), 55 deletions(-) diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 61968e3..642ac46 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -163,7 +163,7 @@ func (a *API) member(ctx context.Context, method, path string, user store.User, return dataOrError(rows, err) case method == "GET" && path == "/jobs": - return a.getJobs(ctx) + return a.getJobs(ctx, query) case method == "GET" && path == "/leaderboard": rows, err := a.Store.Leaderboard(ctx, 100) diff --git a/backend/internal/api/jobs.go b/backend/internal/api/jobs.go index 7d6024d..24f947d 100644 --- a/backend/internal/api/jobs.go +++ b/backend/internal/api/jobs.go @@ -1,13 +1,60 @@ package api -import "context" - -// getJobs serves GET /jobs for the Job Board dashboard card. It reads -// straight from the `jobs` table in Postgres, the same way every other GET -// route reads its own table - jobsync (a separate scheduled Lambda) is the -// only thing that ever writes to it. See backend/cmd/jobsync and -// backend/internal/store/jobs.go. -func (a *API) getJobs(ctx context.Context) (response, error) { - rows, err := a.Store.Jobs(ctx, 500) - return dataOrError(rows, err) -} +import ( + "context" + "strconv" + "time" + + "kronos/internal/store" +) + +// getJobs serves GET /jobs for the Job Board dashboard card, one page at a +// time. The frontend sends ?limit=20, and after the first page, &beforeId= +// &beforeTime= copied from the previous response's nextCursor. jobsync (a +// separate scheduled Lambda) is the only thing that ever writes to the +// `jobs` table this reads from. +func (a *API) getJobs(ctx context.Context, query map[string]string) (response, error) { + // Default page size if the frontend doesn't specify one. + limit := 20 + if v := query["limit"]; v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + limit = n + } + } + + // query["beforeId"] is "" if that key isn't in the URL at all - which is + // exactly what we want to mean "no cursor yet, send me the first page." + beforeID := query["beforeId"] + var beforeTime time.Time + if v := query["beforeTime"]; v != "" { + if t, err := time.Parse(time.RFC3339, v); err == nil { + beforeTime = t + } + } + + rows, err := a.Store.Jobs(ctx, limit, beforeID, beforeTime) + if err != nil { + return serverError(err) + } + + // cursor is only ever used inside this one response, so it's declared + // right here instead of as a package-level type. + type cursor struct { + BeforeID string `json:"beforeId"` + BeforeTime time.Time `json:"beforeTime"` + } + + // If we got back a full page (exactly `limit` rows), there might be + // more - point the frontend at the last row we just sent. If we got back + // fewer than `limit`, we've reached the actual end of the list. + var next *cursor + if len(rows) == limit { + last := rows[len(rows)-1] + next = &cursor{BeforeID: last.ID, BeforeTime: last.FirstSeenAt} + } + + return reply(200, struct { + Jobs []store.JobRow `json:"jobs"` + NextCursor *cursor `json:"nextCursor"` + }{Jobs: rows, NextCursor: next}) +} \ No newline at end of file diff --git a/backend/internal/store/jobs.go b/backend/internal/store/jobs.go index 34fbaae..bf08321 100644 --- a/backend/internal/store/jobs.go +++ b/backend/internal/store/jobs.go @@ -2,6 +2,7 @@ package store import ( "context" + "time" "github.com/jackc/pgx/v5" ) @@ -13,16 +14,17 @@ import ( // was scraped. backend/cmd/jobsync is the glue that converts one into the // other. type JobRow struct { - ID string `json:"id"` - Company string `json:"company"` - Position string `json:"position"` - Location string `json:"location"` - Salary string `json:"salary,omitempty"` - PostingURL string `json:"postingUrl,omitempty"` - Age string `json:"age,omitempty"` - Closed bool `json:"closed"` - SourceRepo string `json:"sourceRepo"` - SourceSection string `json:"sourceSection"` + ID string `json:"id"` + Company string `json:"company"` + Position string `json:"position"` + Location string `json:"location"` + Salary string `json:"salary,omitempty"` + PostingURL string `json:"postingUrl,omitempty"` + Age string `json:"age,omitempty"` + Closed bool `json:"closed"` + SourceRepo string `json:"sourceRepo"` + SourceSection string `json:"sourceSection"` + FirstSeenAt time.Time `json:"firstSeenAt"` } // UpsertJobs inserts newly-seen jobs and refreshes last_seen_at for jobs @@ -62,32 +64,42 @@ func (p *Postgres) UpsertJobs(ctx context.Context, rows []JobRow) (int, error) { return len(rows), nil } -// Jobs returns the job board list, most-recently-first-seen-by-us first. -// Only jobs seen within the last 6 hours are returned: jobsync re-scrapes -// both READMEs in full roughly every hour (see terraform/modules/scheduler), -// so a job that's been missing for 6 hours straight has almost certainly -// closed or been removed from the source repo. Rows are never deleted -// though - a stale row just stops being returned here - so a future -// email-sync Lambda can still look one up by ID for history. -func (p *Postgres) Jobs(ctx context.Context, limit int) ([]JobRow, error) { - rows, err := p.pool.Query(ctx, ` - select id, company, position, location, salary, posting_url, age, closed, source_repo, source_section - from jobs - where last_seen_at > now() - interval '6 hours' - order by first_seen_at desc - limit $1`, limit) - if err != nil { - return nil, err - } - defer rows.Close() +// Jobs returns one page of the job board list, most-recently-first-seen +// first. Only jobs seen within the last 6 hours are returned (see the old +// comment on staleness - that part is unchanged). +// +// Pagination: pass beforeID = "" and a zero time.Time for the very first +// page. For every page after that, pass the ID and FirstSeenAt of the LAST +// job from the previous page - the api Lambda sends these back to the +// frontend as "nextCursor", and the frontend sends them right back as +// beforeId/beforeTime when it asks for more. That tells this query "give me +// jobs that come after that one in the sort order." +// +// This is why it's safe even though jobsync inserts new rows every minute: +// a plain "OFFSET 20" approach would shift every later page by one slot +// whenever a new job sneaks in between two page loads, causing skipped or +// duplicated rows. Anchoring to an actual row's position instead of a +// row count sidesteps that entirely. +func (p *Postgres) Jobs(ctx context.Context, limit int, beforeID string, beforeTime time.Time) ([]JobRow, error) { + rows, err := p.pool.Query(ctx, ` + select id, company, position, location, salary, posting_url, age, closed, source_repo, source_section, first_seen_at + from jobs + where last_seen_at > now() - interval '6 hours' + and ($1 = '' or first_seen_at < $2 or (first_seen_at = $2 and id < $1)) + order by first_seen_at desc, id desc + limit $3`, beforeID, beforeTime, limit) + if err != nil { + return nil, err + } + defer rows.Close() - out := []JobRow{} - for rows.Next() { - var r JobRow - if err := rows.Scan(&r.ID, &r.Company, &r.Position, &r.Location, &r.Salary, &r.PostingURL, &r.Age, &r.Closed, &r.SourceRepo, &r.SourceSection); err != nil { - return nil, err - } - out = append(out, r) - } - return out, rows.Err() + out := []JobRow{} + for rows.Next() { + var r JobRow + if err := rows.Scan(&r.ID, &r.Company, &r.Position, &r.Location, &r.Salary, &r.PostingURL, &r.Age, &r.Closed, &r.SourceRepo, &r.SourceSection, &r.FirstSeenAt); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() } diff --git a/src/lib/api.ts b/src/lib/api.ts index 27856e8..9a3737f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -49,7 +49,14 @@ export const api = { call("/me/username-request", t, { method: "POST", body: JSON.stringify({ username }) }), setTheme: (t: TokenFn, theme: string) => call("/me/theme", t, { method: "POST", body: JSON.stringify({ theme }) }), - jobs: (t: TokenFn) => call("/jobs", t), + jobs: (t: TokenFn, limit = 20, cursor?: JobsCursor | null) => { + const params = new URLSearchParams({ limit: String(limit) }); + if (cursor) { + params.set("beforeId", cursor.beforeId); + params.set("beforeTime", cursor.beforeTime); + } + return call(`/jobs?${params.toString()}`, t); + }, progress: (t: TokenFn) => call("/me/progress", t), leaderboard: (t: TokenFn) => call("/leaderboard", t), recent: (t: TokenFn) => call("/recent", t), @@ -117,6 +124,8 @@ export type ApiJob = { sourceRepo: string; sourceSection: string; }; +export type JobsCursor = { beforeId: string; beforeTime: string }; +export type JobsPage = { jobs: ApiJob[]; nextCursor: JobsCursor | null }; export type ApiProblem = { slug: string; title: string; diff --git a/src/modals/JobBoardModal.tsx b/src/modals/JobBoardModal.tsx index c220da4..47ac399 100644 --- a/src/modals/JobBoardModal.tsx +++ b/src/modals/JobBoardModal.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { Search, ExternalLink, Lock } from "lucide-react"; import { Modal } from "../components/Modal"; import { useData } from "../data/source"; -import { api, type ApiJob } from "../lib/api"; +import { api, type ApiJob, type JobsCursor } from "../lib/api"; // JobBoardModal is the full-list view behind the Job Board card: every job // currently in Postgres (from GET /jobs), with a text search and a @@ -13,17 +13,39 @@ export function JobBoardModal({ onClose }: { onClose: () => void }) { const { getToken } = useData(); const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [cursor, setCursor] = useState(null); + const [hasMore, setHasMore] = useState(false); const [query, setQuery] = useState(""); const [section, setSection] = useState(null); useEffect(() => { api - .jobs(getToken) - .then((rows) => setJobs(rows ?? [])) + .jobs(getToken, 20) + .then((page) => { + setJobs(page.jobs ?? []); + setCursor(page.nextCursor); + setHasMore(page.nextCursor !== null); + }) .catch(() => setJobs([])) .finally(() => setLoading(false)); }, [getToken]); + // Fetches the next page using the cursor from the last response, and + // appends it to what's already on screen (rather than replacing it). + const loadMore = () => { + if (!cursor || loadingMore) return; + setLoadingMore(true); + api + .jobs(getToken, 20, cursor) + .then((page) => { + setJobs((prev) => [...prev, ...(page.jobs ?? [])]); + setCursor(page.nextCursor); + setHasMore(page.nextCursor !== null); + }) + .finally(() => setLoadingMore(false)); + }; + // The chip bar: one chip per distinct sourceSection seen in the data // (e.g. "FAANG+", "Quant", "Other", "Software Engineering", "Product // Management"), in the order they first appear. @@ -131,6 +153,17 @@ export function JobBoardModal({ onClose }: { onClose: () => void }) { ); })} + {hasMore && ( +
  • + +
  • + )} ); diff --git a/src/sections/JobBoardCard.tsx b/src/sections/JobBoardCard.tsx index bcb77c3..9b75ca4 100644 --- a/src/sections/JobBoardCard.tsx +++ b/src/sections/JobBoardCard.tsx @@ -16,8 +16,8 @@ export function JobBoardCard({ onOpen }: { onOpen: () => void }) { useEffect(() => { api - .jobs(getToken) - .then((rows) => setJobs(rows ?? [])) + .jobs(getToken, 5) + .then((page) => setJobs(page.jobs ?? [])) .catch(() => setJobs([])) .finally(() => setLoading(false)); }, [getToken]); From d6f573b5e598c5da6426c72ea3301aab8e8caff3 Mon Sep 17 00:00:00 2001 From: gersondiaz12 Date: Mon, 10 Aug 2026 23:39:40 -0500 Subject: [PATCH 3/8] feat: move job board from Postgres to S3 cache Reverses the jobs table/DB approach in favor of one JSON file in S3 that jobsync overwrites every minute and the api Lambda reads on every GET /jobs request. Pagination changes from SQL keyset cursors to plain offset slicing over the in-memory list, since a single S3 read is already a stable snapshot for the life of one request - unlike the Postgres table, which was being written to every minute. jobsync no longer touches Postgres at all; adds a jobscache S3 module and S3 IAM support in the lambda module. --- backend/cmd/api/main.go | 10 ++ backend/cmd/jobsync/main.go | 86 +++++++--------- backend/db/schema.sql | 22 ----- backend/go.mod | 18 ++-- backend/go.sum | 22 +++++ backend/internal/api/api.go | 8 ++ backend/internal/api/jobs.go | 121 +++++++++++++---------- backend/internal/jobs/cache.go | 60 +++++++++++ backend/internal/jobs/job.go | 20 +++- backend/internal/store/jobs.go | 105 -------------------- src/lib/api.ts | 19 ++-- src/modals/JobBoardModal.tsx | 32 +++--- src/sections/JobBoardCard.tsx | 4 +- terraform/main.tf | 24 ++++- terraform/modules/jobscache/main.tf | 18 ++++ terraform/modules/jobscache/outputs.tf | 7 ++ terraform/modules/jobscache/variables.tf | 3 + terraform/modules/lambda/main.tf | 24 +++++ terraform/modules/lambda/variables.tf | 10 ++ 19 files changed, 338 insertions(+), 275 deletions(-) create mode 100644 backend/internal/jobs/cache.go delete mode 100644 backend/internal/store/jobs.go create mode 100644 terraform/modules/jobscache/main.tf create mode 100644 terraform/modules/jobscache/outputs.tf create mode 100644 terraform/modules/jobscache/variables.tf diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 47904f0..a8e72b9 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -6,6 +6,8 @@ import ( "strconv" "github.com/aws/aws-lambda-go/lambda" + awscfg "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/clerk/clerk-sdk-go/v2" "kronos/internal/api" @@ -25,11 +27,19 @@ func main() { season, _ := strconv.ParseInt(config.Get(ctx, "SEASON_START"), 10, 64) + awsCfg, err := awscfg.LoadDefaultConfig(ctx) + if err != nil { + log.Fatal(err) + } + handler := &api.API{ Store: db, AdminClerkID: config.Get(ctx, "ADMIN_CLERK_ID"), Season: season, Session: config.Get(ctx, "LEETCODE_SESSION"), + S3: s3.NewFromConfig(awsCfg), + JobsBucket: config.Get(ctx, "JOBS_BUCKET"), + JobsKey: config.Get(ctx, "JOBS_KEY"), } lambda.Start(handler.Handle) } diff --git a/backend/cmd/jobsync/main.go b/backend/cmd/jobsync/main.go index c171edd..62b558f 100644 --- a/backend/cmd/jobsync/main.go +++ b/backend/cmd/jobsync/main.go @@ -3,34 +3,39 @@ package main import ( "context" "log" + "os" + "time" "github.com/aws/aws-lambda-go/lambda" + awscfg "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/s3" "kronos/internal/config" "kronos/internal/jobs" - "kronos/internal/store" ) /* jobsync is a scheduled Lambda for the Job Board dashboard item. EventBridge -invokes it on a cadence (see terraform/modules/scheduler, default hourly) -with an empty event - this job doesn't read anything from the event, it just -re-scrapes both READMEs from scratch every time it runs and saves the result -to Postgres. +invokes it every minute (see terraform/modules/scheduler) with an empty +event - this job doesn't read anything from the event, it just re-scrapes +both READMEs from scratch every time it runs and overwrites one JSON file in +S3 with the result. Pipeline for one run: - main -> run -> jobs.FetchSpeedyApplyJobs + jobs.FetchSimplifyJobsNewGrad -> toRows -> store.UpsertJobs + main -> run -> jobs.FetchSpeedyApplyJobs + jobs.FetchSimplifyJobsNewGrad -> jobs.WriteCache The api Lambda's GET /jobs route (backend/internal/api/jobs.go) never talks -to GitHub itself - it only reads whatever is currently in the `jobs` table, -the same way it reads `problems`/`solves` for the LeetCode side of the -dashboard. jobsync is the only thing that writes to that table. +to GitHub itself - it only reads whatever this Lambda most recently wrote to +S3. Unlike the other scheduled Lambdas in this project (sync, enrich, +emailsync), jobsync never touches Postgres at all - the whole Job Board +feature is designed to need no database, just this one cached file. */ -// run does one full scrape-and-store pass. -func run(ctx context.Context, db *store.Postgres, githubToken string) error { +// run does one full scrape-and-cache pass: fetch both sources, combine them, +// and write the result to S3 as one JSON file. +func run(ctx context.Context, s3Client *s3.Client, bucket, key, githubToken string) error { // Step 1: scrape each source independently, so one source failing (e.g. - // GitHub is briefly down) doesn't block the other from being saved. + // GitHub is briefly down) doesn't block the other from being cached. speedyJobs, err := jobs.FetchSpeedyApplyJobs(ctx, githubToken) if err != nil { log.Printf("speedyapply: %v", err) @@ -43,55 +48,34 @@ func run(ctx context.Context, db *store.Postgres, githubToken string) error { all := append(speedyJobs, simplifyJobs...) log.Printf("scraped %d jobs (%d speedyapply, %d simplifyjobs)", len(all), len(speedyJobs), len(simplifyJobs)) - // Step 2: upsert into Postgres. See store.UpsertJobs - a job that's - // already in the table gets its last_seen_at refreshed instead of being - // inserted a second time. - n, err := db.UpsertJobs(ctx, toRows(all)) - if err != nil { - return err - } - log.Printf("upserted %d jobs", n) - return nil -} - -// toRows converts the scrape layer's Job structs (backend/internal/jobs) -// into the store layer's JobRow structs (backend/internal/store). Keeping -// these as two separate types - even though their fields line up 1:1 today - -// means the DB schema and the GitHub-scraping logic can change independently -// of each other; this function is the only place that has to know about both. -func toRows(items []jobs.Job) []store.JobRow { - rows := make([]store.JobRow, len(items)) - for i, j := range items { - rows[i] = store.JobRow{ - ID: j.ID, - Company: j.Company, - Position: j.Position, - Location: j.Location, - Salary: j.Salary, - PostingURL: j.PostingURL, - Age: j.Age, - Closed: j.Closed, - SourceRepo: j.SourceRepo, - SourceSection: j.SourceSection, - } - } - return rows + // Step 2: write the combined list to S3. ScrapedAt is stamped here (not + // by the api Lambda) so it reflects when the data was actually fetched. + payload := jobs.CachePayload{ScrapedAt: time.Now().UTC(), Jobs: all} + return jobs.WriteCache(ctx, s3Client, bucket, key, payload) } func main() { ctx := context.Background() - db, err := store.NewPostgres(ctx, config.Get(ctx, "DATABASE_URL")) + // The AWS SDK config (region, credentials) comes from the Lambda + // execution environment automatically - nothing to configure here. + awsCfg, err := awscfg.LoadDefaultConfig(ctx) if err != nil { log.Fatal(err) } + s3Client := s3.NewFromConfig(awsCfg) + + bucket := os.Getenv("JOBS_BUCKET") + key := os.Getenv("JOBS_KEY") + if key == "" { + key = "jobs.json" + } lambda.Start(func(ctx context.Context) error { - // Optional: config.Get checks the plain GITHUB_TOKEN env var first, - // then a GITHUB_TOKEN_SSM secret if one is configured. Neither is - // set today - fetchReadme works fine unauthenticated too, see its - // comment in backend/internal/jobs/github.go. + // GitHub token is required at this cadence: 2 requests/minute would + // blow past GitHub's 60/hour unauthenticated limit. See the + // GITHUB_TOKEN SSM comment in terraform/main.tf. githubToken := config.Get(ctx, "GITHUB_TOKEN") - return run(ctx, db, githubToken) + return run(ctx, s3Client, bucket, key, githubToken) }) } diff --git a/backend/db/schema.sql b/backend/db/schema.sql index eb3dbfa..8b1d171 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -161,28 +161,6 @@ create table if not exists sd_solves ( primary key (user_id, slug) ); --- Job Board: one row per posting scraped from public GitHub job-list READMEs --- (see backend/internal/jobs and backend/cmd/jobsync, a scheduled Lambda). --- id is a stable hash of company+position+posting_url (see jobs.newID) so --- re-scraping the same posting on the next run updates the row instead of --- duplicating it, and so a future email-sync Lambda can recompute the same --- hash from a parsed email to match it back to a specific job. -create table if not exists jobs ( - id text primary key, - company text not null, - position text not null, - location text not null default '', - salary text not null default '', - posting_url text not null default '', - age text not null default '', -- raw "3d" / "1mo" label copied from the README, not parsed into a real date - closed boolean not null default false, - source_repo text not null, - source_section text not null, - first_seen_at timestamptz not null default now(), - last_seen_at timestamptz not null default now() -); -create index if not exists idx_jobs_last_seen on jobs(last_seen_at desc); - create index if not exists idx_solves_user on solves(user_id); create index if not exists idx_submissions_pending on submissions(enriched) where not enriched; create index if not exists idx_solutions_user_problem on solutions(user_id, problem_id); 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 642ac46..64d05ad 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/aws/aws-lambda-go/events" + "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/clerk/clerk-sdk-go/v2/jwt" "kronos/internal/leetcode" @@ -21,6 +22,13 @@ type API struct { AdminClerkID string Season int64 Session string + + // Job Board: S3 client + where the cached job list lives. jobsync (a + // separate scheduled Lambda) is the only thing that writes to this + // object - see backend/internal/api/jobs.go and backend/cmd/jobsync. + S3 *s3.Client + JobsBucket string + JobsKey string } type request = events.APIGatewayV2HTTPRequest diff --git a/backend/internal/api/jobs.go b/backend/internal/api/jobs.go index 24f947d..c25014f 100644 --- a/backend/internal/api/jobs.go +++ b/backend/internal/api/jobs.go @@ -1,60 +1,77 @@ package api import ( - "context" - "strconv" - "time" + "context" + "strconv" + "time" - "kronos/internal/store" + "kronos/internal/jobs" ) // getJobs serves GET /jobs for the Job Board dashboard card, one page at a -// time. The frontend sends ?limit=20, and after the first page, &beforeId= -// &beforeTime= copied from the previous response's nextCursor. jobsync (a -// separate scheduled Lambda) is the only thing that ever writes to the -// `jobs` table this reads from. +// time. It reads the single cached JSON file jobsync last wrote to S3 (see +// backend/cmd/jobsync and backend/internal/jobs/cache.go), then slices out +// just the page that was asked for - it never talks to GitHub itself. +// +// Pagination here is plain offset-based (?limit=20&offset=20, ...), unlike +// the SQL cursor version we tried earlier: this list comes from ONE S3 read, +// so the full slice is already sitting in memory and stable for the life of +// this one request - there's no "another request snuck a row in before +// yours" problem to design around, since it's not a live database being +// written to row by row. The only place the underlying data can change is +// jobsync's once-a-minute overwrite of the whole file, which is a separate +// concern from paging through one already-fetched snapshot. func (a *API) getJobs(ctx context.Context, query map[string]string) (response, error) { - // Default page size if the frontend doesn't specify one. - limit := 20 - if v := query["limit"]; v != "" { - if n, err := strconv.Atoi(v); err == nil && n > 0 { - limit = n - } - } - - // query["beforeId"] is "" if that key isn't in the URL at all - which is - // exactly what we want to mean "no cursor yet, send me the first page." - beforeID := query["beforeId"] - var beforeTime time.Time - if v := query["beforeTime"]; v != "" { - if t, err := time.Parse(time.RFC3339, v); err == nil { - beforeTime = t - } - } - - rows, err := a.Store.Jobs(ctx, limit, beforeID, beforeTime) - if err != nil { - return serverError(err) - } - - // cursor is only ever used inside this one response, so it's declared - // right here instead of as a package-level type. - type cursor struct { - BeforeID string `json:"beforeId"` - BeforeTime time.Time `json:"beforeTime"` - } - - // If we got back a full page (exactly `limit` rows), there might be - // more - point the frontend at the last row we just sent. If we got back - // fewer than `limit`, we've reached the actual end of the list. - var next *cursor - if len(rows) == limit { - last := rows[len(rows)-1] - next = &cursor{BeforeID: last.ID, BeforeTime: last.FirstSeenAt} - } - - return reply(200, struct { - Jobs []store.JobRow `json:"jobs"` - NextCursor *cursor `json:"nextCursor"` - }{Jobs: rows, NextCursor: next}) -} \ No newline at end of file + if a.S3 == nil || a.JobsBucket == "" { + // Job board isn't configured in this environment - respond with an + // empty list instead of failing every dashboard load. + return reply(200, jobsPage{Jobs: []jobs.Job{}}) + } + + payload, err := jobs.ReadCache(ctx, a.S3, a.JobsBucket, a.JobsKey) + 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 + } + } + + all := payload.Jobs + 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: payload.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/cache.go b/backend/internal/jobs/cache.go new file mode 100644 index 0000000..97f8c3e --- /dev/null +++ b/backend/internal/jobs/cache.go @@ -0,0 +1,60 @@ +package jobs + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" +) + +// WriteCache serializes payload as JSON and uploads it to bucket/key, +// overwriting whatever was there before. Called once per scheduled jobsync +// run (every minute) - this is the only place anything writes to the cache. +func WriteCache(ctx context.Context, client *s3.Client, bucket, key string, payload CachePayload) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + _, err = client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + Body: bytes.NewReader(body), + ContentType: aws.String("application/json"), + }) + return err +} + +// ReadCache downloads and parses the cached jobs JSON. If the object doesn't +// exist yet - e.g. right after a fresh deploy, before jobsync's first +// scheduled run - it returns an empty payload instead of an error, so a +// dashboard load gets "no jobs yet" rather than a 500. +func ReadCache(ctx context.Context, client *s3.Client, bucket, key string) (CachePayload, error) { + out, err := client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + }) + if err != nil { + var noSuchKey *types.NoSuchKey + if errors.As(err, &noSuchKey) { + return CachePayload{}, nil + } + return CachePayload{}, err + } + defer out.Body.Close() + + body, err := io.ReadAll(out.Body) + if err != nil { + return CachePayload{}, err + } + + var payload CachePayload + if err := json.Unmarshal(body, &payload); err != nil { + return CachePayload{}, err + } + return payload, nil +} diff --git a/backend/internal/jobs/job.go b/backend/internal/jobs/job.go index d7243c9..93e0450 100644 --- a/backend/internal/jobs/job.go +++ b/backend/internal/jobs/job.go @@ -1,15 +1,16 @@ // Package jobs scrapes public new-grad/internship job lists out of two -// GitHub READMEs and turns them into a plain []Job slice. This package only -// does scraping + parsing - it has no idea Postgres exists. backend/cmd/jobsync -// is the layer above it that takes these Job structs and saves them (see -// store.UpsertJobs), which keeps "how do we read a README" and "how do we -// persist a job" independent of each other. +// GitHub READMEs and turns them into a plain []Job slice. There is no +// database involved anywhere in this feature: backend/cmd/jobsync (a +// scheduled Lambda) calls this package, then writes the result as one JSON +// file to S3 (see cache.go) as a refresh-every-minute cache. The api +// Lambda's GET /jobs route reads that same file - it never talks to GitHub. package jobs import ( "crypto/sha1" "encoding/hex" "strings" + "time" ) // Job is one job posting scraped from a GitHub README table. @@ -48,3 +49,12 @@ func newID(company, position, postingURL string) string { sum := sha1.Sum([]byte(key)) return hex.EncodeToString(sum[:])[:16] } + +// CachePayload is the JSON document jobsync writes to S3 and the api Lambda +// reads back out. ScrapedAt lets the api Lambda tell "no jobs yet" (the zero +// time) apart from "scraped, list happened to be empty," and lets the +// frontend show "updated N minutes ago" if we want that later. +type CachePayload struct { + ScrapedAt time.Time `json:"scrapedAt"` + Jobs []Job `json:"jobs"` +} diff --git a/backend/internal/store/jobs.go b/backend/internal/store/jobs.go deleted file mode 100644 index bf08321..0000000 --- a/backend/internal/store/jobs.go +++ /dev/null @@ -1,105 +0,0 @@ -package store - -import ( - "context" - "time" - - "github.com/jackc/pgx/v5" -) - -// JobRow is one job posting as stored in Postgres and served to the -// frontend. It mirrors jobs.Job (backend/internal/jobs/job.go) field for -// field, but the store package intentionally does not import the jobs -// package - store only knows about plain data, never about how that data -// was scraped. backend/cmd/jobsync is the glue that converts one into the -// other. -type JobRow struct { - ID string `json:"id"` - Company string `json:"company"` - Position string `json:"position"` - Location string `json:"location"` - Salary string `json:"salary,omitempty"` - PostingURL string `json:"postingUrl,omitempty"` - Age string `json:"age,omitempty"` - Closed bool `json:"closed"` - SourceRepo string `json:"sourceRepo"` - SourceSection string `json:"sourceSection"` - FirstSeenAt time.Time `json:"firstSeenAt"` -} - -// UpsertJobs inserts newly-seen jobs and refreshes last_seen_at for jobs -// that were already in the table. It's called once per jobsync run with -// every job scraped from GitHub that pass. Because id is a stable hash of -// (company, position, postingURL) - see jobs.newID - re-scraping the same -// posting on the next hourly run updates the existing row in place instead -// of inserting a duplicate. -func (p *Postgres) UpsertJobs(ctx context.Context, rows []JobRow) (int, error) { - if len(rows) == 0 { - return 0, nil - } - - batch := &pgx.Batch{} - for _, r := range rows { - batch.Queue(` - insert into jobs - (id, company, position, location, salary, posting_url, age, closed, source_repo, source_section, first_seen_at, last_seen_at) - values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, now(), now()) - on conflict (id) do update set - location = excluded.location, - salary = excluded.salary, - posting_url = excluded.posting_url, - age = excluded.age, - closed = excluded.closed, - last_seen_at = now() - `, r.ID, r.Company, r.Position, r.Location, r.Salary, r.PostingURL, r.Age, r.Closed, r.SourceRepo, r.SourceSection) - } - - br := p.pool.SendBatch(ctx, batch) - defer br.Close() - for range rows { - if _, err := br.Exec(); err != nil { - return 0, err - } - } - return len(rows), nil -} - -// Jobs returns one page of the job board list, most-recently-first-seen -// first. Only jobs seen within the last 6 hours are returned (see the old -// comment on staleness - that part is unchanged). -// -// Pagination: pass beforeID = "" and a zero time.Time for the very first -// page. For every page after that, pass the ID and FirstSeenAt of the LAST -// job from the previous page - the api Lambda sends these back to the -// frontend as "nextCursor", and the frontend sends them right back as -// beforeId/beforeTime when it asks for more. That tells this query "give me -// jobs that come after that one in the sort order." -// -// This is why it's safe even though jobsync inserts new rows every minute: -// a plain "OFFSET 20" approach would shift every later page by one slot -// whenever a new job sneaks in between two page loads, causing skipped or -// duplicated rows. Anchoring to an actual row's position instead of a -// row count sidesteps that entirely. -func (p *Postgres) Jobs(ctx context.Context, limit int, beforeID string, beforeTime time.Time) ([]JobRow, error) { - rows, err := p.pool.Query(ctx, ` - select id, company, position, location, salary, posting_url, age, closed, source_repo, source_section, first_seen_at - from jobs - where last_seen_at > now() - interval '6 hours' - and ($1 = '' or first_seen_at < $2 or (first_seen_at = $2 and id < $1)) - order by first_seen_at desc, id desc - limit $3`, beforeID, beforeTime, limit) - if err != nil { - return nil, err - } - defer rows.Close() - - out := []JobRow{} - for rows.Next() { - var r JobRow - if err := rows.Scan(&r.ID, &r.Company, &r.Position, &r.Location, &r.Salary, &r.PostingURL, &r.Age, &r.Closed, &r.SourceRepo, &r.SourceSection, &r.FirstSeenAt); err != nil { - return nil, err - } - out = append(out, r) - } - return out, rows.Err() -} diff --git a/src/lib/api.ts b/src/lib/api.ts index 9a3737f..74417b2 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -49,14 +49,8 @@ export const api = { call("/me/username-request", t, { method: "POST", body: JSON.stringify({ username }) }), setTheme: (t: TokenFn, theme: string) => call("/me/theme", t, { method: "POST", body: JSON.stringify({ theme }) }), - jobs: (t: TokenFn, limit = 20, cursor?: JobsCursor | null) => { - const params = new URLSearchParams({ limit: String(limit) }); - if (cursor) { - params.set("beforeId", cursor.beforeId); - params.set("beforeTime", cursor.beforeTime); - } - return call(`/jobs?${params.toString()}`, t); - }, + jobs: (t: TokenFn, limit = 20, offset = 0) => + call(`/jobs?limit=${limit}&offset=${offset}`, t), progress: (t: TokenFn) => call("/me/progress", t), leaderboard: (t: TokenFn) => call("/leaderboard", t), recent: (t: TokenFn) => call("/recent", t), @@ -110,8 +104,8 @@ export type MeResponse = { season: number; requestedUsername?: string; }; -// One job posting, as returned by GET /jobs. Matches store.JobRow on the Go -// side (backend/internal/store/jobs.go) field for field. +// One job posting, as returned by GET /jobs. Matches jobs.Job on the Go +// side (backend/internal/jobs/job.go) field for field. export type ApiJob = { id: string; company: string; @@ -124,8 +118,9 @@ export type ApiJob = { sourceRepo: string; sourceSection: string; }; -export type JobsCursor = { beforeId: string; beforeTime: string }; -export type JobsPage = { jobs: ApiJob[]; nextCursor: JobsCursor | null }; +// nextOffset is the offset to pass on the next call to keep paging, or null +// once you've reached the end of the cached list. +export type JobsPage = { jobs: ApiJob[]; scrapedAt: string; nextOffset: number | null }; export type ApiProblem = { slug: string; title: string; diff --git a/src/modals/JobBoardModal.tsx b/src/modals/JobBoardModal.tsx index 47ac399..26b6f88 100644 --- a/src/modals/JobBoardModal.tsx +++ b/src/modals/JobBoardModal.tsx @@ -2,46 +2,44 @@ import { useEffect, useMemo, useState } from "react"; import { Search, ExternalLink, Lock } from "lucide-react"; import { Modal } from "../components/Modal"; import { useData } from "../data/source"; -import { api, type ApiJob, type JobsCursor } from "../lib/api"; +import { api, type ApiJob } from "../lib/api"; // JobBoardModal is the full-list view behind the Job Board card: every job -// currently in Postgres (from GET /jobs), with a text search and a -// section filter (e.g. "FAANG+", "Software Engineering"). It fetches its -// own data independently of JobBoardCard's preview fetch, the same way -// FriendsModal/LeaderboardModal fetch their own full lists. +// currently cached in S3 (from GET /jobs - see backend/internal/jobs/cache.go), +// with a text search and a section filter (e.g. "FAANG+", "Software +// Engineering"). It fetches its own data independently of JobBoardCard's +// preview fetch, the same way FriendsModal/LeaderboardModal fetch their own +// full lists. export function JobBoardModal({ onClose }: { onClose: () => void }) { const { getToken } = useData(); const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); - const [cursor, setCursor] = useState(null); - const [hasMore, setHasMore] = useState(false); + const [nextOffset, setNextOffset] = useState(null); const [query, setQuery] = useState(""); const [section, setSection] = useState(null); useEffect(() => { api - .jobs(getToken, 20) + .jobs(getToken, 20, 0) .then((page) => { setJobs(page.jobs ?? []); - setCursor(page.nextCursor); - setHasMore(page.nextCursor !== null); + setNextOffset(page.nextOffset); }) .catch(() => setJobs([])) .finally(() => setLoading(false)); }, [getToken]); - // Fetches the next page using the cursor from the last response, and - // appends it to what's already on screen (rather than replacing it). + // Fetches the next page starting at nextOffset (from the last response), + // and appends it to what's already on screen (rather than replacing it). const loadMore = () => { - if (!cursor || loadingMore) return; + if (nextOffset === null || loadingMore) return; setLoadingMore(true); api - .jobs(getToken, 20, cursor) + .jobs(getToken, 20, nextOffset) .then((page) => { setJobs((prev) => [...prev, ...(page.jobs ?? [])]); - setCursor(page.nextCursor); - setHasMore(page.nextCursor !== null); + setNextOffset(page.nextOffset); }) .finally(() => setLoadingMore(false)); }; @@ -153,7 +151,7 @@ export function JobBoardModal({ onClose }: { onClose: () => void }) { ); })} - {hasMore && ( + {nextOffset !== null && (
  • , or plain HTML links inside +// Markdown pipe-table cells). These helpers turn one HTML-ish table cell +// into plain text, since that's all the frontend actually wants to display. + +// tagPattern matches any HTML tag, e.g. , , . +var tagPattern = regexp.MustCompile(`<[^>]*>`) + +// hrefPattern pulls the URL out of the first href="..." attribute it finds. +var hrefPattern = regexp.MustCompile(`href="([^"]*)"`) + +// lineBreakPattern matches every line-break spelling the two READMEs use - +// the standard
    , and SimplifyJobs'
    typo'd as
    - so a +// multi-location cell like "Seattle, WA
    Austin, TX" becomes +// "Seattle, WA, Austin, TX" instead of the two locations running together. +var lineBreakPattern = regexp.MustCompile(`(?i)|
    `) + +// summaryPattern strips a
    5 locations label. We +// want the location list that follows it, not the "N locations" summary text. +var summaryPattern = regexp.MustCompile(`(?is).*?`) + +// stripTags removes every HTML tag from s (after handling line breaks and +// specially, see above) and decodes leftover entities like & +// so the result reads like plain text. +func stripTags(s string) string { + s = summaryPattern.ReplaceAllString(s, "") + s = lineBreakPattern.ReplaceAllString(s, ", ") + s = tagPattern.ReplaceAllString(s, "") + return html.UnescapeString(s) +} + +// firstHref returns the URL inside the first href="..." attribute in s, or +// "" if there isn't one (e.g. a closed-application cell that's just an emoji). +func firstHref(s string) string { + m := hrefPattern.FindStringSubmatch(s) + if m == nil { + return "" + } + return m[1] +} + +// cleanText trims whitespace and collapses repeated spaces/newlines so text +// pulled out of indented HTML reads like a single line. +func cleanText(s string) string { + return strings.Join(strings.Fields(s), " ") +} diff --git a/backend/internal/jobs/job.go b/backend/internal/jobs/job.go new file mode 100644 index 0000000..d7243c9 --- /dev/null +++ b/backend/internal/jobs/job.go @@ -0,0 +1,50 @@ +// Package jobs scrapes public new-grad/internship job lists out of two +// GitHub READMEs and turns them into a plain []Job slice. This package only +// does scraping + parsing - it has no idea Postgres exists. backend/cmd/jobsync +// is the layer above it that takes these Job structs and saves them (see +// store.UpsertJobs), which keeps "how do we read a README" and "how do we +// persist a job" independent of each other. +package jobs + +import ( + "crypto/sha1" + "encoding/hex" + "strings" +) + +// Job is one job posting scraped from a GitHub README table. +type Job struct { + // ID is a short fingerprint of (Company, Position, PostingURL). It's + // deterministic - scraping the same posting twice always produces the + // same ID - so a future email-sync Lambda can compute this same ID from + // a parsed email (once it knows the company/role/link) and use it to + // find "which dashboard job is this email about". See the TODO below. + ID string `json:"id"` + Company string `json:"company"` + Position string `json:"position"` + Location string `json:"location"` + Salary string `json:"salary,omitempty"` + PostingURL string `json:"postingUrl,omitempty"` + Age string `json:"age,omitempty"` // straight from the README, e.g. "3d", "1mo" - not parsed into a real duration + Closed bool `json:"closed"` // true when the README marks the application as closed + SourceRepo string `json:"sourceRepo"` // e.g. "speedyapply/2027-SWE-College-Jobs" + SourceSection string `json:"sourceSection"` // which table/heading it came from, e.g. "FAANG+", "Software Engineering" + + // TODO(email-sync): the email-sync Lambda (backend/cmd/emailsync) will + // eventually look a job up by ID and attach fields like these: + // OAStatus string `json:"oaStatus,omitempty"` // "", "OA_RECEIVED", "INTERVIEW_SCHEDULED", "REJECTED" + // OAStatusUpdatedAt string `json:"oaStatusUpdatedAt,omitempty"` + // Nothing sets those yet - this is just marking where they'd go so the + // two features can meet in the middle later. +} + +// newID hashes the three fields that together identify a specific posting. +// Hashing (instead of e.g. concatenating them raw) keeps the ID short and +// free of characters that would need escaping in a URL or JSON key. +func newID(company, position, postingURL string) string { + key := strings.ToLower(strings.TrimSpace(company)) + "|" + + strings.ToLower(strings.TrimSpace(position)) + "|" + + strings.ToLower(strings.TrimSpace(postingURL)) + sum := sha1.Sum([]byte(key)) + return hex.EncodeToString(sum[:])[:16] +} diff --git a/backend/internal/jobs/sections.go b/backend/internal/jobs/sections.go new file mode 100644 index 0000000..20f7754 --- /dev/null +++ b/backend/internal/jobs/sections.go @@ -0,0 +1,70 @@ +package jobs + +import "strings" + +// sectionBounds returns the slice of doc that starts at the heading line +// containing titleContains and runs up to (but not including) the next line +// that starts with headingPrefix (e.g. "## "). If no later heading exists, +// it returns the rest of the document. Returns "" if titleContains isn't +// found at all. +// +// This is how we isolate e.g. "just the USA internships section" out of a +// 280-line README before we bother looking for table rows in it. +func sectionBounds(doc, titleContains, headingPrefix string) string { + start := strings.Index(doc, titleContains) + if start == -1 { + return "" + } + // Back up to the start of that heading's own line. + lineStart := strings.LastIndex(doc[:start], "\n") + 1 + body := doc[lineStart:] + + // Search for the next heading line *after* this one, so we don't + // immediately match the heading we just found. + afterFirstLine := strings.IndexByte(body, '\n') + if afterFirstLine == -1 { + return body + } + rest := body[afterFirstLine+1:] + next := strings.Index(rest, "\n"+headingPrefix) + if next == -1 { + return body + } + return body[:afterFirstLine+1+next+1] +} + +// subsection is one heading + the text under it, as produced by +// splitSubsections below. +type subsection struct { + Title string + Body string +} + +// splitSubsections splits body into pieces at each line that starts with +// prefix (e.g. "### "), using the heading text (minus the prefix) as each +// piece's Title. Text before the first matching heading is discarded - the +// two READMEs we scrape never put job rows there. +func splitSubsections(body, prefix string) []subsection { + var subs []subsection + var title string + var buf strings.Builder + + flush := func() { + if title != "" { + subs = append(subs, subsection{Title: title, Body: buf.String()}) + } + buf.Reset() + } + + for _, line := range strings.Split(body, "\n") { + if strings.HasPrefix(line, prefix) { + flush() + title = strings.TrimSpace(strings.TrimPrefix(line, prefix)) + continue + } + buf.WriteString(line) + buf.WriteByte('\n') + } + flush() + return subs +} diff --git a/backend/internal/jobs/simplifyjobs.go b/backend/internal/jobs/simplifyjobs.go new file mode 100644 index 0000000..8954bb7 --- /dev/null +++ b/backend/internal/jobs/simplifyjobs.go @@ -0,0 +1,107 @@ +package jobs + +import ( + "context" + "regexp" + "strings" +) + +const ( + simplifyOwner = "SimplifyJobs" + simplifyRepo = "New-Grad-Positions" + // The repo's default branch is "dev", not "main" - worth calling out + // since almost every other GitHub repo defaults to "main". + simplifyRef = "dev" + simplifyPath = "README.md" +) + +// FetchSimplifyJobsNewGrad downloads the SimplifyJobs New-Grad-Positions +// README and extracts jobs from the two sections the issue asked for: +// Software Engineering and Product Management. (The README also has Data +// Science/AI, Quant, and Hardware sections - not scraped here, but adding +// one is a one-line call to parseSimplifySection, see below.) +func FetchSimplifyJobsNewGrad(ctx context.Context, githubToken string) ([]Job, error) { + md, err := fetchReadme(ctx, githubToken, simplifyOwner, simplifyRepo, simplifyRef, simplifyPath) + if err != nil { + return nil, err + } + + var jobs []Job + jobs = append(jobs, parseSimplifySection(md, "Software Engineering New Grad Roles", "Software Engineering")...) + jobs = append(jobs, parseSimplifySection(md, "Product Management New Grad Roles", "Product Management")...) + return jobs, nil +} + +// parseSimplifySection isolates one "## " section +// of the README (by searching for headingContains, since the emoji prefix +// makes an exact match brittle) and parses the HTML table inside it. +func parseSimplifySection(md, headingContains, sectionLabel string) []Job { + section := sectionBounds(md, headingContains, "## ") + if section == "" { + return nil + } + return parseSimplifyHTMLTable(section, sectionLabel) +} + +// Unlike SpeedyApply's Markdown pipe tables, SimplifyJobs writes each job +// table as raw HTML: ...
    ...
    . +// We only want the
    (.*?)
    //
    ) embedded in the +// Markdown; see parseSpeedyApplyMarkdown in speedyapply.go. Section names +// (FAANG+, Quant, and the rest) come from the README's own headings rather +// than a fixed list here. +const ( + speedyApplyOwner = "speedyapply" + speedyApplyRepo = "2027-SWE-College-Jobs" + speedyApplyRef = "main" + speedyApplyPath = "README.md" +) + +// SimplifyJobs - full-time new-grad listings. +// +// Only two of this README's sections are scraped, "Software Engineering New +// Grad Roles" and "Product Management New Grad Roles"; the Data Science/AI, +// Quant and Hardware sections are left alone (see FetchSimplifyJobsNewGrad in +// simplifyjobs.go, where adding one is a single line). +// +// Note the ref: this repo's default branch is "dev", not "main", which is +// unusual enough to be worth stating twice. +const ( + simplifyOwner = "SimplifyJobs" + simplifyRepo = "New-Grad-Positions" + simplifyRef = "dev" + simplifyPath = "README.md" +) diff --git a/backend/internal/jobs/speedyapply.go b/backend/internal/jobs/speedyapply.go index 52e8b70..8062304 100644 --- a/backend/internal/jobs/speedyapply.go +++ b/backend/internal/jobs/speedyapply.go @@ -6,15 +6,9 @@ import ( "strings" ) -const ( - speedyApplyOwner = "speedyapply" - speedyApplyRepo = "2027-SWE-College-Jobs" - speedyApplyRef = "main" - speedyApplyPath = "README.md" -) - // FetchSpeedyApplyJobs downloads the SpeedyApply internship README and turns -// its Markdown tables into Job structs. +// its Markdown tables into Job structs. The repo it reads is declared in +// sources.go. func FetchSpeedyApplyJobs(ctx context.Context, githubToken string) ([]Job, error) { md, err := fetchReadme(ctx, githubToken, speedyApplyOwner, speedyApplyRepo, speedyApplyRef, speedyApplyPath) if err != nil {