- criticalWhy does the tag in the URL change after the page loads?
- A legacy ReApplicantEmail entry link redirects into ReDefault.aspx with a completely different tag. Keep the entry tag as the firm's durable identity and use the landed tag only to build request URLs — swapping them makes the company identity change on every crawl.
- highWhy did a firm's job count suddenly drop?
- A partially rendered WebForms page looks identical to a shrinking board. Compare your parsed row count against the board's own positions counter and fail the run on a mismatch, rather than publishing a truncated snapshot that expires live jobs.
- highCan I accept any host serving /viRecruitSelfApply/?
- No. Accept the vendor namespace *.viglobalcloud.com automatically, and pin firm-owned hosts explicitly — some run a nested path prefix that cannot be derived from the hostname. An unproved host that merely matches the route shape must fail closed.
- mediumWhy do some boards list jobs with no clickable link?
- A few deployments render rows as postbacks only, with no addressable job id. Those boards cannot be scraped honestly — classify them as unsupported rather than reporting an empty board, which would expire every job the firm still has open.
- mediumWhen is a ViRecruit job actually removed?
- Removal evidence is narrow: only a 404 on the canonical legacy detail URL counts. A proved-empty legacy board is a valid authoritative empty snapshot, but a board that simply failed to load is neither empty nor removed and must be retried.
ViRecruit / viDesktop Jobs API.
Extract vacancies from ViRecruit self-apply portals — the ASP.NET boards used by most large law firms — and reconcile every snapshot against the board's own positions counter.
What's in every response.
Data fields, real-world applications, and the companies already running on ViRecruit / viDesktop.
Data fields
- Full Job Descriptions
- Board Positions Counter
- Modern & Legacy Layouts
- GUID-Scoped Boards
- Vendor & Custom Hosts
- Inline Listing Descriptions
Use cases
- 01Legal Industry Job Boards
- 02Professional Services Hiring Feeds
- 03Law Firm Careers Monitoring
- 04Legacy ATS Migration Audits
Trusted by
- BakerHostetler
- Cozen O'Connor
- Littler
How to scrape ViRecruit / viDesktop.
Step-by-step guide to extracting jobs from ViRecruit / viDesktop-powered career pages—endpoints, authentication, and working code.
from urllib.parse import urlparse, parse_qs
VENDOR_SUFFIX = ".viglobalcloud.com"
PAGES = {"recdefault.aspx", "redefault.aspx", "reapplicantemail.aspx", "rejobview.aspx"}
def parse_virecruit(url: str) -> dict:
parsed = urlparse(url)
path = parsed.path
lowered = path.lower()
if "/virecruitselfapply/" not in lowered:
raise ValueError("not a ViRecruit self-apply route")
page = lowered.rsplit("/", 1)[-1]
if page not in PAGES:
raise ValueError(f"unrecognised ViRecruit page {page}")
# Some firms run a nested prefix, e.g. /viDesktopEx/viRecruitSelfApply/.
prefix = path[: lowered.index("/virecruitselfapply/")].strip("/")
query = {k.lower(): v[0] for k, v in parse_qs(parsed.query).items()}
return {
"host": parsed.netloc.lower(),
"vendor_hosted": parsed.netloc.lower().endswith(VENDOR_SUFFIX),
"prefix": prefix,
"page": page,
"tag": query.get("tag"), # keep this even after a redirect
"filter_job_id": query.get("filterjobid"),
"job_id": query.get("jobid"),
}
print(parse_virecruit("https://videsktop.littler.com/viRecruitSelfApply/RecDefault.aspx"
"?Tag=e742d0d0-59b7-46f6-a5cd-68c8e5b09b72&FilterJobID=432"))import requests
session = requests.Session()
session.headers["Accept"] = "text/html,application/xhtml+xml"
def open_board(entry_url: str) -> dict:
entry = parse_virecruit(entry_url)
resp = session.get(entry_url, timeout=30, allow_redirects=True)
resp.raise_for_status()
landed = parse_virecruit(resp.url)
if landed["host"] != entry["host"]:
raise RuntimeError("board redirected off-host — fail closed")
if landed["page"] not in ("recdefault.aspx", "redefault.aspx"):
raise RuntimeError("entry link did not land on a board page")
return {
"seed_tag": entry["tag"], # durable identity
"board_tag": landed["tag"], # request-time tag; may differ
"host": entry["host"],
"prefix": entry["prefix"],
"layout": "modern" if landed["page"] == "recdefault.aspx" else "legacy",
"html": resp.text,
"url": resp.url,
}
board = open_board("https://cozencareers.viglobalcloud.com/viRecruitSelfApply"
"/ReApplicantEmail.aspx?Tag=734c1474-fb1c-4d68-aa1f-5535c19f4983")
print(board["layout"], board["seed_tag"], "->", board["board_tag"])from bs4 import BeautifulSoup
def parse_board(board: dict) -> list[dict]:
soup = BeautifulSoup(board["html"], "html.parser")
# Every real board is a WebForms page; no ViewState means no board.
if soup.select_one("form#Form1 input[name='__VIEWSTATE']") is None:
raise RuntimeError("response is not a ViRecruit WebForms board")
counter = soup.select_one("#contentPlaceHolder_spanPositionsCounter")
if counter is None or not counter.get_text(strip=True).isdigit():
raise RuntimeError("board omitted its positions counter")
advertised = int(counter.get_text(strip=True))
rows = []
if board["layout"] == "modern":
for row in soup.select("[data-vi-filter], .position-row"):
heading = row.select_one("h4")
if not heading:
continue
body = row.select_one("section.description [data-vi-filter='detail'], section.description")
rows.append({
"title": heading.get_text(strip=True),
"description_html": body.decode_contents() if body else None,
})
else:
for anchor in soup.select("a[href*='ReJobView.aspx']"):
identity = parse_virecruit(anchor["href"] if anchor["href"].startswith("http")
else f"https://{board['host']}{anchor['href']}")
rows.append({"job_id": identity["job_id"], "title": anchor.get_text(strip=True)})
if len(rows) != advertised:
raise RuntimeError(f"parsed {len(rows)} rows but the board advertises {advertised}")
return rows
listings = parse_board(board)
print(f"{len(listings)} positions")import time
def job_url(board: dict, job_id: str) -> str:
prefix = f"/{board['prefix']}" if board["prefix"] else ""
return (f"https://{board['host']}{prefix}/viRecruitSelfApply/ReJobView.aspx"
f"?Tag={board['board_tag']}&JobID={job_id}")
def hydrate(board: dict, job_id: str) -> dict | None:
url = job_url(board, job_id)
resp = session.get(url, timeout=30)
if resp.status_code == 404:
return None # the only removal evidence ViRecruit gives
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
if soup.select_one("form#Form1 input[name='__VIEWSTATE']") is None:
raise RuntimeError("detail response is not a ViRecruit page")
description = soup.select_one("#contentPlaceHolder_labelJobDescription")
return {
"job_id": job_id,
"title": (soup.select_one("#page-title h3") or soup).get_text(strip=True),
"description_html": description.decode_contents() if description else None,
"listing_url": url,
"apply_url": url,
}
for row in listings[:3]:
if row.get("job_id"):
print(bool(hydrate(board, row["job_id"])))
time.sleep(0.25)# Firm-owned hosts and their nested prefixes cannot be derived from the host
# alone, so pin the exact triples you have audited.
AUDITED_HOSTS = {
"videsktop.littler.com": "",
"portal.velaw.com": "viDesktopEx",
}
def is_supported(url: str) -> bool:
try:
route = parse_virecruit(url)
except ValueError:
return False
if route["vendor_hosted"]:
return True
return AUDITED_HOSTS.get(route["host"]) == route["prefix"]
def board_state(board: dict) -> str:
soup = BeautifulSoup(board["html"], "html.parser")
if soup.select_one("a[href*='ReJobView.aspx']") or soup.select_one("section.description"):
return "listable"
if soup.select_one("#contentPlaceHolder_panelPositionsList") is None:
return "unsupported" # postback-only board, no addressable job ids
return "empty" # proved-empty board, an authoritative snapshot
print(is_supported("https://careers.example.com/viRecruitSelfApply/RecDefault.aspx?Tag=abc"))
print(board_state(board))- 1Keep the entry tag as the firm's identity and follow the redirect only for inventory
- 2Require the board's positions counter to equal your parsed row count
- 3Read modern descriptions inline; only legacy boards need a ReJobView request
- 4Pin firm-owned hosts and their path prefixes explicitly instead of inferring them
- 5Classify postback-only boards as unsupported, never as empty
- 6Accept removal only from a 404 on the canonical legacy detail URL
One endpoint. All ViRecruit / viDesktop jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=virecruit / videsktop" \
-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 ViRecruit / viDesktop
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.