- highUsing rcd as the job key produces unstable, duplicated IDs
- rcd is volatile board/record state that changes with form context. Parse the jobId query value from page=jobSpecific links as the durable key and keep rcd only as record_id metadata.
- highDescription comes back empty on some tenants
- Themes render the Description value either in the same row as its td.jobFieldStyle label or one row down inside a nested table. Look for a td.jobValueStyle in the label's row first, then fall back to the first td.jobValueStyle in the enclosing table.
- mediumOnly the first page of vacancies is captured
- The board renders a single HTML page with no safe continuation cursor. Detect a Next control (form[name='paging'] submit/button or an a[href*='start_row='] link) and flag the snapshot incomplete instead of assuming the first page is the whole board.
- mediumAn empty board is misread as a parse failure
- Distinguish a genuinely empty board (page text says 'no current vacancies' / 'no vacancies found' / '0 vacancies') from a missing job table, which signals a layout change or wrong URL that needs re-inspection.
- lowDetail requests return 404 or 410
- CVMail returns 404/410 once a posting is withdrawn. Treat those status codes as a removal signal for that job rather than a hard scrape failure, and drop the listing from the active set.
CVMail Jobs API.
Pull full vacancy feeds from UK law firms and graduate legal recruiters running hosted CVMail boards. Every listing and detail page renders as server-side HTML at a predictable main.cfm URL, so a plain GET-and-parse loop captures titles, practice areas, salaries and closing dates — no login or headless browser.
What's in every response.
Data fields, real-world applications, and the companies already running on CVMail.
Data fields
- Full Job Descriptions
- Practice Area & Department
- Contract Type & Salary
- Location & Closing Dates
- Job Reference Codes
- Native Apply URLs
Use cases
- 01Legal Sector Hiring Feeds
- 02Graduate & Trainee Vacancy Tracking
- 03Law Firm Careers Aggregation
- 04Practice-Area Talent Monitoring
Trusted by
- Bevan Brittan
- RPC
- Travers Smith
How to scrape CVMail.
Step-by-step guide to extracting jobs from CVMail-powered career pages—endpoints, authentication, and working code.
import requests
from urllib.parse import urlparse
# e.g. https://fsr.cvmailuk.com/rpc/main.cfm?page=jobBoard&fo=1
board_url = "https://fsr.cvmailuk.com/rpc/main.cfm?page=jobBoard&fo=1"
parts = urlparse(board_url)
host = parts.netloc.lower()
segments = [s for s in parts.path.split("/") if s]
assert host.endswith((".cvmailuk.com", ".cvmail.net")), "not a CVMail host"
assert len(segments) == 2 and segments[1].lower() == "main.cfm", "not a board URL"
tenant = segments[0].lower() # "rpc"
print(tenant)from bs4 import BeautifulSoup
from urllib.parse import urljoin, urlparse, parse_qs
HEADERS = {"Accept": "text/html"}
def job_id_from(url):
q = parse_qs(urlparse(url).query)
if q.get("page", [""])[0] != "jobSpecific":
return None
jid = q.get("jobId", [""])[0]
# jobId must be a clean alphanumeric/_/- token to be a valid key
return jid if jid and jid.replace("-", "").replace("_", "").isalnum() else None
def parse_listings(board_url):
html = requests.get(board_url, headers=HEADERS, timeout=15).text
soup = BeautifulSoup(html, "html.parser")
jobs, seen = [], set()
for table in soup.select("table"):
if not table.select("a.jobMoreDetailCaptionStyle[href]"):
continue
headers = [h.get_text(" ", strip=True)
for h in table.select("td.jbTableHeaderCaptionStyle")]
for row in table.select("tr"):
a = row.select_one("a.jobMoreDetailCaptionStyle[href]")
if not a:
continue
detail_url = urljoin(board_url, a["href"])
jid = job_id_from(detail_url)
if not jid or jid in seen:
continue
seen.add(jid)
values = [c.get_text(" ", strip=True) for c in row.select("td.jbTableTextStyle")]
fields = {h: v for h, v in zip(headers, values) if h and v}
jobs.append({
"external_id": jid,
"title": a.get_text(" ", strip=True),
"detail_url": detail_url,
"location": fields.get("Location"),
"closing_date": fields.get("Closing date") or fields.get("Close date"),
"department": fields.get("Department or practice area") or fields.get("Department"),
"salary": fields.get("Salary"),
"reference": fields.get("Reference"),
})
return jobsdef job_identity(detail_url):
q = parse_qs(urlparse(detail_url).query)
if q.get("page", [""])[0] != "jobSpecific":
return None
jid = q.get("jobId", [""])[0]
if not jid:
return None
return {"external_id": jid, "record_id": q.get("rcd", [None])[0]}
print(job_identity(
"https://fsr.cvmailuk.com/rpc/main.cfm?page=jobSpecific&jobId=78306&rcd=1176178"))
# {'external_id': '78306', 'record_id': '1176178'}def value_for(label_el):
# value cell is usually in the same row as its label...
row = label_el.find_parent("tr")
if row:
v = row.select_one("td.jobValueStyle")
if v:
return v
# ...but Description often sits one row down inside a nested table
table = label_el.find_parent("table")
return table.select_one("td.jobValueStyle") if table else None
def fetch_detail(detail_url):
resp = requests.get(detail_url, headers=HEADERS, timeout=15)
if resp.status_code in (404, 410):
return None # CVMail returns 404/410 once a posting is pulled
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
fields, description = {}, None
for label in soup.select("td.jobFieldStyle"):
key = label.get_text(" ", strip=True)
val = value_for(label)
if not key or val is None:
continue
if key.lower() == "description":
description = val.decode_contents().strip()
else:
text = val.get_text(" ", strip=True)
if text:
fields[key] = text
title = fields.get("Job Title")
if not title:
og = soup.select_one('meta[property="og:title"]')
title = og["content"].strip() if og and og.get("content") else None
apply_a = next((a for a in soup.select("a.jobSpecificActionPaneItemStyle[href]")
if a.get_text(strip=True).lower() == "apply"), None)
title_el = soup.select_one("title")
return {
"title": title,
"description": description,
"location": fields.get("Location"),
"salary": fields.get("Salary"),
"contract_type": fields.get("Contract type"),
"reference": fields.get("Reference"),
"closing_date": fields.get("Closing date") or fields.get("Close date"),
"apply_url": urljoin(detail_url, apply_a["href"]) if apply_a else detail_url,
"company": title_el.get_text(strip=True) if title_el else None,
}def snapshot_status(board_html):
soup = BeautifulSoup(board_html, "html.parser")
text = soup.get_text(" ", strip=True).lower()
if any(m in text for m in
("no current vacancies", "no vacancies found", "0 vacancies")):
return "empty" # complete: the board is genuinely empty
has_next = (
soup.select_one("form[name='paging'] input[type='submit'][value*='Next' i]")
or soup.select_one("form[name='paging'] button[value*='Next' i]")
or soup.select_one("a[href*='start_row=']")
)
# Complete only when a job table rendered AND no Next control is present.
return "incomplete" if has_next is not None else "complete"- 1Validate the host ends with cvmailuk.com or cvmail.net and the path is /{tenant}/main.cfm before trusting a board.
- 2Key every job on the native jobId; store rcd only as record_id metadata.
- 3Parse Description defensively: same-row value cell first, then the enclosing table's value cell.
- 4Treat a paging Next control or start_row link as 'more pages' and mark the snapshot incomplete.
- 5Separate an explicit 'no current vacancies' board from a missing table (a real layout change).
- 6Self-throttle to ~5 requests/second and cap concurrent detail fetches at ~3, matching the native scraper's pacing.
One endpoint. All CVMail jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=cvmail" \
-H "X-Api-Key: YOUR_KEY"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.
Access CVMail
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.