Several remote job boards publish free JSON feeds you can call without a key. Himalayas has a paginated jobs API, Remote OK returns about 100 newest listings as JSON, and We Work Remotely still exposes category RSS. The same role often appears on more than one board, and only Himalayas consistently publishes a seniority level, so a short script that normalizes, dedupes and guesses seniority from the title is more useful than three separate curls.
This walkthrough sticks to those official public endpoints. No HTML scraping, no login.
1. Himalayas: newest jobs as JSON
GET https://himalayas.app/jobs/api?limit=20 The response is an object with jobs, totalCount, and (preferred for paging) nextCursor. Cursor paging is faster than the older offset parameter, which Himalayas has marked as deprecated. There is also a search endpoint if you want the board to filter by keyword:
GET https://himalayas.app/jobs/api/search?q=python&page=1 Search returns 20 jobs per page and uses ?page=2, ?page=3, not cursor. Each job includes title, companyName, guid (the public URL), locationRestrictions, timezoneRestrictions, seniority, salary fields and a Unix pubDate.
2. Remote OK: one feed, skip the banner
GET https://remoteok.com/api The first element is not a job. It is a legal banner with attribution rules. Skip any object that has no id / position. Real jobs use position for the title, company, url, location, tags, and optional salary_min / salary_max. Titles sometimes contain HTML entities (&) and occasional mojibake (München instead of München), so decode entities and try a latin1-to-utf8 repair when needed.
Remote OK asks you to link back to their job URL with follow links and to name them as the source if you republish.
3. Optional: We Work Remotely RSS
The full feed at /remote-jobs.rss is multi-megabyte. Prefer a category feed, for example programming:
GET https://weworkremotely.com/categories/remote-programming-jobs.rss Titles look like Company: Role. Headquarters is buried in the HTML description. Treat this board as optional coverage on top of the two JSON APIs.
4. Guess seniority when the board does not send it
Himalayas usually sends seniority as a list (Senior, entry-level, …). Remote OK and WWR do not. A small title heuristic covers the common cases:
import re SENIORITY_PATTERNS = [ ("executive", re.compile(r"\b(c[etofmrip]o|vp|vice[\s-]president|co-?founder)\b", re.I)), ("director", re.compile(r"\b(director|head of)\b", re.I)), ("entry", re.compile(r"\b(intern|junior|jr\.?|entry[\s-]level|graduate|trainee)\b", re.I)), ("manager", re.compile(r"\b(engineering manager|software manager|product manager)\b", re.I)), ("senior", re.compile(r"\b(senior|sr\.?|lead|principal|staff|architect)\b", re.I)), ("mid", re.compile(r"\b(mid[\s-]?level|intermediate)\b", re.I)), ] BOARD_MAP = { "entry-level": "entry", "entry": "entry", "junior": "entry", "mid-level": "mid", "mid": "mid", "senior": "senior", "senior-level": "senior", "lead": "senior", "manager": "manager", "director": "director", "executive": "executive", } def seniority_from_title(title: str): for level, pat in SENIORITY_PATTERNS: if pat.search(title or ""): return level return None Order matters: check executive / director / entry before the broad senior|lead|staff pattern, otherwise "Junior" never wins and "Vice President" becomes senior. Title heuristics are imperfect (Team Lead Account Executive becomes senior because of "Lead"), which is why keeping seniority_source (board vs title) is useful.
5. The full script
Needs only requests (stdlib for RSS). It pulls 20 Himalayas jobs, the Remote OK feed, and the WWR programming RSS, merges on a normalized company|title key, then keeps roles open to Germany (or worldwide) at senior-or-above.
#!/usr/bin/env python3 import html import re import xml.etree.ElementTree as ET from datetime import datetime, timezone import requests UA = {"User-Agent": "remote-jobs-tutorial/1.0 (+https://emirmrkaljevic.github.io/tinyrex-data-tools/)"} SESSION = requests.Session() SESSION.headers.update(UA) SENIORITY_PATTERNS = [ ("executive", re.compile(r"\b(c[etofmrip]o|vp|vice[\s-]president|co-?founder)\b", re.I)), ("director", re.compile(r"\b(director|head of)\b", re.I)), ("entry", re.compile(r"\b(intern|junior|jr\.?|entry[\s-]level|graduate|trainee)\b", re.I)), ("manager", re.compile(r"\b(engineering manager|software manager|product manager)\b", re.I)), ("senior", re.compile(r"\b(senior|sr\.?|lead|principal|staff|architect)\b", re.I)), ("mid", re.compile(r"\b(mid[\s-]?level|intermediate)\b", re.I)), ] BOARD_MAP = { "entry-level": "entry", "entry": "entry", "junior": "entry", "mid-level": "mid", "mid": "mid", "senior": "senior", "senior-level": "senior", "lead": "senior", "manager": "manager", "director": "director", "executive": "executive", } def seniority_from_title(title: str): for level, pat in SENIORITY_PATTERNS: if pat.search(title or ""): return level return None def board_seniority(raw): if not isinstance(raw, list) or not raw: return None return BOARD_MAP.get(str(raw[0]).lower().strip()) def fix_mojibake(s: str) -> str: try: return s.encode("latin1").decode("utf-8") except (UnicodeEncodeError, UnicodeDecodeError): return s def norm_key(title, company): t = re.sub(r"\W+", " ", (title or "").lower()).strip() c = re.sub(r"\W+", " ", (company or "").lower()).strip() return f"{c}|{t}" def fetch_himalayas(limit=20): r = SESSION.get("https://himalayas.app/jobs/api", params={"limit": limit}, timeout=30) r.raise_for_status() out = [] for j in r.json().get("jobs", []): title = j.get("title") or "" board = board_seniority(j.get("seniority")) title_sen = seniority_from_title(title) out.append({ "source": "himalayas", "title": title, "company": j.get("companyName"), "url": j.get("guid") or j.get("applicationLink"), "location_restrictions": j.get("locationRestrictions") or [], "seniority": board or title_sen, "seniority_source": "board" if board else ("title" if title_sen else None), "posted_at": datetime.fromtimestamp(j["pubDate"], tz=timezone.utc).isoformat() if j.get("pubDate") else None, }) return out def fetch_remoteok(): r = SESSION.get("https://remoteok.com/api", timeout=30) r.raise_for_status() out = [] for j in r.json(): if not j.get("id") or not j.get("position"): continue # skip legal banner title = fix_mojibake(html.unescape(j.get("position") or "")) company = fix_mojibake(html.unescape(j.get("company") or "")) loc = fix_mojibake(j.get("location") or "") restrictions = [] if not loc or loc.lower() in ("worldwide", "anywhere", "remote") else [loc] sen = seniority_from_title(title) out.append({ "source": "remoteok", "title": title, "company": company, "url": j.get("url"), "location_restrictions": restrictions, "seniority": sen, "seniority_source": "title" if sen else None, "posted_at": j.get("date"), }) return out def fetch_wwr_programming(max_items=25): r = SESSION.get( "https://weworkremotely.com/categories/remote-programming-jobs.rss", timeout=30 ) r.raise_for_status() root = ET.fromstring(r.content) out = [] for item in root.findall("./channel/item")[:max_items]: raw = item.findtext("title") or "" company, title = (raw.split(": ", 1) + [""])[:2] if ": " in raw else ("", raw) desc = item.findtext("description") or "" m = re.search(r"Headquarters:\s*</strong>\s*([^<]+)", desc, re.I) hq = (m.group(1).strip() if m else "") or "Remote" restrictions = [] if re.search(r"remote|anywhere|worldwide", hq, re.I) else [hq] sen = seniority_from_title(title) out.append({ "source": "weworkremotely", "title": title.strip(), "company": company.strip(), "url": item.findtext("link"), "location_restrictions": restrictions, "seniority": sen, "seniority_source": "title" if sen else None, "posted_at": item.findtext("pubDate"), }) return out def open_to_country(job, country: str, include_worldwide=True) -> bool: restr = job.get("location_restrictions") or [] if not restr: return include_worldwide needle = country.lower() return any(needle in (x or "").lower() for x in restr) def merge_and_dedupe(*lists): seen, merged = set(), [] for jobs in lists: for j in jobs: key = norm_key(j["title"], j["company"]) if not key or key in seen: continue seen.add(key) merged.append(j) return merged def main(): him = fetch_himalayas(limit=20) rok = fetch_remoteok() wwr = fetch_wwr_programming(max_items=25) all_jobs = merge_and_dedupe(him, rok, wwr) filtered = [ j for j in all_jobs if open_to_country(j, "Germany", include_worldwide=True) and j.get("seniority") in ("senior", "manager", "director", "executive") ] print(f"fetched himalayas={len(him)} remoteok={len(rok)} wwr={len(wwr)}") print(f"merged={len(all_jobs)} after_country+seniority={len(filtered)}") for j in filtered[:5]: print( f"{j['source']:14} | {j['seniority']:9} ({j['seniority_source']}) | " f"{j['company']} | {j['title'][:60]}" ) if __name__ == "__main__": main() A real run on 2026-11-05 looked like this:
fetched himalayas=20 remoteok=99 wwr=25 merged=143 after_country+seniority=29 himalayas | senior (board) | Evernest | Senior Immobilienberater (m/w/d) auf selbstständiger Basis remoteok | senior (title) | Ringbook Limited | Tech lead remoteok | senior (title) | StaffRight | Senior Property Manager ENG remoteok | senior (title) | Capmo | Team Lead Account Executive remoteok | senior (title) | Cloudary SAS | AWS Cloud Solutions Architect Empty location_restrictions means worldwide / no published country limit, so those Remote OK rows pass the Germany filter when include_worldwide=True. The Evernest row is the opposite case: Himalayas listed Germany explicitly and tagged seniority on the board.
6. Country filter without false positives
locationRestrictions on Himalayas is a list of country names the candidate may live in. Remote OK's location is freer text (city, US state, or blank). Mapping blank / "Worldwide" / "Remote" to "no restriction" keeps the filter honest. If you only want roles that name your country, set include_worldwide=False.
Limits worth knowing
- Remote OK returns roughly the newest 100 jobs per call, not full history. H...
