mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-02 13:12:50 +00:00
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 <noreply@anthropic.com> --------- Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4 files changed
+45
-8
No files matched your search
@@ -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 "<company>" --title "<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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user