- highwindow.allOffers block is missing or a regex grabs the wrong closing bracket
- Locate window.allOffers = and walk from the opening [ to its matching ] while tracking string/escape state. Other inline <script> blocks on the page contain unrelated brackets, so a naive regex returns truncated or invalid JSON.
- highTwo different UUIDs share the same slug on one board
- Key jobs on the Slug and treat the Id (UUID) as a candidate only. When a second UUID appears under a known slug, keep the first and flag the collision instead of overwriting — merging drops a live posting.
- mediumDetail-page JSON-LD JobPosting is temporarily absent
- The offer can render before its JSON-LD is published. Fall back to the visible HTML — read article.single-offre h1 and .description.rich-text — after confirming the canonical link and form UUID match the listing.
- mediumAddress_State is mistaken for a US state
- The upstream Address_State field holds an ISO country code (e.g. "FR"), not a region. Map it to country and use Address_Region for the sub-national area.
- lowAccented characters arrive as HTML entities
- JSON-LD strings embed entities like Île-de-France or Evol'Emploi. Run every JSON-LD string through html.unescape before storing or the text ends up garbled.
WeRecruit Jobs API.
Pull structured job offers from French SMB and staffing career boards on careers.werecruit.io, decoded straight from the JSON embedded in each board page.
What's in every response.
Data fields, real-world applications, and the companies already running on WeRecruit.
Data fields
- Translated Job Titles
- Contract & Employment Type
- City, Region & Postal Code
- Department & Schedule
- Publication & Closing Dates
- Full Job Descriptions
Use cases
- 01French Job Market Tracking
- 02Staffing & Interim Sourcing
- 03SMB Hiring Signals
- 04Multilingual Job Aggregation
DIY GUIDE
How to scrape WeRecruit.
Step-by-step guide to extracting jobs from WeRecruit-powered career pages—endpoints, authentication, and working code.
Step 1: Fetch the tenant board page
import requests
locale, tenant = "fr", "walter-learning"
board_url = f"https://careers.werecruit.io/{locale}/{tenant}"
resp = requests.get(board_url, timeout=15)
if resp.status_code == 404:
raise LookupError(f"tenant '{tenant}' not found")
resp.raise_for_status()
html = resp.textStep 2: Carve out the window.allOffers JSON array
import re
_START = re.compile(r"window\.allOffers\s*=\s*")
def extract_all_offers_json(html: str) -> str | None:
m = _START.search(html)
if not m or m.end() >= len(html) or html[m.end()] != "[":
return None
start = m.end()
depth = 0
in_string = escape = False
for i in range(start, len(html)):
c = html[i]
if escape:
escape = False
elif in_string:
if c == "\\":
escape = True
elif c == '"':
in_string = False
elif c == '"':
in_string = True
elif c == "[":
depth += 1
elif c == "]":
depth -= 1
if depth == 0:
return html[start:i + 1]
return NoneStep 3: Map each offer to structured fields
import json
offers = json.loads(extract_all_offers_json(html))
jobs = []
for o in offers:
# Require all three: a malformed offer must not silently drop half the alias pair.
if not (o.get("Url") and o.get("Slug") and o.get("Id")):
continue
title = o.get("TitleTranslated") or next(iter((o.get("Title") or {}).values()), None)
company = o.get("Subsidiary_Name") or o.get("Company_Name") or o.get("Holding_Name")
jobs.append({
"external_id": o["Slug"].strip(), # stable key
"native_id": o["Id"], # UUID (candidate only)
"url": o["Url"].strip(),
"title": title,
"company": company,
"employment_type": o.get("TypeTranslated"),
"schedule": o.get("TimeTranslated"),
"department": o.get("Address_Department"),
"city": o.get("Address_City"),
"region": o.get("Address_Region"),
"postal_code": o.get("Address_PostalCode"),
"country": o.get("Address_State"), # ISO country code
"published_at": o.get("PublicationStartDate"),
"closes_at": o.get("EndDate"),
})
print(f"parsed {len(jobs)} offers")Step 4: Pull full descriptions from the detail page
import html as ihtml
from bs4 import BeautifulSoup
def fetch_details(job_url: str) -> dict:
resp = requests.get(job_url, timeout=15)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
for tag in soup.find_all("script", type="application/ld+json"):
try:
data = json.loads(tag.string or "")
except (ValueError, TypeError):
continue
for node in (data if isinstance(data, list) else [data]):
if isinstance(node, dict) and node.get("@type") == "JobPosting":
desc = BeautifulSoup(node.get("description", ""), "html.parser").get_text(" ", strip=True)
return {
"title": ihtml.unescape(node.get("title", "")),
"description": desc,
"posted_at": node.get("datePosted"),
"employment_type": node.get("employmentType"),
}
# Fallback: some pages briefly render the offer before the JSON-LD is present.
article = soup.select_one("article.single-offre")
if article:
h1 = article.select_one("h1")
body = article.select_one(".description.rich-text")
return {
"title": h1.get_text(strip=True) if h1 else None,
"description": body.get_text(" ", strip=True) if body else None,
}
return {}Step 5: Guard slug collisions and pace requests
import time
seen = {} # slug -> native UUID
for job in jobs:
prior = seen.get(job["external_id"])
if prior is not None:
if prior != job["native_id"]:
print(f"identity collision on slug {job['external_id']} — review before merging")
continue
seen[job["external_id"]] = job["native_id"]
for job in [j for j in jobs if seen.get(j["external_id"]) == j["native_id"]]:
job.update(fetch_details(job["url"]))
time.sleep(0.4) # mirror the scraper's ~400ms crawl delay Common issues
Best practices
- 1Key jobs on the mutable Slug; keep the UUID only as a candidate id until a migration is approved.
- 2Balance brackets with string-state tracking when carving out window.allOffers — never stop at the first ].
- 3Run html.unescape on every JSON-LD string to fix accented French text.
- 4Treat Address_State as an ISO country code, not a US state.
- 5Prefer the listing's TypeTranslated for contract type; JSON-LD employmentType only carries hours.
- 6Pace detail fetches (~400ms, ~3 concurrent) to stay polite on shared tenant boards.
Or skip the complexity
One endpoint. All WeRecruit jobs. No scraping, no sessions, no maintenance.
Get API accesscURL
curl "https://connect.jobo.world/api/jobs?sources=werecruit" \
-H "X-Api-Key: YOUR_KEY"Developer tools
Try the API.
Test Jobs and Feed endpoints against https://connect.jobo.world with live request/response examples, then copy ready-to-use curl commands.
Ready to integrate Access WeRecruit
Access WeRecruit
job data today.
One API call. Structured data. No scraping infrastructure to build or maintain — start with the $5 free starting balance and scale as you grow.
99.9%API uptime
<200msAvg response
50M+Jobs processed