#!/usr/bin/env python3 """Canonical dedup key for a job posting, and an audit for existing state. `/scrape` Step 4 keys every seen_jobs.json entry by company+title. The rule was prose only (""), so each run slugified in its own way and the state file accumulated two distinct failures: * Keys carrying characters that break things downstream. `/apply` and `/outcome` derive an archive folder name from the same company+role pair, and documents/README.md's subfolder rule exists because a "/" splits that path across directories. Real examples found in a live workspace: "deloitte_junior-cybersecurity-analyst-(ot/iot)", "neverhack-estonia_penetration-tester-/-red-teamer", "ops-consulting,-llc_malware-analyst". * The same job stored twice under different keys, because one run truncated the title at a different point than the next. "deloitte_cyber-intelligence- center-security-analy" and "deloitte_cyber-intelligence-center-security- analyst-at" are one posting, one URL, two entries - and dedup is the whole point of the file. Both are fixed by making the key a pure, deterministic function of the posting. Truncation is length-capped *and* disambiguated by a hash of the full slug, so a long title always produces the same key and two different long titles never collide. A title that slugifies to nothing (a posting written in a non-Latin script) has no usable key half at all - "securion_" was a real entry, and it would have collided with every future non-Latin posting from that company. Those fall back to the portal's numeric id from the URL. Usage: python3 tools/job_key.py --company "Acme Corp" --title "SOC Analyst (L2)" python3 tools/job_key.py --audit job_scraper/seen_jobs.json Exit 0 when a key is produced, or when an audit finds nothing. Exit 1 when an audit finds violations. """ import argparse import hashlib import json import re import sys import unicodedata from pathlib import Path ROOT = Path(__file__).resolve().parent.parent STATE = ROOT / "job_scraper" / "seen_jobs.json" COMPANY_MAX = 40 TITLE_MAX = 60 HASH_LEN = 6 # Anything outside this set becomes a separator. Deliberately strict: "/" and # "," are the characters that actually caused damage, and an allowlist cannot # be surprised by the next punctuation mark a job board invents. _NON_SLUG = re.compile(r"[^a-z0-9]+") _JOB_ID = re.compile(r"(\d{6,})") def slugify(text: str) -> str: """Lowercase ASCII slug. Non-Latin scripts legitimately reduce to ''.""" if not text: return "" decomposed = unicodedata.normalize("NFKD", str(text)) ascii_only = decomposed.encode("ascii", "ignore").decode("ascii") return _NON_SLUG.sub("-", ascii_only.lower()).strip("-") def _cap(slug: str, limit: int) -> str: """Cap length without making truncation lossy across runs. A bare truncation is what produced the duplicate Deloitte entries: two runs cut the same title at different points and the file gained a second key for one job. Appending a hash of the *full* slug makes the result deterministic for a given title and distinct for any other. """ if len(slug) <= limit: return slug digest = hashlib.sha1(slug.encode("utf-8")).hexdigest()[:HASH_LEN] return f"{slug[:limit].rstrip('-')}-{digest}" def make_key(company: str, title: str, url: str = "") -> str: """The canonical seen_jobs.json key for one posting.""" company_slug = _cap(slugify(company), COMPANY_MAX) or "unknown-company" title_slug = _cap(slugify(title), TITLE_MAX) if not title_slug: # No Latin characters in the title. The portal's own numeric id is the # only stable handle left; never emit a bare "company_" prefix. match = _JOB_ID.search(url or "") if match: title_slug = match.group(1) else: basis = slugify(unicodedata.normalize("NFKD", str(title or url or ""))) digest = hashlib.sha1((str(title) + str(url)).encode("utf-8")).hexdigest()[:HASH_LEN] title_slug = basis or f"untitled-{digest}" return f"{company_slug}_{title_slug}" # A canonical key is "_": lowercase alphanumerics and # hyphens on either side of exactly one underscore. The underscore is the # separator, so it is the one character outside the slug alphabet that belongs. _CANONICAL = re.compile(r"^[a-z0-9][a-z0-9-]*_[a-z0-9][a-z0-9-]*$") def is_canonical(key: str) -> bool: """Structurally safe as a dedup key and as an archive folder name.""" return bool(key) and bool(_CANONICAL.match(key)) def is_legacy_shape(key: str) -> bool: """Old three-part "company_title_location" keys. Harmless - they carry no path-breaking character - but they are not what make_key produces, so a later run would store the same job under a new key and reintroduce a duplicate. Reported apart from real damage so the fix stays a decision rather than an automatic rename. """ return bool(key) and key.count("_") > 1 and all( re.fullmatch(r"[a-z0-9][a-z0-9-]*", part) for part in key.split("_") if part ) def audit(path: Path) -> int: try: doc = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: print(f"cannot read {path}: {exc}", file=sys.stderr) return 1 seen = doc.get("seen", doc) if not isinstance(seen, dict): print(f"{path}: expected an object of job entries", file=sys.stderr) return 1 malformed = [k for k in seen if not is_canonical(k) and not is_legacy_shape(k)] legacy = [k for k in seen if is_legacy_shape(k)] by_url: dict[str, list[str]] = {} for key, entry in seen.items(): url = (entry.get("url") or "").rstrip("/") if url: by_url.setdefault(url, []).append(key) duplicates = {u: ks for u, ks in by_url.items() if len(ks) > 1} # A key that does not match what make_key would produce today is drift, not # damage: reported separately so a rename is a choice, never automatic. drift = [ k for k, v in seen.items() if is_canonical(k) and k != make_key(v.get("company", ""), v.get("title", ""), v.get("url", "")) ] print(json.dumps({ "entries": len(seen), "malformed_keys": malformed, "legacy_three_part_keys": legacy, "duplicate_urls": duplicates, "keys_not_matching_current_rule": len(drift), }, indent=2, ensure_ascii=False)) return 1 if (malformed or duplicates) else 0 def main() -> int: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--company") ap.add_argument("--title") ap.add_argument("--url", default="") ap.add_argument("--audit", nargs="?", const=str(STATE), metavar="STATE_JSON") args = ap.parse_args() if args.audit: return audit(Path(args.audit)) if args.company is None or args.title is None: ap.error("give --company and --title, or --audit") print(make_key(args.company, args.title, args.url)) return 0 if __name__ == "__main__": sys.exit(main())