Files
wehub-resource-sync bb5c75ce05
Component Security Validation / Security Audit (push) Has been cancelled
Deploy to Cloudflare Pages / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:38:58 +08:00

770 lines
29 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Generate claude-jobs.json by scraping free job sources for Claude Code positions.
Sources (all free, no API keys required):
1. HN Firebase API - "Who is Hiring" thread comments
2. HN Algolia API - Search across multiple months
3. RemoteOK API - JSON job feed
4. WeWorkRemotely RSS - Programming jobs feed
5. Anthropic Careers - Greenhouse API (jobs mentioning Claude Code)
Output: docs/claude-jobs.json
"""
import json
import re
import time
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from html import unescape
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.request import Request, urlopen
from urllib.parse import quote_plus
from urllib.error import URLError
# ---------------------------------------------------------------------------
# Utilities
# ---------------------------------------------------------------------------
def fetch_json(url, timeout=15):
"""Fetch JSON from a URL."""
req = Request(url, headers={"User-Agent": "claude-code-templates/1.0"})
try:
with urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
except Exception as e:
print(f" [warn] fetch_json failed for {url[:80]}: {e}")
return None
def fetch_text(url, timeout=15):
"""Fetch raw text/XML from a URL."""
req = Request(url, headers={"User-Agent": "claude-code-templates/1.0"})
try:
with urlopen(req, timeout=timeout) as resp:
return resp.read().decode("utf-8")
except Exception as e:
print(f" [warn] fetch_text failed for {url[:80]}: {e}")
return None
def strip_html(html_text):
"""Remove HTML tags, decode entities, and fix mojibake."""
if not html_text:
return ""
text = re.sub(r"<[^>]+>", " ", html_text)
text = unescape(text)
# Fix common UTF-8 mojibake (double-decoded characters)
mojibake_map = {
"\u00e2\u0080\u0099": "\u2019", # right single quote '
"\u00e2\u0080\u009c": "\u201c", # left double quote "
"\u00e2\u0080\u009d": "\u201d", # right double quote "
"\u00e2\u0080\u0094": "\u2014", # em dash —
"\u00e2\u0080\u0093": "\u2013", # en dash
"\u00e2\u0080\u00a6": "\u2026", # ellipsis …
"\u00c2\u00a0": " ", # non-breaking space
}
for bad, good in mojibake_map.items():
text = text.replace(bad, good)
# Catch remaining mojibake patterns: ’ â€" â€" etc.
text = re.sub(r"\u00e2\u0080.", lambda m: "'", text)
text = re.sub(r"\s+", " ", text).strip()
return text
def is_claude_related(text):
"""Check if text mentions Claude Code or related tools."""
if not text:
return False
t = text.lower()
keywords = [
"claude code", "claude-code", "anthropic claude", "claude ai",
"claude coder", "using claude", "claude experience",
]
if any(kw in t for kw in keywords):
return True
# "claude" alone needs job context to avoid false positives
if "claude" in t:
job_words = ["hiring", "position", "engineer", "developer", "role",
"stack", "workflow", "tool", "cursor"]
return any(w in t for w in job_words)
return False
def truncate(text, length=200):
"""Truncate text at word boundary."""
if not text or len(text) <= length:
return text or ""
return text[:length].rsplit(" ", 1)[0] + "..."
def extract_salary(text):
"""Extract salary string from text.
Only matches compensation-like amounts (≥$10k or with k/K suffix).
Skips funding/revenue figures like '$37M raised' or '$300M+ revenue'.
"""
if not text:
return ""
# First, remove sentences about funding/revenue to avoid false positives
cleaned = re.sub(r"\$[\d,.]+\s*[MBmb](?:illion)?[^.]*(?:\.|$)", "", text)
cleaned = re.sub(r"(?:raised|revenue|funding|valuation|ARR)[^.]*\$[\d,.]+[^.]*(?:\.|$)", "", cleaned, flags=re.IGNORECASE)
patterns = [
r"\$[\d,]+[kK]\s*[-\u2013]\s*\$?[\d,]+[kK]", # $120k-$150k, $120k-150k
r"\$[\d,]+[kK](?:\+|\s*\/\s*(?:yr|year))?", # $150k+, $150k/yr
r"\$[\d,]{3,}\s*[-\u2013]\s*\$?[\d,]{3,}", # $120,000-$150,000
r"\$[\d,]+[kK]?\s*\/\s*(?:yr|year)", # $120,000/yr
r"[\d,]+[kK]\s*[-\u2013]\s*[\d,]+[kK]", # 120k-150k
r"\u20ac[\d,]+[kK]?\s*[-\u2013]\s*\u20ac?[\d,]+[kK]?", # EUR
]
for pat in patterns:
m = re.search(pat, cleaned)
if m:
val = m.group(0)
# Validate: must represent a realistic salary
nums = re.findall(r"[\d,]+", val)
has_k = "k" in val.lower()
if nums:
first_num = int(nums[0].replace(",", ""))
# With 'k' suffix: must be >= 30 (i.e. $30k+)
if has_k and first_num < 30:
continue
# Without 'k': must be >= 30,000 (raw dollar amounts)
# Values like $100-140 or $150-300 are ambiguous/truncated
if not has_k and first_num < 1000:
continue
return val
return ""
def extract_urls(text):
"""Extract URLs from text."""
return re.findall(r"https?://[^\s<>\"']+", text or "")
def company_icon(name):
"""Best-effort favicon URL for a company."""
known = {
"anthropic": "https://www.anthropic.com/favicon.ico",
"google": "https://www.google.com/favicon.ico",
"microsoft": "https://www.microsoft.com/favicon.ico",
"meta": "https://www.facebook.com/favicon.ico",
"stripe": "https://stripe.com/favicon.ico",
"github": "https://github.com/favicon.ico",
"vercel": "https://vercel.com/favicon.ico",
"supabase": "https://supabase.com/favicon.ico",
"openai": "https://openai.com/favicon.ico",
"shopify": "https://shopify.com/favicon.ico",
"notion": "https://notion.so/favicon.ico",
"figma": "https://figma.com/favicon.ico",
}
lower = name.lower().strip()
for key, icon in known.items():
if key in lower:
return icon
return ""
# ---------------------------------------------------------------------------
# HN Comment Parser
# ---------------------------------------------------------------------------
def parse_hn_comment(comment_data):
"""Parse a HN 'Who is Hiring' comment into a structured job dict.
HN hiring comments typically follow:
CompanyName | Location | Remote | Salary | URL
Description paragraph(s)...
"""
text = comment_data.get("text", "")
if not text:
return None
clean = strip_html(text)
if not is_claude_related(clean):
return None
comment_id = comment_data.get("id", "")
posted_ts = comment_data.get("time", 0)
posted_at = datetime.fromtimestamp(posted_ts, tz=timezone.utc).isoformat() if posted_ts else ""
# --- Parse first line (pipe-delimited header) ---
# Split on <p> to get paragraphs
paragraphs = re.split(r"<p>", text)
header_html = paragraphs[0] if paragraphs else ""
header = strip_html(header_html)
# Try pipe-delimited: "Company | Location | Remote | ..."
parts = [p.strip() for p in header.split("|")]
company = parts[0] if parts else "Unknown"
# Clean company name (remove trailing URLs, etc.)
company = re.sub(r"https?://\S+", "", company).strip()
company = re.sub(r"\s*[-\u2013(].*", "", company).strip() if len(company) > 60 else company
# Location
location = ""
remote = False
# Words that indicate a part is a role title, not a location
role_words = {"engineer", "developer", "lead", "manager", "architect",
"designer", "scientist", "analyst", "operations", "head",
"director", "founder", "cto", "ceo", "vp ", "senior",
"staff", "principal", "junior", "intern", "product",
"technical", "native"}
for part in parts[1:]:
lower = part.lower().strip()
# Skip parts that look like role titles
if any(w in lower for w in role_words):
continue
if any(w in lower for w in ["remote", "anywhere", "distributed"]):
remote = True
if "only" in lower or "(" in lower or len(lower) > 6:
location = part.strip()
elif re.search(r"[A-Z][a-z]+", part) and not re.match(r"^\$", part.strip()):
if not location and any(w in lower for w in [",", "city", "francisco", "york", "london",
"berlin", "seattle", "austin", "chicago",
"boston", "denver", "miami", "toronto",
"europe", "us ", "u.s.", "worldwide"]):
location = part.strip()
elif not location and len(part.strip()) < 40:
location = part.strip()
# Also check full text for remote indicators if not found in header
if not remote:
remote_in_body = any(w in clean.lower() for w in ["remote", "anywhere", "distributed", "work from home"])
if remote_in_body:
remote = True
if not location:
location = "Remote" if remote else "On-site"
elif remote and "remote" not in location.lower():
location = f"{location} (Remote)"
# Salary
salary = extract_salary(header)
if not salary:
salary = extract_salary(clean)
# Apply URL — first URL found in comment
urls = extract_urls(text)
apply_url = urls[0] if urls else f"https://news.ycombinator.com/item?id={comment_id}"
# Description — everything after header, truncated
body_parts = paragraphs[1:] if len(paragraphs) > 1 else []
description_html = " ".join(body_parts)
description = truncate(strip_html(description_html), 300)
if not description:
description = truncate(clean, 300)
# Tags — extract tech keywords
tags = extract_tech_tags(clean)
return {
"id": f"hn-{comment_id}",
"company": company[:80],
"position": extract_position(clean, company),
"location": location[:80],
"remote": remote or "remote" in location.lower(),
"salary": salary,
"description": description,
"applyUrl": apply_url,
"source": "HackerNews",
"sourceUrl": f"https://news.ycombinator.com/item?id={comment_id}",
"postedAt": posted_at,
"tags": tags,
"companyIcon": company_icon(company),
}
def extract_position(text, company):
"""Try to extract a job title from the text."""
# Common patterns in HN posts
patterns = [
r"(?:hiring|looking for|seeking)\s+(?:a\s+)?([A-Z][A-Za-z\s/\-&]+(?:Engineer|Developer|Architect|Designer|Manager|Lead|Scientist|Analyst|Programmer))",
r"((?:Senior|Staff|Principal|Lead|Junior|Mid[- ]?Level|Head of)\s+[A-Za-z\s/\-&]+(?:Engineer|Developer|Architect|Designer|Manager|Scientist))",
r"((?:Full[- ]?Stack|Front[- ]?end|Back[- ]?end|DevOps|ML|AI|Platform|Infrastructure|Software|Product)\s+(?:Engineer|Developer|Architect|Manager))",
]
for pat in patterns:
m = re.search(pat, text)
if m:
title = m.group(1).strip()
# Don't return the company name as position
if title.lower() != company.lower() and len(title) < 80:
return title
return "Software Engineer"
def extract_tech_tags(text):
"""Extract technology tags from text."""
tech_keywords = {
"react": "React", "next.js": "Next.js", "nextjs": "Next.js",
"typescript": "TypeScript", "javascript": "JavaScript",
"python": "Python", "rust": "Rust", "go ": "Go", "golang": "Go",
"node.js": "Node.js", "nodejs": "Node.js",
"postgresql": "PostgreSQL", "postgres": "PostgreSQL",
"supabase": "Supabase", "firebase": "Firebase",
"aws": "AWS", "gcp": "GCP", "azure": "Azure",
"docker": "Docker", "kubernetes": "Kubernetes",
"claude code": "Claude Code", "claude-code": "Claude Code",
"cursor": "Cursor", "copilot": "Copilot",
"react native": "React Native", "swift": "Swift",
"kotlin": "Kotlin", "java ": "Java",
"ruby": "Ruby", "rails": "Rails",
"django": "Django", "flask": "Flask",
"vue": "Vue.js", "angular": "Angular", "svelte": "Svelte",
"tailwind": "Tailwind", "graphql": "GraphQL",
"redis": "Redis", "mongodb": "MongoDB", "mysql": "MySQL",
}
found = []
t = text.lower()
for keyword, label in tech_keywords.items():
if keyword in t and label not in found:
found.append(label)
return found[:10] # Cap at 10 tags
# ---------------------------------------------------------------------------
# Source 1: HN Firebase API
# ---------------------------------------------------------------------------
def find_latest_hiring_threads():
"""Find the latest 'Who is Hiring' thread IDs via Algolia."""
print("[1/5] Finding latest HN 'Who is Hiring' threads...")
url = (
"https://hn.algolia.com/api/v1/search?"
"query=%22who%20is%20hiring%22&tags=story&hitsPerPage=6"
f"&numericFilters=created_at_i>{int(time.time()) - 86400 * 100}"
)
data = fetch_json(url)
if not data:
return []
threads = []
for hit in data.get("hits", []):
title = (hit.get("title") or "").lower()
if "who is hiring" in title and "freelancer" not in title and "wants to be hired" not in title:
threads.append({
"id": int(hit["objectID"]),
"title": hit.get("title", ""),
"date": hit.get("created_at", ""),
})
print(f" Found {len(threads)} hiring threads")
return threads[:3] # Last 3 months
def fetch_hn_thread_jobs(thread_id):
"""Fetch all top-level comments from a HN thread and filter for Claude jobs."""
print(f" Fetching thread {thread_id}...")
thread_data = fetch_json(f"https://hacker-news.firebaseio.com/v0/item/{thread_id}.json")
if not thread_data:
return []
kid_ids = thread_data.get("kids", [])
print(f" Thread has {len(kid_ids)} top-level comments, fetching...")
jobs = []
# Batch fetch comments with thread pool
def fetch_comment(cid):
return fetch_json(f"https://hacker-news.firebaseio.com/v0/item/{cid}.json", timeout=10)
with ThreadPoolExecutor(max_workers=20) as executor:
futures = {executor.submit(fetch_comment, cid): cid for cid in kid_ids}
for future in as_completed(futures):
comment = future.result()
if comment and not comment.get("deleted") and not comment.get("dead"):
job = parse_hn_comment(comment)
if job:
jobs.append(job)
print(f" Found {len(jobs)} Claude-related jobs in thread {thread_id}")
return jobs
def collect_hn_firebase():
"""Collect jobs from HN Firebase API."""
threads = find_latest_hiring_threads()
all_jobs = []
for thread in threads:
jobs = fetch_hn_thread_jobs(thread["id"])
# Tag jobs with the thread month
for job in jobs:
if not job.get("postedAt") and thread.get("date"):
job["postedAt"] = thread["date"]
all_jobs.extend(jobs)
time.sleep(0.5)
return all_jobs
# ---------------------------------------------------------------------------
# Source 2: HN Algolia API (supplementary search)
# ---------------------------------------------------------------------------
def collect_hn_algolia(existing_ids):
"""Search HN Algolia for Claude Code mentions in hiring threads."""
print("[2/5] Searching HN Algolia for additional Claude Code mentions...")
jobs = []
search_terms = ["claude code", "claude-code"]
for term in search_terms:
url = (
f"https://hn.algolia.com/api/v1/search?"
f"query={quote_plus(term)}&tags=comment&hitsPerPage=50"
f"&numericFilters=created_at_i>{int(time.time()) - 86400 * 100}"
)
data = fetch_json(url)
if not data:
continue
for hit in data.get("hits", []):
hn_id = f"hn-{hit.get('objectID', '')}"
if hn_id in existing_ids:
continue
# Only include comments from hiring threads
story_title = (hit.get("story_title") or "").lower()
if "who is hiring" not in story_title:
continue
comment_text = hit.get("comment_text", "")
if not is_claude_related(strip_html(comment_text)):
continue
# Build a minimal comment structure for the parser
fake_comment = {
"id": hit.get("objectID", ""),
"text": comment_text,
"time": hit.get("created_at_i", 0),
}
job = parse_hn_comment(fake_comment)
if job:
jobs.append(job)
existing_ids.add(hn_id)
time.sleep(0.5)
print(f" Found {len(jobs)} additional jobs via Algolia")
return jobs
# ---------------------------------------------------------------------------
# Source 3: RemoteOK API
# ---------------------------------------------------------------------------
def collect_remoteok():
"""Collect jobs from RemoteOK API."""
print("[3/5] Searching RemoteOK API...")
data = fetch_json("https://remoteok.com/api")
if not data:
return []
jobs = []
for item in data[1:]: # First element is metadata
desc = item.get("description", "")
tags_list = item.get("tags", [])
tags_str = " ".join(tags_list) if isinstance(tags_list, list) else str(tags_list)
full_text = f"{item.get('position', '')} {desc} {tags_str} {item.get('company', '')}"
if not is_claude_related(full_text):
continue
slug = item.get("slug", item.get("id", ""))
jobs.append({
"id": f"rok-{slug}",
"company": item.get("company", "Unknown"),
"position": item.get("position", "Software Engineer"),
"location": "Remote",
"remote": True,
"salary": extract_salary(f"{item.get('salary_min', '')} {item.get('salary_max', '')}"),
"description": truncate(strip_html(desc), 300),
"applyUrl": item.get("apply_url", "") or f"https://remoteok.com/remote-jobs/{slug}",
"source": "RemoteOK",
"sourceUrl": f"https://remoteok.com/remote-jobs/{slug}",
"postedAt": item.get("date", ""),
"tags": extract_tech_tags(full_text),
"companyIcon": item.get("company_logo", "") or company_icon(item.get("company", "")),
})
print(f" Found {len(jobs)} jobs from RemoteOK")
return jobs
# ---------------------------------------------------------------------------
# Source 4: WeWorkRemotely RSS
# ---------------------------------------------------------------------------
def collect_weworkremotely():
"""Collect jobs from WeWorkRemotely RSS feed."""
print("[4/5] Searching WeWorkRemotely RSS...")
xml_text = fetch_text("https://weworkremotely.com/categories/remote-programming-jobs.rss")
if not xml_text:
return []
jobs = []
try:
root = ET.fromstring(xml_text)
for item in root.findall(".//item"):
title = item.findtext("title", "")
desc = item.findtext("description", "")
link = item.findtext("link", "")
pub_date = item.findtext("pubDate", "")
full_text = f"{title} {strip_html(desc)}"
if not is_claude_related(full_text):
continue
# WWR title format: "Company: Job Title"
company_match = re.match(r"^([^:]+):\s*(.+)", title)
company = company_match.group(1).strip() if company_match else "Remote Company"
position = company_match.group(2).strip() if company_match else title
# Convert RFC-2822 date to ISO 8601 for consistent sorting
iso_date = pub_date
if pub_date:
try:
from email.utils import parsedate_to_datetime
dt = parsedate_to_datetime(pub_date)
iso_date = dt.isoformat()
except Exception:
pass # Keep original if parsing fails
jobs.append({
"id": f"wwr-{link.rstrip('/').split('/')[-1] if link else 'unknown'}",
"company": company[:80],
"position": position[:120],
"location": "Remote",
"remote": True,
"salary": extract_salary(full_text),
"description": truncate(strip_html(desc), 300),
"applyUrl": link,
"source": "WeWorkRemotely",
"sourceUrl": link,
"postedAt": iso_date,
"tags": extract_tech_tags(full_text),
"companyIcon": company_icon(company),
})
except ET.ParseError as e:
print(f" [warn] XML parse error: {e}")
print(f" Found {len(jobs)} jobs from WeWorkRemotely")
return jobs
# ---------------------------------------------------------------------------
# Source 5: Anthropic Careers (Greenhouse API)
# ---------------------------------------------------------------------------
def collect_anthropic_careers():
"""Collect jobs from Anthropic's careers page via Greenhouse public API.
Strategy:
1. Fetch all jobs (no content) — lightweight list of ~500+ positions
2. Pre-filter by title keywords to find candidates (~50 max)
3. Fetch full content for candidates individually
4. Final filter for "Claude Code" mentions in description
"""
print("[5/5] Searching Anthropic Careers (Greenhouse API)...")
data = fetch_json("https://boards-api.greenhouse.io/v1/boards/anthropic/jobs")
if not data or "jobs" not in data:
print(" [warn] Could not fetch Anthropic job listings")
return []
all_listings = data["jobs"]
print(f" Total Anthropic listings: {len(all_listings)}")
# Pre-filter: titles likely to mention Claude Code
# Include broad engineering/product titles + anything with "claude" in title
title_keywords = [
"claude", "engineer", "developer", "architect", "platform",
"infrastructure", "product", "design", "research", "ml ",
"machine learning", "ai ", "applied", "full stack", "fullstack",
"front end", "frontend", "back end", "backend", "devops", "sre",
"security", "data", "sdk", "api", "tools", "dx", "evangelist",
"communications", "technical", "software",
]
candidates = []
for job in all_listings:
title_lower = (job.get("title") or "").lower()
if any(kw in title_lower for kw in title_keywords):
candidates.append(job)
# No hard cap — title pre-filter already narrows the list sufficiently
print(f" Pre-filtered to {len(candidates)} candidate roles, fetching content...")
jobs = []
def fetch_job_content(job_meta):
"""Fetch full job details and check for Claude Code mentions."""
job_id = job_meta.get("id")
detail = fetch_json(
f"https://boards-api.greenhouse.io/v1/boards/anthropic/jobs/{job_id}",
timeout=15,
)
if not detail:
return None
content_html = unescape(detail.get("content", "")) # Greenhouse double-escapes HTML
title = detail.get("title", "")
full_text = f"{title} {strip_html(content_html)}"
# Must specifically mention "Claude Code" (not just "Claude" — all Anthropic jobs mention Claude)
claude_code_keywords = [
"claude code", "claude-code", "claude coder",
]
if not any(kw in full_text.lower() for kw in claude_code_keywords):
return None
# Parse location
location_name = ""
loc = detail.get("location", {})
if isinstance(loc, dict):
location_name = loc.get("name", "")
# Check remote from metadata
remote = False
for meta in detail.get("metadata", []):
if meta.get("name", "").lower() in ("location type", "location_type"):
val = (str(meta.get("value", "")) or "").lower()
if "remote" in val:
remote = True
if not location_name:
location_name = "Remote" if remote else "San Francisco, CA"
elif remote and "remote" not in location_name.lower():
location_name = f"{location_name} (Remote)"
# Salary from content
salary = extract_salary(full_text)
# Department
departments = detail.get("departments", [])
dept_names = [d.get("name", "") for d in departments if d.get("name")]
# Tags
tags = extract_tech_tags(full_text)
if "Claude Code" not in tags:
tags.insert(0, "Claude Code")
if "Anthropic" not in tags:
tags.append("Anthropic")
# Description — clean, skip boilerplate "About Anthropic" intro, and truncate
clean_desc = strip_html(content_html)
# Skip the generic "About Anthropic" intro paragraph — find the role-specific section
for marker in ["The role", "The Role", "About the role", "About the Role",
"What you'll do", "What You'll Do", "The position", "The Position",
"We're looking", "We are looking", "This role", "In this role",
"As a ", "As an ", "Join ", "You will ", "You'll "]:
idx = clean_desc.find(marker)
if idx > 0 and idx < 800:
clean_desc = clean_desc[idx:]
break
# Strip "About the role" prefix itself if present
clean_desc = re.sub(r"^About the [Rr]ole:?\s*", "", clean_desc)
description = truncate(clean_desc, 300)
return {
"id": f"anth-{job_id}",
"company": "Anthropic",
"position": title[:120],
"location": location_name[:80],
"remote": remote or "remote" in location_name.lower(),
"salary": salary,
"description": description,
"applyUrl": detail.get("absolute_url", f"https://www.anthropic.com/careers/jobs"),
"source": "Anthropic",
"sourceUrl": detail.get("absolute_url", f"https://www.anthropic.com/careers/jobs"),
"postedAt": detail.get("first_published", detail.get("updated_at", "")),
"tags": tags,
"companyIcon": "https://www.anthropic.com/favicon.ico",
}
# Batch fetch with thread pool
with ThreadPoolExecutor(max_workers=10) as executor:
futures = {executor.submit(fetch_job_content, c): c for c in candidates}
for future in as_completed(futures):
result = future.result()
if result:
jobs.append(result)
print(f" Found {len(jobs)} Claude Code jobs at Anthropic")
return jobs
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def generate():
print("=" * 60)
print("Claude Code Jobs Scraper (free sources only)")
print("=" * 60)
all_jobs = []
# 1. HN Firebase (primary)
hn_jobs = collect_hn_firebase()
all_jobs.extend(hn_jobs)
# 2. HN Algolia (supplementary)
existing_ids = {j["id"] for j in all_jobs}
algolia_jobs = collect_hn_algolia(existing_ids)
all_jobs.extend(algolia_jobs)
# 3. RemoteOK
rok_jobs = collect_remoteok()
all_jobs.extend(rok_jobs)
# 4. WeWorkRemotely
wwr_jobs = collect_weworkremotely()
all_jobs.extend(wwr_jobs)
# 5. Anthropic Careers
anth_jobs = collect_anthropic_careers()
all_jobs.extend(anth_jobs)
# Deduplicate by ID
seen = set()
unique = []
for job in all_jobs:
if job["id"] not in seen:
seen.add(job["id"])
unique.append(job)
# Sort by postedAt descending
unique.sort(key=lambda j: j.get("postedAt", ""), reverse=True)
# Build output
sources = sorted(set(j["source"] for j in unique))
output = {
"lastUpdated": datetime.now(timezone.utc).isoformat(),
"totalJobs": len(unique),
"sources": sources,
"jobs": unique,
}
# Write to docs/
output_path = "docs/claude-jobs.json"
with open(output_path, "w", encoding="utf-8") as f:
json.dump(output, f, indent=2, ensure_ascii=False)
# Also copy to dashboard/public/ if it exists
import os
dashboard_path = "dashboard/public/claude-jobs.json"
if os.path.isdir("dashboard/public"):
with open(dashboard_path, "w", encoding="utf-8") as f:
json.dump(output, f, indent=2, ensure_ascii=False)
print(f"\nCopied to {dashboard_path}")
# Summary
print("\n" + "=" * 60)
print(f"Total unique jobs: {len(unique)}")
for src in sources:
count = sum(1 for j in unique if j["source"] == src)
print(f" {src}: {count}")
remote_count = sum(1 for j in unique if j.get("remote"))
print(f" Remote: {remote_count} / On-site: {len(unique) - remote_count}")
print(f"\nSaved to {output_path}")
print("=" * 60)
if __name__ == "__main__":
generate()