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. Himalayas search is capped and paged; the plain feed supports cursor paging across a much larger
totalCount. - Be polite: one request per board per run is enough for a personal digest. Cache results if you poll on a schedule.
- Attribution: link back to the original job URL and name the board. Himalayas and Remote OK do not allow resubmitting their jobs to aggregators such as Google Jobs or LinkedIn.
- Title-based seniority will mis-tag some roles. Prefer the board field when present, and keep unknown seniority instead of dropping it unless you want a strict filter.
- Remote OK strings may need
html.unescapeplus a latin1/utf-8 mojibake fix before you store them.
If you'd rather not run it yourself
Disclosure: I built and maintain a hosted version on Apify, Remote Jobs Scraper. It reads the same official feeds (and a few more boards), normalizes the schema, dedupes across sources and can filter by country and seniority. For a one-off personal feed, the script above is all you need. Longer notes and input examples live in the guide.
Top comments (0)