From 8123d88f301b7b678fffdf948b4e797f1915a4fa Mon Sep 17 00:00:00 2001 From: Mariano Gobea Alcoba Date: Sat, 17 Oct 2026 15:10:20 -0300 Subject: [PATCH] fix(scrape): use the full job_scraper/seen_jobs.json path in every step of the spec (#608) * fix(scrape): use the full job_scraper/seen_jobs.json path in every step of the spec Step 0 reads job_scraper/seen_jobs.json but Steps 1-4 and the rules used the bare filename. Step 4 is where the file is written, so an agent following it literally can create seen_jobs.json in the repo root, outside the **/job_scraper/seen_jobs.json ignore rule, where it gets committed with the user's scrape history. Adds a spec test that rejects bare references; relaxes the existing dedup-continuity regex to accept the qualified path. * docs(changelog): entry for #608 Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Mads Lorentzen Co-authored-by: Claude Fable 5.1 --- .claude/skills/job-scraper/SKILL.md | 14 +++++++------- CHANGELOG.md | 7 +++++++ tests/test_scrape_contract.py | 2 +- tests/test_scrape_seen_jobs_path.py | 30 +++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 8 deletions(-) create mode 100644 tests/test_scrape_seen_jobs_path.py diff --git a/.claude/skills/job-scraper/SKILL.md b/.claude/skills/job-scraper/SKILL.md index e14f6945..1dbcf671 100644 --- a/.claude/skills/job-scraper/SKILL.md +++ b/.claude/skills/job-scraper/SKILL.md @@ -98,7 +98,7 @@ command (see its SKILL.md — do not guess flags) to extract **key requirements* `false` means the posting page itself renders LinkedIn's "No longer accepting applications" banner — the job died between being indexed and being fetched (expired LinkedIn URLs redirect to *similar live jobs*, so a search hit can be a ghost). Mark -such a job, never silently drop it: write its entry to `seen_jobs.json` in Step 4 with +such a job, never silently drop it: write its entry to `job_scraper/seen_jobs.json` in Step 4 with `"status": "expired"` and leave it out of the Step 5 presentation — an absent entry looks identical to a job never seen, and the recorded status is what makes a later ghost report self-triaging. `isActive: true` is only the absence of that banner, not @@ -117,10 +117,10 @@ site for the role and store that URL instead, or drop the candidate rather than fragment link. For every candidate: -- Skip if the URL matches any existing `seen_jobs.json` entry, regardless of +- Skip if the URL matches any existing `job_scraper/seen_jobs.json` entry, regardless of that entry's key. This preserves dedup continuity for postings stored under the pre-helper key rule while new entries use the canonical key from Step 4. -- Otherwise, skip if the company+title combo already exists in `seen_jobs.json` +- Otherwise, skip if the company+title combo already exists in `job_scraper/seen_jobs.json` - Skip if the company+role already appears in `job_search_tracker.csv` ### Step 2.5: Mass-Posting Detection (within this run) @@ -149,7 +149,7 @@ python3 tools/job_key.py --company "" --title "" --url "<url>" It prints one line: the canonical key for that posting. The key must be a pure function of the posting, because two runs that slugify differently store the same job twice and defeat the dedup this step exists to provide. The helper also length-caps long titles and disambiguates the cap with a hash of the full slug, so a truncated title is stable across runs and two different long titles never collide. `python3 tools/job_key.py --audit` reports entries in an existing state file that predate this rule; it only reports, and never rewrites keys, since a rewritten key breaks the tracker's own company+role matching. -2. Add ALL fetched jobs (new and skipped) to `seen_jobs.json` with structure: +2. Add ALL fetched jobs (new and skipped) to `job_scraper/seen_jobs.json` with structure: ```json { "seen": { @@ -213,7 +213,7 @@ Scraper-based portal CLIs rot silently: when a portal changes its markup, the pa **Free pass (no extra requests).** For each enabled portal that ran in Step 1b: - **Degraded scan:** inspect the results it returned this run. Flags: `company` null or empty on every result, empty titles, undecoded entities (`&`) or HTML fragments in titles, URLs that do not point at the portal. Any of these means the parser is half-working and `/scrape` is silently collecting junk. -- **Yield history:** if the portal returned zero results across all of this run's queries, check whether `seen_jobs.json` holds prior entries from it (via the `portal` field, or by matching URL domains for entries predating the field). A portal that produced jobs on earlier runs and produces nothing now is suspect - the same queries worked before. +- **Yield history:** if the portal returned zero results across all of this run's queries, check whether `job_scraper/seen_jobs.json` holds prior entries from it (via the `portal` field, or by matching URL domains for entries predating the field). A portal that produced jobs on earlier runs and produces nothing now is suspect - the same queries worked before. **Escalation (bounded, on suspicion only).** A suspect portal gets **one** sentinel probe: run its documented `search` with the example query from its own SKILL.md (that query provably worked when the skill was registered), the portal's limit flag capped at 3, `--format json`. If that returns nothing, retry **once** with a single common word. Only then is the verdict **broken**. A 429 or block page is **never** evidence of breakage - record the portal as **inconclusive (rate-limited)**, back off, and do not retry. @@ -273,7 +273,7 @@ After presenting, ask: If the user picks a number, invoke the **job-application-assistant** skill workflow (fit evaluation first, then CV + cover letter if approved). -If the run found many new jobs (roughly 8+), also suggest `/rank` - it batch-scores all new postings against the full fit framework and returns a ranked shortlist, which beats eyeballing a long table. (`/rank` sets the `ranked`, `expired` and `unreachable` status values in `seen_jobs.json`; treat all three as already-seen for dedup purposes.) +If the run found many new jobs (roughly 8+), also suggest `/rank` - it batch-scores all new postings against the full fit framework and returns a ranked shortlist, which beats eyeballing a long table. (`/rank` sets the `ranked`, `expired` and `unreachable` status values in `job_scraper/seen_jobs.json`; treat all three as already-seen for dedup purposes.) ### Step 6: Update Tracker (Optional) @@ -284,7 +284,7 @@ If the user decides to apply to any job, the tracker row is written by **job-app ## Important Rules 1. **Never fabricate job postings.** Only present jobs from actual CLI search/detail output or WebSearch/WebFetch results. -2. **Respect deduplication.** Always check seen_jobs.json AND job_search_tracker.csv before presenting. +2. **Respect deduplication.** Always check job_scraper/seen_jobs.json AND job_search_tracker.csv before presenting. 3. **Focus on configured geographic area.** Skip jobs that require relocation or are clearly outside commute range. 4. **Only open positions.** Skip postings with expired deadlines or those marked as closed. 5. **Be efficient with detail fetches.** Don't run `detail` or WebFetch on every search hit — pre-filter by title/snippet, then fetch only promising matches. diff --git a/CHANGELOG.md b/CHANGELOG.md index 54768a8a..fe3e60ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,13 @@ per-file diff commands. ### Fixed +- **`/scrape` names `job_scraper/seen_jobs.json` by its full path in every step** (#608, + `.claude/skills/job-scraper/SKILL.md`, `tests/test_scrape_seen_jobs_path.py`) - seven references + after Step 0, including the Step 4 write instruction, used the bare filename, and an assistant + following them wrote the state file at the repository root, where the + `**/job_scraper/seen_jobs.json` ignore rule does not reach it; one fork committed its scrape + history that way. Every reference now carries the directory, and a test fails on any bare mention. + - **`salary_lookup.py` accepts a BOM-prefixed `salary_data.json`** (#594, #602, `salary_lookup.py`, `tests/test_salary_lookup.py`) - the data file is opened with `utf-8-sig`, so a file saved by Windows PowerShell `Out-File`/`Set-Content` or by an editor that prepends a byte-order mark now diff --git a/tests/test_scrape_contract.py b/tests/test_scrape_contract.py index 2cea7159..185e289e 100644 --- a/tests/test_scrape_contract.py +++ b/tests/test_scrape_contract.py @@ -143,7 +143,7 @@ class SeenJobsDedupContinuityTests(unittest.TestCase): text = SCRAPER_SKILL.read_text(encoding="utf-8") self.assertRegex( text, - r"URL matches any existing `seen_jobs\.json` entry, regardless of\s+that entry's key", + r"URL matches any existing `(?:job_scraper/)?seen_jobs\.json` entry, regardless of\s+that entry's key", "legacy seen_jobs entries must be matched by URL during the key-rule transition", ) diff --git a/tests/test_scrape_seen_jobs_path.py b/tests/test_scrape_seen_jobs_path.py new file mode 100644 index 00000000..c16cd116 --- /dev/null +++ b/tests/test_scrape_seen_jobs_path.py @@ -0,0 +1,30 @@ +"""Every seen_jobs.json reference in /scrape's spec names the full path. + +Step 0 reads `job_scraper/seen_jobs.json`, but later steps (notably Step 4, +where the file is written) used the bare filename. An agent following Step 4 +literally can create `seen_jobs.json` in the repo root. The ignore rules only +cover `**/job_scraper/seen_jobs.json`, so a root-level copy is untracked-by-rule +only by accident: it shows up in `git status`, gets swept into a commit, and +publishes the user's scrape history. Pinning the full path in the spec keeps +the read path, the write path and the ignore rule pointing at the same file. +""" +import re +import unittest +from pathlib import Path + +SKILL = Path(__file__).resolve().parent.parent / ".claude" / "skills" / "job-scraper" / "SKILL.md" +BARE = re.compile(r"(?<!job_scraper/)(?<![\w/.-])seen_jobs\.json") + + +class ScrapeSeenJobsPathSpec(unittest.TestCase): + def test_no_bare_seen_jobs_reference(self): + offenders = [ + f"line {i}: {line.strip()[:90]}" + for i, line in enumerate(SKILL.read_text(encoding="utf-8").splitlines(), 1) + if BARE.search(line) + ] + self.assertEqual(offenders, [], "use job_scraper/seen_jobs.json everywhere in the spec") + + +if __name__ == "__main__": + unittest.main()