Capstone β Job Posting Scraper & Tracker#
Build a scraper that runs itself, notices what changed, and answers questions about the market.
β± ~6β8 hours π needs: Hidden JSON APIs Β· Change Detection & Dedup Β· DuckDB + Parquet Β· Scheduled Scraping
Anyone can scrape a page once. This capstone is about the harder, more valuable thing: a pipeline that runs unattended for weeks, doesn’t duplicate data, doesn’t get banned, and produces a dataset worth querying.
βοΈ Choose your targets before you write code. Read each site’s
robots.txtand Terms. Prefer sites with a public API or feed, and job boards that permit it. Job posts contain personal data (recruiter names, emails) β collect only the fields your questions need, and never publish personal contact details. Your submission must include the decision record for each source. A source you can’t justify is a source you don’t scrape.
What you’re building#
flowchart LR
S1["Source A"] --> A["Acquire<br/>(hidden API > HTML)"]
S2["Source B"] --> A
A --> N["Normalise<br/>+ validate schema"]
N --> ST["SQLite state<br/>(IDs, hashes, last_seen)"]
ST -->|"new / changed only"| P["Parquet<br/>(dated partitions)"]
P --> Q["DuckDB queries"]
Q --> D["Dashboard"]
CR["GitHub Actions cron"] -.->|daily| ARequirements#
1. Acquire β two different sources. At least two job sources, each justified in your decision record.
- Try hardest for a hidden JSON API; most job boards have one. Document how you found it (or why there isn’t one).
- Fall back to Playwright only where necessary, and say why.
- Handle pagination with an explicit stop condition and a
MAX_PAGEScap. - Be polite: rate limits, backoff, and caching.
2. Normalise β a validated schema. Every record conforms to a Pydantic model; rows that fail are quarantined with the reason, not silently dropped.
class JobPosting(BaseModel):
job_id: str # stable ID, derived from the canonical URL
title: str
company: str
location: str | None
salary_min: int | None # parse "βΉ12β18 LPA" into numbers where you can
salary_max: int | None
posted_date: date | None
url: str
source: str
content_hash: str # over the meaningful fields only3. Track state incrementally. SQLite holds job_id, content_hash, first_seen, last_seen. A second run on unchanged data must report 0 new, 0 changed. Detect removed postings via last_seen β a job disappearing is a signal (filled or expired).
4. Store as dated Parquet. data/date=YYYY-MM-DD/postings.parquet, so DuckDB can glob the history.
5. Schedule it. A GitHub Actions cron running daily that commits only when data changed, and fails loudly if it collects suspiciously few rows.
6. Answer questions. A DuckDB-backed dashboard (Streamlit, or static HTML + a generated JSON) showing at least:
- Postings per day, and the trend over your collection window
- Top companies and locations hiring
- Salary distribution where parseable
- Churn: how many postings appeared and disappeared this week
Deliverables#
| # | Item |
|---|---|
| 1 | Public repo: scraper, dashboard, README.md with setup |
| 2 | Decision record per source: robots/ToS, four legality questions, verdict |
| 3 | Actions run history showing β₯5 successful scheduled runs on different days |
| 4 | The Parquet dataset (or a sample if large) |
| 5 | Live dashboard link, or a screenshot plus run instructions |
| 6 | A short FINDINGS.md: three things the data told you, with the query for each |
Grading#
| Weight | Criterion |
|---|---|
| 20% | Acquisition β found the real data source; polite; handles pagination |
| 20% | Correctness β schema validated; idempotent; a re-run yields 0 new/0 changed |
| 15% | Automation β genuinely runs unattended; commits only on change; fails loudly |
| 15% | Storage & querying β sensible Parquet layout; DuckDB queries that answer real questions |
| 15% | Dashboard β readable, honest, and actually driven by your data |
| 15% | Ethics & judgment β decision record, data minimisation, no personal contact details |
Stretch goals#
- Fuzzy-match the same role posted across both sources (entity resolution).
- Extract required skills from descriptions with an LLM into a validated schema β then measure how often it’s wrong.
- Alert (email/Discord) when a posting matching your criteria appears.
- Backfill history from the Wayback Machine to extend your window past your start date.
Checklist before you submit#
- Running twice in a row reports 0 new, 0 changed.
- A deliberately broken selector makes the run fail, not write empty data.
- No API keys or
auth.jsonin the repo. - No personal contact details in the dataset.
- Every source has a written decision record.
- A stranger can clone the repo and run it from the README alone.