Job posting saver with Chrome extension and Python API
Chrome extension saves job postings from any site into a local tracker with status, notes, and follow-up reminders, backed by a FastAPI and SQLite server.
pythonfastapisqlitejavascriptchrome-extensionpytest
Built 1 time, downloaded 0 times.
Install from your terminal
Run this in your project folder. It signs you in, shows what changes and asks before it overwrites a file.
npx claude-lab add job-posting-saver-with-chrome-extension-and-python-api-4a3257CLAUDE.md# Job posting saver
Manifest V3 Chrome extension (plain JavaScript, no build step) that captures job postings from any site and POSTs them to a local FastAPI + SQLite backend. Solo user, runs on this machine only. Tracks status, notes and follow-up reminders.
@SPEC.md
@REVIEW.md
## Layout
- extension/: manifest.json, background.js (service worker), content.js (parser), popup.html and popup.js, options page, tracker page
- server/app/: main.py, models.py, db.py, schemas.py, reminders.py
- tests/: pytest suite; tests/fixtures/ holds saved posting HTML
- PLAN.md: phased build plan. Each phase ends with a /goal prompt.
## Commands
Run from the repo root unless noted.
- Install: `uv sync` or `pip install -r requirements.txt`
- Dev server (from server/): `uvicorn app.main:app --reload --port 8765`
- Tests: `pytest -q`
- Lint: `ruff check .`
- Format: `ruff format .`
- Types: `mypy server/app`
- Full check before calling work done: run the verify skill
## Gotchas
- MV3 service workers are killed when idle. Keep no state in globals; persist to chrome.storage.
- Reminders use chrome.alarms in the extension. The server only stores follow_up_at. Re-register all alarms on startup.
- Some page CSPs block localhost requests from content scripts. Fetch from background.js only.
- Extraction order: JSON-LD JobPosting first, then Open Graph and meta tags, then manual fields in the popup. Never fail a save because parsing failed.
- Parser logic lives in content.js. server/app/parse.py mirrors it for Python-side fixture tests, so change both together.
## Conventions
- Type hints everywhere. Pydantic models for all request and response bodies.
- Status is an enum: saved, applied, interviewing, offer, rejected, withdrawn.
- Timestamps are UTC ISO 8601.
- Dedupe by normalized URL: lowercase host, strip fragments and utm_* params, trim trailing slash.
- Tests use a temporary SQLite DB, never the dev database.
- Keep functions small.
## Security
- Server binds to 127.0.0.1 only.
- CORS allows only the extension origin, set by env var EXTENSION_ORIGIN.
- Optional API token is read from env API_TOKEN. Use the placeholder YOUR_API_TOKEN in docs and examples. Never commit a real token.
- Never log posting text or notes.
## Workflow
- Explore, plan, code, then run the verify skill before saying done.
- /clear between unrelated tasks. /compact at natural breaks.
- Hand test output to the test-runner subagent to keep context small.
- Phase 1 touches server/, tests/ and requirements.txt or pyproject.toml only. No extension code until its phase.
- Do not add dependencies or a JS build tool without asking.
.claude/agents/test-runner.md---
name: test-runner
description: Runs pytest, ruff and mypy and reports only failures concisely
model: haiku
tools: Bash, Read
disallowedTools: Edit, Write
maxTurns: 8
---
You run the project's checks and report only what failed. You do not edit code.
Run these commands from the repo root, in this order:
1. `ruff check .`
2. `ruff format --check .`
3. `mypy server/app`
4. `pytest -q`
Rules:
- Run every command even if an earlier one fails, so the report covers all failures.
- If a command is not installed or cannot start, report it as "not run" with the error line. Do not guess a result.
- Use `Read` only to look at a file around a reported line when the error message alone is not enough. Keep reads short.
- Never paste full logs. Do not include passing tests or successful output.
For each failure, report:
- Command name
- Failing test name or rule code (for example `tests/test_jobs.py::test_patch_status`, or `F401`)
- `file:line`
- The key error message, one line
End with a single line: `PASS` if all four commands succeeded, otherwise `FAIL: <number> failing commands`.
.claude/settings.json{
"permissions": {
"allow": [
"Bash(pytest:*)",
"Bash(ruff:*)",
"Bash(mypy:*)",
"Bash(uv:*)",
"Bash(uvicorn:*)",
"Bash(git status)",
"Bash(git diff:*)",
"Bash(git log:*)"
],
"ask": [
"Bash(pip install:*)",
"Bash(git commit:*)",
"Bash(git push:*)"
],
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Bash(rm -rf:*)",
"Bash(curl:*|sh)",
"Bash(sudo:*)"
],
"blockWritesOutsideWorkingDirectories": true
},
"env": {
"CLAUDE_CODE_SUBAGENT_MODEL": "haiku"
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "python3 -c \"import sys,json,subprocess; p=json.load(sys.stdin).get('tool_input',{}).get('file_path',''); p.endswith('.py') and subprocess.run(['ruff','format',p],capture_output=True)\" 2>/dev/null || true"
}
]
}
]
}
}
.claude/skills/parse-posting/SKILL.md---
name: parse-posting
description: Add a parser fixture or fix wrong title or company extraction for a job site in the extension's content.js.
disable-model-invocation: false
---
# Parse posting
Playbook for adding support for a job site or fixing wrong extracted fields.
Extraction order is fixed:
1. JSON-LD JobPosting
2. Open Graph and meta tags
3. DOM heuristics
4. Manual fields in the popup
A parse failure must never block a save.
## Procedure
1. Save the page HTML to `tests/fixtures/<site>.html`. Remove personal data first: names, emails, phone numbers, saved-job lists, account menus, and tokens in URLs.
2. Write the failing test first. Assert the expected title and company for the fixture. Parser tests run against the logic in `server/app/parse.py`, which mirrors `content.js`. If that file does not exist yet, add a manual checklist entry for the fixture instead and say so in the summary.
3. Run `pytest -q` and confirm the new test fails for the right reason.
4. Adjust extraction in `extension/content.js`. Make the smallest change that works: a JSON-LD case, a meta fallback, or a site-specific rule. Keep the extraction order.
5. If the parser logic changed, mirror the change in `server/app/parse.py`.
6. Run `pytest -q` until it passes, then run the verify skill.
7. Commit with a message that names the site and the field fixed. Do not commit fixtures that still contain personal data.
## Rules
- Never hard-code a selector that only exists behind a login.
- Keep each site-specific rule under 10 lines, with a comment above it that names the site, for example `// Site: example-jobs.com`.
- Prefer structured data (JSON-LD, meta tags) over DOM selectors. DOM selectors break when a site redesigns.
- Fall back gracefully. A missing field becomes null, not an error, and the save still goes through with manual fields.
- Do not fetch extra pages or call external services to fill fields.
- Do not log page text or posting content.
- Content scripts cannot call localhost under some page CSPs. Send the parse result to `background.js`, which posts it to the API.
- Do not widen host permissions to fix one site. If a new site needs one, justify it in a comment in `manifest.json`.
## Fields to verify
Check each field against the expected value in the test:
- **title**: the job title only, with no company name or "Apply now" text.
- **company**: the hiring company. Use a recruiter platform's name only when that platform is the employer.
- **location**: city, region or "Remote" as shown on the page. Null if absent.
- **salary_text**: the salary exactly as written on the page, unparsed. Null if absent.
- **url**: the canonical URL from `<link rel="canonical">` or `og:url` when present, otherwise the current page URL. The server normalizes it: lowercase host, no fragment, no utm_* params, no trailing slash.
- **description_excerpt**: plain text with whitespace collapsed, truncated to 2000 characters.
.claude/skills/verify/SKILL.md---
name: verify
description: Run tests, lint, type check and a smoke test of the API and extension manifest before declaring work done
allowed-tools: Bash, Read
---
# Verify
Prove a change works for this stack before saying it is done. Run the steps in order from the repo root unless a step says otherwise.
## Step 1: Lint and format check
```
ruff check .
ruff format --check .
```
Record pass or fail. If only the format check fails, run `ruff format .`, then rerun the check.
## Step 2: Type check
```
mypy server/app
```
## Step 3: Tests
Run `pytest -q`. Report only failures: test name, file:line, and the first error lines. Do not paste the full log.
## Step 4: API smoke run
Run this as one shell block so the server is stopped in the same command, even if a check fails:
```
cd server
uvicorn app.main:app --port 8765 &
PID=$!
for i in $(seq 1 20); do curl -s localhost:8765/health && break; sleep 0.5; done
curl -s -X POST localhost:8765/jobs -H 'Content-Type: application/json' \
-d '{"url":"https://example.com/jobs/1?utm_source=test","title":"Smoke test engineer","company":"Example"}'
curl -s -X POST localhost:8765/jobs -H 'Content-Type: application/json' \
-d '{"url":"https://example.com/jobs/1?utm_source=test","title":"Smoke test engineer","company":"Example"}'
curl -s "localhost:8765/jobs?q=Smoke"
kill $PID
cd ..
```
Check these results:
- `/health` returns ok.
- Both POSTs return the same `id`. If the body needs more required fields, use the fields in `server/app/schemas.py`.
- The search returns exactly one row.
If `API_TOKEN` is set in the environment, send the auth header the server expects (check `server/app/main.py`). Never print the token.
## Step 5: Extension manifest
If `extension/manifest.json` does not exist yet, mark this step SKIPPED and give the reason.
Otherwise check:
- `python -m json.tool extension/manifest.json` succeeds.
- `manifest_version` is 3.
- Every file the manifest references exists. Check `background.service_worker`, each `content_scripts[].js`, `action.default_popup`, `options_page`, and any icons. Paths are relative to `extension/`.
## Step 6: Report
Print a table with one row per step: PASS, FAIL or SKIPPED, plus the reason for any skip or failure.
Then give the overall verdict:
- **PASS**: every step ran and passed.
- **PASS WITH SKIPS**: every step that ran passed, and the skipped steps are named.
- **FAIL**: any step failed. Give the exact command that failed and its first error lines.
Do not say the work is done or successful when a step failed, or when a skip is not named.
## Manual check
If the change touched extension UI (popup, options page, tracker page), tell the user to:
1. Open chrome://extensions and turn on Developer mode.
2. Click Load unpacked and choose the `extension/` folder (or click the reload button if already loaded).
3. Open the popup or page and check it.
Label this as manual. The skill does not verify it.
INSTALL.md# Install and first session
For an intermediate user. Setup takes about 30 minutes.
## Prerequisites
- Python 3.11 or newer
- uv or pip
- Google Chrome
- Claude Code 2.1.296 or newer (check with `claude --version`)
## 1. Create the project
1. Create a folder for the project and open a terminal in it.
2. Copy the planning files into the repo root: CLAUDE.md, SPEC.md, PLAN.md, REVIEW.md, INSTALL.md.
3. Copy `.claude/` into the repo root. It holds settings, skills and the test-runner agent.
4. Run `git init`.
## 2. Create the Python environment
On macOS or Linux:
```
python -m venv .venv
source .venv/bin/activate
pip install fastapi uvicorn sqlmodel pytest httpx ruff mypy
```
On Windows, activate with `.venv\Scripts\activate`.
If you use uv, Phase 1 can create pyproject.toml, and `uv sync` replaces the pip line.
## 3. Install Claude Code plugins
Start Claude Code in the project with `claude`, then run:
```
claude plugin marketplace add anthropics/skills
/plugin install code-review@claude-plugins-official
/plugin install feature-dev@claude-plugins-official
/plugin install commit-commands@claude-plugins-official
/plugin install security-guidance@claude-plugins-official
/plugin install example-skills@anthropic-agent-skills
```
Then run `/skill-doctor`. It is a built-in command. Confirm that the verify and parse-posting skills load.
## 4. Start Phase 1
1. Paste the Phase 1 `/goal` prompt from PLAN.md. This is the kickoff prompt.
2. Claude builds the backend in server/, tests/ and the dependency file.
3. Check the result yourself. Look for the verify skill output showing all steps passing.
## 5. Between phases
1. Run `/clear` to empty the context.
2. Review the diff and commit with commit-commands.
3. Paste the next phase's `/goal` prompt from PLAN.md.
4. Run `/goal clear` if a goal stops early or you want to change it.
Do not start Phase 2 until Phase 1 passes verify.
## 6. Load the extension (after Phase 2)
1. Open chrome://extensions.
2. Turn on Developer mode.
3. Click Load unpacked and choose the extension/ folder.
4. Open the options page and set the server URL to https://example.com
5. If API_TOKEN is set on the server, paste the same value in the options page.
## 7. Run the server
From server/ with the environment active:
```
uvicorn app.main:app --reload --port 8765
```
Keep this terminal open while you use the extension.
## Token saving tips
- Run `/clear` between phases and between unrelated tasks.
- Run `/usage` to see where tokens went.
- Run `/compact` at natural breaks.
- Let the test-runner subagent read test output. It returns failures only.
## Troubleshooting
- **Service worker inactive:** Chrome stops idle workers. Click the toolbar button again or reload the extension. Saved state lives in chrome.storage, so nothing is lost.
- **CORS errors:** Set EXTENSION_ORIGIN on the server to your extension's origin, shown on chrome://extensions. Restart uvicorn.
- **Alarms not firing:** Reload the extension so alarms re-register on startup. Check that the follow-up date is in the future.
- **Notifications blocked:** Open Chrome site settings for the extension and allow notifications. Chrome may also block them in system settings.
- **Server refuses connections:** Confirm uvicorn is running on port 8765 and the server URL in options matches it.
PLAN.md# Build plan
Six phases. Each phase ends with a `/goal` prompt. Run one phase at a time, then `/clear`, commit, and start the next.
## Model guidance
- Use the default model for build work.
- Use `ultrathink` only for the extraction design (phase 3) and the MV3 alarm and service-worker lifecycle (phase 5).
## Phase 1: Backend API
- FastAPI app in `server/app/`, SQLite via SQLModel
- CRUD endpoints, URL normalization, dedupe on normalized URL
- pytest tests using a temporary database
```
/goal Phase 1 of PLAN.md is done: a FastAPI backend in server/app with SQLite storage. Endpoints: GET /health, POST /jobs, GET /jobs (filters status and q), GET /jobs/{id}, PATCH /jobs/{id}, DELETE /jobs/{id}. Status is an enum (saved, applied, interviewing, offer, rejected, withdrawn). URLs are normalized and unique; a duplicate POST updates the existing row. follow_up_at is a nullable UTC ISO datetime. Tests in tests/ use a temporary SQLite file and cover create, duplicate save, status filter, PATCH notes and status, delete, 404s, and invalid status returning 422. Checks: run the verify skill and make sure `ruff check .`, `ruff format --check .`, `mypy server/app` and `pytest -q` all pass, and the uvicorn smoke run answers on /health. Show the final command output. Constraints: touch only server/, tests/, and requirements.txt or pyproject.toml; add no dependencies beyond fastapi, uvicorn, sqlmodel, pydantic, pytest, httpx, ruff, mypy; bind to 127.0.0.1; do not write extension code. Delegate test runs to the test-runner subagent. Stop after 25 turns and report what is left.
```
## Phase 2: Extension skeleton
- `extension/manifest.json`: MV3, permissions `activeTab`, `storage`, `alarms`, `notifications`, `scripting`
- Host permission only for the server URL
- Popup that saves a manual entry to the API
```
/goal Phase 2 of PLAN.md is done: a loadable MV3 extension in extension/. manifest.json has manifest_version 3, permissions activeTab, storage, alarms, notifications, scripting, and a host permission only for the server URL. popup.html and popup.js let the user enter title, company, location and URL manually and save them to POST /jobs through background.js. Show a clear error when the server is down and keep the draft in chrome.storage. Options page stores server URL and token. Checks: the verify skill passes (manifest step included), `pytest -q` and `ruff check .` pass, and the manifest references only files that exist. Constraints: touch only extension/ and tests/ for any manifest check; no build tools, no npm, no new Python dependencies; no <all_urls>; no eval. Keep no state in service worker globals. Stop after 25 turns and report what is left.
```
## Phase 3: Extraction
- `content.js` parser: JSON-LD first, then meta and Open Graph, then DOM heuristics
- Fixture HTML files in `tests/fixtures/`
- Python-side tests that mirror the parser logic in `server/app/parse.py`, or a manual checklist if that is not feasible
```
/goal Phase 3 of PLAN.md is done: extraction works for the JSON-LD fixture and at least one meta-only fixture. content.js extracts title, company, location, salary_text, canonical url and description_excerpt (max 2000 characters) in this order: JSON-LD JobPosting, then Open Graph and meta tags, then DOM heuristics. Parsing never blocks a save; manual fields stay editable in the popup. Mirror the parsing rules in server/app/parse.py and test them with pytest against fixtures in tests/fixtures/. Add a short test that fails before the parser change and passes after it. Checks: the verify skill passes, `pytest -q` passes, `ruff check .` passes. Constraints: touch only extension/, server/app/parse.py, tests/ and tests/fixtures/; no site-specific rule longer than 10 lines; no login-only selectors. Stop after 25 turns and report what is left.
```
## Phase 4: Tracker page and notes
- Tracker page lists jobs, filters by status, edits status and notes, deletes
- `GET /jobs.csv` exports the list
```
/goal Phase 4 of PLAN.md is done: a tracker page in extension/ that lists jobs from GET /jobs, filters by status, edits status and notes with PATCH /jobs/{id}, and deletes with DELETE /jobs/{id}. Add GET /jobs.csv on the server that returns the same filtered list as CSV with a header row. Notes must never be lost on PATCH; add a test for that. Checks: the verify skill passes, `pytest -q` and `ruff check .` pass, and the webapp-testing skill confirms the tracker page renders and filters. Constraints: touch only extension/, server/, tests/; no new dependencies; no notes or posting text in logs. Stop after 25 turns and report what is left.
```
## Phase 5: Reminders
- `follow_up_at` set from the popup and from snooze
- `chrome.alarms` scheduled on save and resynced on startup
- Notifications open the tracker page; snooze for 1 day or 3 days
```
/goal Phase 5 of PLAN.md is done: follow-up reminders work end to end. Setting follow_up_at in the popup stores a UTC ISO time on the server and creates a chrome.alarms alarm. On service worker startup, background.js re-registers every alarm from the server list, because the worker can be killed at any time. Clicking the notification opens the tracker page. Snooze buttons set follow_up_at 1 day or 3 days later in UTC. Add pytest tests for timestamp handling and the snooze calculation on the server side. Checks: the verify skill passes, `pytest -q` and `ruff check .` pass. Constraints: no timers in service worker globals; no new dependencies; touch only extension/, server/, tests/. Stop after 25 turns and report what is left.
```
## Phase 6: Polish and hardening
- Error states in popup and tracker
- Token auth and CORS lock-down
- README and a manual load-unpacked test checklist
```
/goal Phase 6 of PLAN.md is done: the extension and server are hardened. The server accepts requests only from EXTENSION_ORIGIN (CORS), and when API_TOKEN is set it requires the token on every request except /health. Every popup and tracker error shows a readable message. README.md explains setup, and a manual checklist covers load-unpacked testing, reminders, and offline behavior. Add tests for the 401 and CORS cases. Checks: the verify skill passes, `pytest -q`, `ruff check .` and `mypy server/app` pass. Constraints: no secrets in the repo; placeholder YOUR_API_TOKEN only; never log posting text; touch only server/, extension/, tests/ and README.md. Stop after 25 turns and report what is left.
```
REVIEW.md# Review checklist
Read by /code-review through the import in CLAUDE.md. Check every item in the diff.
## Extension
- No remote code, no `eval`, no `new Function`, no inline scripts.
- Permissions stay minimal: activeTab, storage, alarms, notifications, scripting.
- Host permissions cover only the server URL. Any `<all_urls>` needs a comment that names the content script that requires it.
- The service worker keeps no state in globals. Use chrome.storage.
- Alarms are re-registered on save and on startup.
- Content scripts never call localhost. Fetches go through background.js.
## Server
- Every endpoint has Pydantic request and response schemas.
- Status uses the enum: saved, applied, interviewing, offer, rejected, withdrawn.
- Errors return correct HTTP codes: 404 for missing jobs, 422 for invalid input, 401 for a bad or missing token.
- SQL is parameterized. No queries built from strings.
- The server binds to 127.0.0.1 only. CORS allows only EXTENSION_ORIGIN.
## Privacy
- Posting text and notes are never logged or sent to third parties.
- No secrets in the repo. API_TOKEN comes from the environment only.
## Tests and style
- New behavior has a pytest test. Parser changes add a fixture in tests/fixtures/.
- `ruff check .` and `ruff format --check .` pass.
- Type hints are present. Functions stay small.
## High priority
- Duplicate saves: the same normalized URL must update the existing row.
- Lost notes on PATCH: a partial update must keep fields it did not send.
- Timezone bugs in follow_up_at: store UTC, return ISO with offset.
SPEC.md# Job tracker spec
## Goal
Save job postings from any site into a personal tracker with status, notes and follow-up reminders. Solo user, runs locally.
## User story
While viewing a posting, I click the toolbar button, see the prefilled title, company, location, URL and salary (if found), and save in one click.
## Data model
Job fields:
- `id` (integer, primary key)
- `url` (text, unique, normalized)
- `title`, `company`, `location` (text)
- `salary_text` (text, nullable)
- `description_excerpt` (text, max 2000 characters)
- `status` (enum: saved, applied, interviewing, offer, rejected, withdrawn)
- `notes` (text)
- `saved_at`, `updated_at` (UTC ISO timestamps)
- `follow_up_at` (UTC ISO timestamp, nullable)
URL normalization: lowercase host, strip fragments and `utm_*` parameters, trim the trailing slash.
## API
- `POST /jobs` creates a job, or updates the existing row when the normalized URL already exists
- `GET /jobs?status=&q=` lists jobs, filtered by status and a text search
- `GET /jobs/{id}` returns one job
- `PATCH /jobs/{id}` updates any subset of fields
- `DELETE /jobs/{id}` removes a job
- `GET /health` returns `{"status": "ok"}`
The server binds to 127.0.0.1. The API token is optional and read from the `API_TOKEN` environment variable.
## Extension UI
- Popup: save the current posting, pick a status, edit notes, set a follow-up date
- Options page: server URL and API token
- Tracker page: list all jobs, filter by status, open and edit a job
## Reminders
- `chrome.alarms` fires a notification at `follow_up_at`
- Clicking the notification opens the tracker page
- Snooze options: 1 day or 3 days
- The server stores due dates only; the extension schedules alarms
## Acceptance criteria
- Saving the same normalized URL twice updates the existing row and creates no duplicate.
- A PATCH that changes status persists after a server restart.
- A PATCH that changes notes leaves all other fields unchanged.
- The parser returns a title and company for the JSON-LD fixture in `tests/fixtures/`.
- An invalid status value returns HTTP 422.
- `GET /jobs?status=applied` returns only jobs with status `applied`.
- With the server stopped, the popup shows a clear error and keeps the draft in `chrome.storage`.
- Snoozing for 1 day sets `follow_up_at` to 24 hours later, in UTC.
## Offline behavior
If the server is unreachable, the popup shows a clear error message, keeps the draft in `chrome.storage`, and lets the user retry without retyping.
## Out of scope for v1
- Accounts and cloud sync
- Firefox and Safari support
- Auto-apply
- Email integration
- AI summaries
## Open questions
Fill in during phase 1.
- Which sites are the first priority for parser fixtures?
- Should the description excerpt be stored at all if it raises privacy concerns?
- Is a single default follow-up offset needed besides the snooze options?