job-scraper-pipeline — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited job-scraper-pipeline (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Automated job scraping, deduplication, and gatekeeper scoring for ML/AI job hunting.
All PII, paths, and scoring keywords live in profile.yaml at the repo root. Copy profile.yaml.example to profile.yaml and edit. Keys this skill reads:
paths.queue — queue JSON location (default ~/hermes-merchant/state/queue/jobs.json)paths.scraper_log — log filescoring.required_keywords, scoring.adjacent_roles, scoring.reject_keywords, scoring.ai_companies, scoring.tiersMinimal loader (Python):
import yaml, os
from pathlib import Path
cfg = yaml.safe_load(Path("profile.yaml").read_text())
queue_path = Path(os.path.expanduser(cfg["paths"]["queue"]))
queue_path.parent.mkdir(parents=True, exist_ok=True)| Source | Method | Status | Notes |
|---|---|---|---|
| Naukri.com | browser_navigate | Blocked | Search results require JS rendering; homepage loads but listings aren't accessible. |
| Indeed.com | browser_navigate | Blocked by Cloudflare | "Additional Verification Required"; cannot bypass without residential proxies. |
| Together AI | Browser → https://job-boards.greenhouse.io/togetherai | Works | Greenhouse format — use the JS snippet below to extract all jobs. |
| Cohere | Browser → https://jobs.ashbyhq.com/cohere | Works (~100+ roles) | AshbyHQ — largest pipeline source. Departments include Agentic Platform, Modeling, Applied-ML, Inference, Model Serving. |
| Baseten | Browser → https://jobs.ashbyhq.com/baseten | Works | AshbyHQ. Many roles previously bounced on email — prefer web form. |
| Modal Labs | Browser → https://www.modal.com/careers | Works (~24 roles) | AshbyHQ embedded on page — scroll to see listings. |
| Cerebras | Browser → https://www.cerebras.ai/join-us → click ML dept | Works | Greenhouse-based, limited ML roles. |
| Mistral AI | Browser → https://jobs.lever.co/mistral | Works | Lever ATS — do NOT add query params; go direct and filter in-page. |
| LangChain | Browser → https://jobs.ashbyhq.com/langchain | Works | AshbyHQ; iframe on their own careers page — go direct. |
| Pinecone | Browser → https://www.pinecone.io/careers | Works | AshbyHQ-hosted, JS console extraction works. |
| Groq | Browser → https://www.groq.com/careers/ | Moved | All engineering roles now on an external ATS (Gem.com). |
| Hugging Face | Browser → https://huggingface.co/jobs | Login-gated | Requires auth to view. |
| Anthropic | Browser → https://job-boards.greenhouse.io/anthropic | Works partially | Greenhouse — some listings require a second fetch. |
| Qdrant | Browser | Blocked | careers.qdrant.tech resolves to a private network. |
Three ATS platforms dominate AI company career pages. Each wants a different scraping approach:
| ATS | URL Pattern | Best Method |
|---|---|---|
| Greenhouse | job-boards.greenhouse.io/{company} | browser_navigate → browser_console JS snippet |
| AshbyHQ | jobs.ashbyhq.com/{company} | browser_navigate → browser_snapshot |
| Lever | jobs.lever.co/{company} | browser_navigate → browser_snapshot (no query params) |
Known Greenhouse: Anthropic, Together AI, Cerebras. Known AshbyHQ: Cohere, Baseten, LangChain, Modal, Pinecone. Known Lever: Mistral AI.
Many AI companies host on Greenhouse at https://job-boards.greenhouse.io/{company}.
JavaScript snippet to extract ALL job links from a Greenhouse page (run via browser_console):
"use strict"; (() => {
const links = document.querySelectorAll('a[href*="/jobs/"]');
const seen = new Set();
return Array.from(links)
.filter(l => l.href && !seen.has(l.href) && seen.add(l.href))
.map(l => l.href + '\t' + l.textContent.trim())
.join('\n');
})()Returns a newline-separated list of URL\tJob Title pairs. Greenhouse pages load all jobs on one page — no pagination.
web_extract frequently fails with 409 BILLING_ERROR on job-heavy targets (Naukri, Indeed, Anthropic, Cohere, Mistral, Together AI, Groq, LangChain, Baseten).web_search is the working fallback — it returns titles, descriptions, and listing URLs from search-engine indexes. Use it to discover individual job-listing URLs.web_search first (sequentially) to discover URLs.web_extract fails (409/504), use search-result snippets as job data — they contain title, company, and location.browser_navigate directly to the ATS URL.site:indeed.com web_search queries sometimes return snippet data with enough info (title, company, location) to score.naukri.com/llm-engineer-jobs-16) extract successfully via web_extract; search pages don't.Load thresholds and keyword lists from profile.yaml:
tiers = cfg["scoring"]["tiers"]
required = cfg["scoring"]["required_keywords"]
adjacent = cfg["scoring"]["adjacent_roles"]
reject = cfg["scoring"]["reject_keywords"]
ai_cos = set(cfg["scoring"]["ai_companies"])score >= tiers.tier_1 (default 75) → tailor resume, apply immediately.tiers.tier_2 <= score < tiers.tier_1 → generic or light tailoring.tiers.tier_2 → skip unless specifically interested.def score_job(title, location="", source="", cfg=None):
text = f"{title} {location}".lower()
score = 0
matched = []
core_roles = [
"machine learning engineer", "ml engineer", "ai engineer",
"generative ai engineer", "rag engineer", "llm engineer",
]
for role in core_roles:
if role in text:
score += 40
matched.append(role)
for kw in cfg["scoring"]["required_keywords"]:
if kw.lower() in text:
score += 10
matched.append(kw)
for adj in cfg["scoring"]["adjacent_roles"]:
if adj.lower() in text:
score += 10
high_value = [
"llm", "rag", "generative ai", "genai", "agentic", "nlp",
"deep learning", "fine-tuning", "rlhf", "multimodal",
"foundation model", "transformer", "post-training",
"model serving", "vector db", "embeddings",
]
for hw in high_value:
if hw in text:
score += 5
company_key = source.lower().strip()
if company_key in {c.lower() for c in cfg["scoring"]["ai_companies"]}:
has_ai = any(kw.lower() in text for kw in cfg["scoring"]["required_keywords"])
has_adj = any(adj.lower() in text for adj in cfg["scoring"]["adjacent_roles"])
if not has_ai and has_adj:
score += 15
elif has_ai or has_adj:
score += 5
for rk in cfg["scoring"]["reject_keywords"]:
if rk.lower() in text:
score -= 15
score = max(0, min(100, score))
t = cfg["scoring"]["tiers"]
tier = "tier-1" if score >= t["tier_1"] else ("tier-2" if score >= t["tier_2"] else "tier-3")
return score, tier, matchedCohere, Baseten, Modal, and friends use generic titles like "Member of Technical Staff", "Forward Deployed Engineer", or "Agentic Platform". These score low without the AI-company context bonus because the title doesn't contain explicit ML keywords.
Roles like "Forward Deployed Engineer, Agentic Platform" (Cohere) score 0 by title alone. Include agentic, post-training, inference, forward deployed, prompt specialist, applied researcher, research engineer, ml researcher, ai researcher, reinforcement learning, rlhf, and agent in either adjacent_roles or required_keywords to catch them.
"Research Engineer" contains "engineer" but is NOT a reject. Reject logic should only trigger on exact matches like "java developer" or "react developer" — not any string containing "developer".
{
"url": "https://jobs.ashbyhq.com/cohere/...",
"source": "cohere",
"title": "Forward Deployed Engineer, Agentic Platform",
"company": "Cohere",
"location": "Toronto; New York",
"experience": "Not specified",
"score": 35,
"tier": "tier-2",
"matched_keywords": [],
"scraped_at": "2026-04-19T17:35:45+00:00",
"status": "pending"
}pending — in queue, not yet applied.applied — successfully applied via web form.applied_email — applied via email.failed — application attempt failed; include failure_reason and manual_apply_url.blocked — known blocker (visa, experience mismatch, etc.).Before appending, compute existing_urls = {j["url"] for j in queue} and skip URLs already present.
f"score={j['score']}" with nested dict access confuses older Python parsers. Use string concatenation or .format().score_job returns (score, tier, matched), unpack all three at every call site.web_search / web_extract in parallel reliably hits 409 BILLING_ERROR. Run sequentially.https://jobs.ashbyhq.com/langchain — don't try to scrape the embed.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.