WorkIt: Early-Career Job Aggregator

Aug 2026 – Present

  • Python
  • PostgreSQL
  • GitHub Actions

I built the Python scraper behind WorkIt, a five-person team's job board for internship and new-grad roles, and the GitHub Actions workflow that imports its results into PostgreSQL. Routine runs fell from 9.5 to about 3 minutes with every site paced as before, and the workflow's dry run on pull requests caught a database schema mismatch before it merged.

WorkIt's landing page: a line saying it tailors a resume to each role and matches you with jobs, Google and LinkedIn sign-up buttons, and two sample job-match cards over an illustrated city.
WorkIt's public landing page, October 2026. The app behind it needs sign-in and is still in development; the page and the app are mostly Andrew Shi's work, and the jobs come from my scraper.

The problem

Internship and new-grad postings live on company careers pages, most of them on a few hiring systems: Greenhouse, Lever and Ashby. WorkIt, built by a team of 5 in CSE 416, gets its jobs from a scraper that reads those boards directly, keeps the early-career software roles and fills in the facts a job card shows.

Two constraints shaped it. The scraper must be polite: it obeys each site’s robots.txt, stays away when it can’t read one, and paces every site. And the database is shared, so an import that writes bad rows breaks the site for everyone.

How it works

The scraper reads about 1,800 company boards and keeps 1,000+ roles; a scheduled GitHub Actions workflow runs it and imports the result into PostgreSQL on Supabase:

Pipeline: job boards feed the scraper, which fills in card facts; a scheduled workflow imports them into PostgreSQL on Supabase for the web app, and a pull request runs the same path as a dry run. Job boards Greenhouse, Lever, Ashby Scraper (mine) a lane per provider, polite pacing Card facts (mine) pay, job type, work style Import (Xiang Liu's script) run on a schedule in Actions (mine) PostgreSQL on Supabase Web app (the team) On a PR (mine) the same path, import as dry run
What I built is outlined in the accent colour: the scraper, its card facts, the schedule and the pull-request dry run. The import script and the schema are Xiang Liu's.

Giving each job-board provider its own lane

A run took 9.5 minutes, 5.5 of them Lever’s one-second crawl delay over its 333 boards. Every board shared 8 workers, so a worker waiting out Lever was one not reading Greenhouse.

Speed must never come from less pacing, so I changed two other things. Each provider runs in its own lane, a thread pool with its own workers and the same per-site pacing, so no server sees more traffic. And quiet boards rest: a board that has never had a posting we keep (282 of the 333 on Lever) is read on 1 day in 3, staggered, and at least every 3 days. A --full run still reads every board.

Routine run time: 9.5 minutes before, 5.5 of them Lever's crawl delay; about 3 minutes after. Before: 9.5 min 5.5 min of Lever's delay After: about 3 min 0 2 4 6 8 10 min
A routine run before and after, drawn to scale; the mark on the old run ends Lever's share of it.

Routine runs went from 9.5 to about 3 minutes. The 9.5 comes from runs before the workflow existed, how many unrecorded; the 3 held in Actions, where the first 8 scheduled scrapes took 2 min 45 s to 3 min 2 s. More workers could not have fixed Lever: its 333 boards share one server, paced at a request a second, so they take 5.5 minutes regardless. The cost is freshness: a company’s first posting on a quiet board can appear up to 2 days late.

A dry run of the import on every pull request that touches it

My workflow scrapes, then imports, on a schedule: every 6 hours at first, until Andrew Shi made it every 3. The import script is Xiang Liu’s: one transaction that refuses an empty feed or closing more than half the jobs at once, with a dry-run mode that writes nothing. Those guards stop a bad scrape at run time, after the change behind it has merged.

I wanted the failure before the merge. Any pull request that changes the scraper, the import, its schema or the workflow now runs the whole path in Actions, real scrape included, with the import forced to a dry run against the live database, not a throwaway Postgres like the one WorkIt PR 80’s rollout was rehearsed on. A throwaway is built from the branch’s own migrations, so it always matches the code; WorkIt applies migrations by hand, never in a deploy, so the live database is what falls behind.

It earned its place on 14 October 2026, when my own resume-builder change, WorkIt PR 116, failed its dry run with “column job_postings.keywords does not exist”: the code expected a column the database lacked. No backend file changed before the next run passed; the database did, once the pull request’s migrations were applied, as its instructions require before it deploys. It merged 2 days later.

The cost: every such pull request waits for a full scrape (the failing run took 4 min 39 s).

Inferring work style when a posting doesn’t say

A job card shows a work style: on site, hybrid or remote. Many postings never state one (“San Jose, CA”, no more), so the card could say “not listed” or guess.

I chose to infer, in order: what the posting states, from its board’s field or a sentence in the text; then what the company’s other postings state most often; then “On site” for a posting that names a place and never mentions remote, hybrid or working from home. Only specific hybrid wording counts as stated, because a bare “hybrid” was wrong 38 times in 89 (“hybrid cloud”).

I measured it on 250 postings whose board states a work style but whose text does not, checking the board’s label against the inference:

Work style right on 250 labelled postings: the inference order 87%, always guessing On site 70%. Inference order: 87% Always "On site": 70% 0 50 100%
Share of 250 labelled postings whose work style each method got right, drawn to scale.

The order was right 87% of the time, against 70% for always guessing “On site”, and 997 of that day’s 1,096 jobs got a work style. The cost is the misses: hybrid jobs shown as on site. Postings that state nothing mostly work on site, so saying so beats “not listed”. Work style is the only fact the scraper infers; for pay, job type and the rest, a wrong fact is worse than a blank.

Result

  • Speed: routine runs 9.5 → about 3 minutes, pacing unchanged.
  • Safety: the pull-request dry run caught a schema mismatch before it merged.
  • Coverage: a work style on 997 of 1,096 cards, 87% right against 70%.
  • Tests: I wrote 145 of the scraper’s 252 tests and added its test-and-lint job to CI on every pull request.

What’s still unproven

  • CI informs; it doesn’t gate. Branch protection is off, so the test-and-lint job and the dry run can fail without blocking a merge.
  • The 87% was measured where the answer is known. Postings whose board states nothing may differ, and nothing measures them.
  • The speed figure predates newer providers: the 9.5 to 3 minutes covers Greenhouse, Lever and Ashby, and Andrew has since added more.
  • Scout is untested on facts. My assistant that answers from one posting and the user’s resume, has grounding rules and 19 tests but no measure of how often it makes things up; its only users so far are the 4 teammates.
  • Some of my work hasn’t merged and sits on branches.

Who did what

I wrote the scraper with its tests and CI job, the card facts, the scheduled workflow and its dry run, the Vercel deployment, the profile schema design, and Scout. Xiang Liu wrote the import script with its transaction and guards, the database schema and migrations, Supabase sign-in and row-level security. Brian Cao created the Supabase project. Vedant Vyas added Google and LinkedIn sign-in. Andrew Shi built most of the web app and added role categories and providers to the scraper.