🎉 Premium Proxies · 3-Day Free TrialClaim Now
Use Cases

Proxies for Job Board Aggregation: Operator's Guide

Running a job aggregator? How proxies for job board aggregation handle cross-source dedupe, employer canonicalisation, expiry detection, and re-crawl cost.

S SparkProxy 2 22 min read
Share
Proxies for Job Board Aggregation: Operator's Guide

Proxies for job board aggregation solve a problem that only appears at the third or fourth source you add. Scraping one board is an extraction exercise. Aggregating forty boards, six ATS platforms, and eight hundred career pages is an identity and freshness problem, and the collection layer either supports that or quietly poisons it. One requisition can surface as five listings with three employer spellings, two location formats, and four posting dates. Your users see five jobs. There is one.

This guide is for the operator running the aggregator, the labour-market dataset, or the internal talent-intelligence warehouse. For the mechanics of pulling listings off one board, read how to scrape Indeed job listings instead. This post assumes that part already works.

Key Takeaways

  • Dedupe on identity before similarity: resolve the apply link to an ATS requisition ID and most syndicated copies collapse.
  • The strongest employer key is the ATS tenant slug or careers domain, not the name string. Names move; the tenant does not.
  • Remote is not a location. Store it as an attribute plus an eligibility scope, or your country filters will lie.
  • Listings die three ways: removed, silently filled, re-posted. Only the first shows in an HTTP status code.
  • Batch liveness checks (render_js=false, comma-separated URLs, 1 credit per batch) cut refresh cost by an order of magnitude.
  • Track cost per accurate listing-day, not cost per request.

Why Aggregation Breaks Where Single-Source Scraping Works

A single-source scraper has one schema, one rate limit, one locale, and one notion of "this listing exists". Every one of those becomes plural the moment you aggregate. Rate limits turn into a scheduling constraint: sweep 40 boards for 60 role-and-location queries each and that is 2,400 discovery requests before you touch a detail page, against sources that cap requests per IP per window. From one IP the run dies in minutes.

Then the schemas disagree. Board A gives you "London, United Kingdom", Board B "Greater London", the company's own Workday page "GBR-London-1 Finsbury Ave". One requisition, three jobs in your database, inflated coverage numbers and broken geo filters. Freshness stops being free too: with forty expiry conventions and no shared identifier, "is this job still open?" costs money to answer, every day, forever. Proxies solve neither identity nor freshness, but without distributed collection you cannot gather enough of the graph, often enough, to solve either.


What Proxies for Job Board Aggregation Actually Do

Four concrete jobs, roughly in order of how much they matter:

Distribute request volume under per-IP limits. A rotating pool spreads a 250,000-request day across enough exits that no single IP looks abnormal to any source.

Serve the correct regional variant. Boards route by visitor IP. The same search from a US exit and a German exit returns different listings, different salary currency, sometimes different mandatory fields. For the German market you need a DE exit, not a US one with a language header.

Keep collection isolated per source. Distinct exit ranges keep a bad afternoon on one board from taking out the whole refresh cycle.

Make cheap liveness checks possible at all. Verifying 400,000 listings means 400,000 small requests. From a handful of addresses that reads as an attack. Spread across a pool it reads as background traffic.


Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

Cross-Source Deduplication: One Role, Five Boards

Most aggregators start with fuzzy text matching, then spend two years tuning thresholds. Start somewhere else.

A large share of board listings are syndicated copies of one applicant tracking system posting, and the copy carries the original's requisition ID in its apply link. That is an exact identifier, not a similarity score. Resolve the apply URL, extract the ID, and the copies collapse deterministically. Fuzzy matching then handles only the residue.

Duplicate classes you will actually see

ClassWhat it looks likeRight resolution
Syndicated copySame requisition on 3-6 boards, description lightly reworded or truncatedMerge on ATS requisition ID
Multi-location fan-outOne requisition published to 12 city pagesMerge into one record with a location array
Re-post after expiryIdentical role reappears with a new date and new URLMerge, but keep both posting events
Agency versus directRecruiter lists the same role, employer masked as "Leading fintech"Link as related, do not merge blindly
Distinct requisitionsTwo genuine openings, same title, same officeDo not merge, and this is the failure mode of pure text similarity

That last row is why similarity-only dedupe caps out. Two real openings for "Software Engineer II, Payments" in the same city have near-identical descriptions. They are two jobs, and only an identifier separates them.

import re

ATS_PATTERNS = [
    # Greenhouse: job-boards.greenhouse.io/acme/jobs/4172233007
    (r"greenhouse\.io/(?P<tenant>[^/]+)/jobs/(?P<req>\d+)", "greenhouse"),
    # Lever: jobs.lever.co/acme/8f3c2a9e-1d44-4c2b-9f77-2b0e5a1d9c31
    (r"lever\.co/(?P<tenant>[^/]+)/(?P<req>[0-9a-f-]{36})", "lever"),
    # Workday: acme.wd3.myworkdayjobs.com/en-US/External/job/London/..._R-12345
    (r"(?P<tenant>[\w-]+)\.wd\d+\.myworkdayjobs\.com/.*?(?P<req>R-\d+)", "workday"),
    # Ashby: jobs.ashbyhq.com/acme/9c1e2f0a-...
    (r"ashbyhq\.com/(?P<tenant>[^/]+)/(?P<req>[0-9a-f-]{36})", "ashby"),
    # SmartRecruiters: jobs.smartrecruiters.com/Acme/743999812345
    (r"smartrecruiters\.com/(?P<tenant>[^/]+)/(?P<req>\d+)", "smartrecruiters"),
]

def ats_identity(apply_url: str):
    for pattern, vendor in ATS_PATTERNS:
        m = re.search(pattern, apply_url, re.I)
        if m:
            return f"{vendor}:{m.group('tenant').lower()}:{m.group('req')}"
    return None

Board apply links are usually redirects, so resolve the chain first. A plain fetch at render_js=false costs 1 credit:

import requests

API = "https://scrape.sparkproxy.io/api/v1"
HEADERS = {"X-API-Key": "YOUR_API_KEY"}

def resolve_apply_url(tracking_url: str) -> str:
    r = requests.get(API, headers=HEADERS, params={
        "url": tracking_url,
        "render_js": "false",
        "json_response": "true",
    }, timeout=60)
    body = r.json()
    return body.get("meta", {}).get("url", tracking_url)

Tier 2: blocking plus SimHash on the residue

Do not compare every listing to every other. Block first, then compare inside blocks.

import hashlib, re

STOP = {"senior", "junior", "lead", "staff", "i", "ii", "iii", "sr", "jr"}

def title_tokens(title: str) -> frozenset:
    words = re.findall(r"[a-z0-9+#]+", title.lower())
    return frozenset(w for w in words if w not in STOP)

def blocking_key(employer_id: str, title: str, country: str) -> str:
    toks = "|".join(sorted(title_tokens(title))[:4])
    raw = f"{employer_id}::{toks}::{country}"
    return hashlib.blake2b(raw.encode(), digest_size=8).hexdigest()

Inside a block, compare SimHash fingerprints of the normalised description. Manku, Jain and Das Sarma's WWW 2007 paper on near-duplicate detection for web crawling used 64-bit fingerprints with a Hamming distance threshold of 3, still a sane starting point for job descriptions.

def near_duplicate(fp_a: int, fp_b: int, k: int = 3) -> bool:
    return bin(fp_a ^ fp_b).count("1") <= k

Strip the boilerplate first. Boards append their own apply instructions, equal-opportunity blocks, and cookie notices, and that shared text inflates similarity between different roles from the same board. Fingerprint the requirements and responsibilities sections only. The same normalise-then-compare discipline applies downstream; the guide to cleaning scraped data covers it in depth.

Record merges as edges with a confidence and a method, never as destructive overwrites: when a threshold turns out wrong you want to unmerge without re-crawling.


Canonicalising Employer Names

Employer names are the least reliable field in the record. One company appears as "Acme Ltd", "ACME LIMITED", "Acme Group (UK)", "Acme Technologies GmbH", and, via an agency, "Confidential".

String normalisation is table stakes:

import re, unicodedata

SUFFIXES = r"\b(ltd|limited|inc|incorporated|llc|plc|gmbh|ag|sa|sas|bv|nv|oy|ab|as|pty|pte|srl|spa|kk|co)\b\.?"

def normalise_employer(name: str) -> str:
    s = unicodedata.normalize("NFKC", name).casefold()
    s = s.replace("&", " and ")
    s = re.sub(SUFFIXES, " ", s)
    s = re.sub(r"[^\w\s]", " ", s)
    return re.sub(r"\s+", " ", s).strip()

That stalls quickly. "Acme Group" and "Acme Technologies" normalise to different strings while being one employer, and two unrelated "Apex Consulting" entries normalise to the same string while being two.

The stronger key is structural. The apply link almost always reveals the employer's ATS tenant or careers domain, and those are stable identifiers the employer controls:

SignalExampleStability
ATS tenant slug`greenhouse:acme`, `lever:acme`High, changes only on ATS migration
Careers subdomain`careers.acme.io`High
Corporate email domain in contact field`talent@acme.io`Medium, often absent
Logo image URL host`cdn.acme.io`Medium
Normalised name string`acme`Low

Build the registry on the structural signals, attach every observed name string as an alias, and use the name only to match new listings to an entity you already know. For the display name, pick the most frequent alias seen on the employer's own domain, not overall: board listings skew toward whatever the recruiter typed that morning.

Two cases need explicit handling. Agency listings that mask the employer should stay unresolved rather than guessed: a wrong attribution is worse than a missing one. ATS migrations break tenant continuity, so keep a merged_into pointer on the old entity instead of rewriting history.


Canonicalising Locations and the Remote Problem

Location strings arrive in every shape a human can type.

Raw stringCountryAdmin-1CityRemote
`London, UK`GBEnglandLondonno
`Greater Manchester Area`GBEnglandManchesterno
`München, Bayern`DEBayernMunichno
`NY, NY`USNYNew Yorkno
`Remote (US)`(none)(none)(none)yes, scope US
`Hybrid - Austin, TX`USTXAustinpartial
`GBR-London-1 Finsbury Ave`GBEnglandLondonno

Resolve against a gazetteer such as GeoNames, store ISO 3166-1 alpha-2 for country and ISO 3166-2 for subdivisions, and keep the raw string beside the resolved values so you can re-resolve without re-crawling.

The failure that quietly ruins aggregator data is treating "Remote" as a place. Stamp a remote role with the employer's headquarters city and every city-level count you publish is wrong, and users get results they cannot apply for. Model it as two fields: a workplace_type of onsite, hybrid, or remote, and an eligibility_scope holding the countries where the employer will hire. Schema.org's JobPosting vocabulary draws that line with jobLocationType: TELECOMMUTE and applicantLocationRequirements, so prefer the markup over the visible text where a source emits it.

Parsing structured data is cheaper and more accurate than scraping the rendered page:

import json
from selectolax.parser import HTMLParser

def job_posting_jsonld(html: str):
    tree = HTMLParser(html)
    for node in tree.css('script[type="application/ld+json"]'):
        try:
            data = json.loads(node.text())
        except ValueError:
            continue
        for obj in (data if isinstance(data, list) else [data]):
            if obj.get("@type") == "JobPosting":
                return obj
    return None

A well-formed JobPosting block hands you datePosted, validThrough, hiringOrganization, jobLocation, employmentType, baseSalary, and often an identifier carrying the ATS requisition number. Identity, employer, location, and expiry, all in one parse.


Freshness, Expiry Detection, and Re-Post Games

Listings leave your dataset three ways, and only one of them announces itself.

Removed. The detail URL returns 404 or 410, or the listing disappears from the source's index or sitemap. Cheap and unambiguous.

Silently filled. The page still returns 200 and the role is closed. Sometimes there is a DOM marker: "no longer accepting applications", a disabled apply button, a validThrough date in the past. Often there is nothing, and the listing simply stops appearing in search results while staying reachable by direct URL. Index membership, not HTTP status, is the reliable liveness test on boards that never delete pages.

Re-posted. The employer or board refreshes the posting date to push the role back up the sort order, sometimes under a new URL. Treat that as a new listing and your "new jobs today" metric becomes noise. Treat it as the same listing and you lose the signal that the role has been open ninety days, which is itself valuable labour-market data.

Handle all three by separating a listing (one URL on one source) from a posting (one requisition's continuous life) from a posting event (a single observation). A re-post creates a new listing and a new event, both attached to the existing posting.

Detection rules, ordered by what they cost you:

  1. Absence from the source index or ATS feed since the last discovery pass. Free, it falls out of the crawl you already ran.
  2. validThrough in the past, from JSON-LD you already parsed. Free.
  3. HTTP status on the canonical URL. One credit per batch, not per URL.
  4. DOM markers for closed roles. Needs a fetch, sometimes a full render.

Escalate to step 4 only for listings that survived steps 1 to 3 and are suspiciously old.


Adaptive Re-Crawl Scheduling

Nightly full re-crawls are the default and they are wrong. Fresh listings change often because someone is actively managing them; very old listings are mostly dead but expensive to keep checking. Schedule by age band and source volatility instead:

Listing ageCheck intervalMethodRationale
0-3 daysdailyindex membershipedits and quick pulls are common
4-14 daysevery 2 daysindex membership plus batch statusthe stable middle
15-45 daysevery 4 daysbatch statusmost expiries land here
46-90 daysweeklybatch status, DOM check on suspicionlikely stale
90+ daysweekly, then archiveone DOM check, then retirekeep the record, stop paying for it

Layer a per-source volatility multiplier on top: track how fast each source's listings change state per day and scale its intervals by that. A board where five percent of listings expire daily deserves several times the attention of one at half a percent, and you learn both from your own logs inside two weeks.

def next_check_days(age_days: int, source_volatility: float) -> float:
    base = 1 if age_days <= 3 else 2 if age_days <= 14 else 4 if age_days <= 45 else 7
    # volatility is the observed daily state-change rate, e.g. 0.05
    multiplier = max(0.5, min(2.0, 0.02 / max(source_volatility, 0.001)))
    return round(base * multiplier, 1)

Deduplication and scheduling interact, which is easy to miss. Once five listings merge into one posting they no longer need the same cadence: check the authoritative copy, usually the employer's own ATS page, on the tight schedule, and check the syndicated copies rarely, only to confirm they still exist. For the orchestration side, see the guide on scheduling and automating web scrapers.


Geo-Distributed Collection

Listings differ by country, and not only in language.

What changes with exit countryEffect on your dataset
Which listings appear at allCoverage gaps you cannot see from one country
Currency and salary formatSalary parsing breaks or converts wrong
Mandatory pay-range disclosureSome jurisdictions require ranges, others do not
Domain redirects`example-board.io` sends you to a country subdomain
Date and number formatting`03/04/2026` means two different days
Legal notices appended to descriptionsBoilerplate that pollutes similarity scoring

Pay disclosure keeps changing. Colorado's Equal Pay for Equal Work Act (in force 2021) started the US wave, California and New York followed in 2023, and EU Directive (EU) 2023/970 requires member states to transpose pay-transparency rules by 7 June 2026. Salary coverage therefore varies by jurisdiction and year, so any cross-country salary statistic must control for that or it will show trends that are really just disclosure law arriving.

One rule saves a lot of pain: pin the collection country per source and keep it stable. Fetch the same listing from a US exit on Monday and a UK exit on Tuesday and the currency, formatting, and appended notices all change, your content hash flips, and change detection reports an edit that never happened. Every false positive costs a re-parse and a re-index, so normalise before hashing and pin the locale anyway. What geo-targeting means in proxies covers the mechanics.


Collection Patterns With the SparkProxy Scraping API

Three patterns cover almost all aggregator traffic, against the documented endpoint https://scrape.sparkproxy.io/api/v1 with the key in an X-API-Key header.

Discovery pass, geo-targeted and rendered. Search and index pages are usually client-rendered, so this is the expensive tier: 5 credits for rendering plus 5 for country_code.

import requests

API = "https://scrape.sparkproxy.io/api/v1"
HEADERS = {"X-API-Key": "YOUR_API_KEY"}

def discover(board_search_url: str, country: str) -> str:
    r = requests.get(API, headers=HEADERS, params={
        "url": board_search_url,
        "render_js": "true",
        "country_code": country,        # ISO 3166-1 alpha-2, +5 credits
        "wait_for": ".job-results",     # gives up after 30 seconds
        "block_resources": "true",      # skip images, fonts, CSS
        "tag": f"discovery-{country}",  # echoed back for grouping
    }, timeout=120)
    return r.text

Detail extraction with extract_rules. Pull typed fields instead of shipping raw HTML around:

import json, requests

rules = {
    "title":       "h1.job-title",
    "employer":    ".company-name",
    "location":    ".job-location",
    "posted":      "time.posted-date",
    "apply_url":   {"selector": "a.apply-button", "type": "attribute", "attribute": "href"},
    "description": ".job-description",
}

r = requests.get(API, headers=HEADERS, params={
    "url": "https://example-board.io/jobs/48291",
    "render_js": "true",
    "extract_rules": json.dumps(rules),
    "tag": "detail",
}, timeout=120)
record = r.json()

Batch liveness checks. The pattern that changes aggregator economics. Batch mode takes comma-separated URLs, requires render_js=false, and the whole batch costs 1 credit however many URLs it holds:

import requests

def liveness_batch(urls: list) -> list:
    r = requests.get(API, headers=HEADERS, params={
        "url": ",".join(urls),      # batch mode
        "render_js": "false",       # required for batch
        "tag": "liveness",
    }, timeout=180)
    return r.json()["results"]      # url, success, httpStatus, body, error

dead = [x["url"] for x in liveness_batch(batch) if x["httpStatus"] in (404, 410)]

For long discovery jobs, callback_url returns HTTP 202 immediately and POSTs the result to your webhook, so your scheduler never holds thousands of open connections. When a source fights back, premium_proxy=true moves it to residential exits at 10 credits without JS or 25 with, and stealth=true adds a homepage pre-warm, a forced Google referrer, and longer idle delays for 5 more. Reserve both for the sources that need them; failed scrapes surface as HTTP 530 and their credits are refunded.


The Economics of Continuous Re-Crawl

Work an illustrative model: 400,000 live listings across 40 sources, 12,000 new per day, at the documented rates of 1 credit for a plain fetch, 5 with rendering, 5 more for country_code.

StrategyDaily workDaily credits
Naive: re-render every listing nightly400,000 rendered fetches2,000,000
Plain re-fetch every listing nightly400,000 plain fetches400,000
Tiered and batchedsee breakdown below~86,000

The tiered breakdown, with liveness batched 50 URLs to a call:

ComponentVolumeRateCredits
Discovery pages (rendered plus geo)1,2001012,000
New listing detail fetches (rendered)12,000560,000
Liveness checks (500,000 URL-checks in 10,000 batched calls)10,000 calls110,000
Suspicion escalations (rendered DOM check)80054,000
**Total****86,000**

Roughly a 23x reduction against the naive nightly re-render, and the dataset comes out fresher, because the savings get spent checking new and volatile listings more often instead of polling ninety-day-old ones daily.

Three levers do the work: batching (50 URLs per 1-credit call, a 50x cut on the largest volume line), rendering discipline (5x cheaper without a browser, and most JobPosting JSON-LD is emitted server-side anyway), and dedupe-aware scheduling (check one authoritative copy, not five syndicated ones).

The KPI worth tracking is cost per accurate listing-day: collection spend divided by the listing-days where your record matched reality. It penalises under-crawling (stale records shown to users) and over-crawling (money spent confirming nothing changed) at once, which cost per request does not.


Frequently asked questions

FAQ

They spread collection across many IP addresses so an aggregator can sweep dozens of job boards while staying under each source's per-IP rate limits, and they provide country-specific exits so each regional variant of a board returns what a local visitor sees.

Resolve each listing's apply link to its ATS requisition ID (Greenhouse, Lever, Workday, Ashby and SmartRecruiters all expose one in the URL) and merge on that exact identifier. Fall back to blocking plus SimHash similarity only where no requisition ID can be recovered.

Not on a single global interval. Tier by listing age, checking 0-3 day listings daily and 46-90 day listings weekly, then scale each source's intervals by its observed state-change rate. Most expiries land in the 15-45 day band.

Absence from the source's own index or ATS feed is the most reliable signal, because many boards leave filled roles reachable by direct URL after removing them from search. Back it with validThrough from JSON-LD, then batched HTTP status checks, and render the page only for listings that survive all three and still look suspicious.

It has been litigated rather than settled. hiQ v. LinkedIn and Van Buren point to low CFAA exposure for genuinely public pages with no login, but hiQ still lost on breach of contract, and EU database rights, GDPR, and copyright in the description text all apply independently. Get jurisdiction-specific advice.

Far less than a nightly full refresh if you batch. On the documented SparkProxy rates, re-rendering 400,000 listings nightly is 2,000,000 credits per day, while a tiered schedule with liveness checks batched 50 URLs to a 1-credit call lands near 86,000 credits per day for a fresher dataset.


Special Discount · 20% off

Get 20% off your first month

Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.

Save up to 15% more on quarterly, half-yearly and yearly plans

Claim Discount

About the Author

The SparkProxy Technical Team builds and operates the infrastructure behind SparkProxy's datacenter proxies, residential proxies, and Scraping API. We work daily with teams running high-volume, multi-source pipelines, job aggregators and labour-market data providers among them, and the patterns above come from what those pipelines need: predictable geo-targeting, cheap high-frequency liveness checks, per-source isolation. Full documentation lives at sparkproxy.io/docs/scraping-api, and the team is reachable at support@sparkproxy.io.

Keep reading

Related articles

Proxies for Hotel Rate Parity Monitoring

Proxies for Hotel Rate Parity Monitoring

Most hotel rate parity monitoring alerts are false. Learn the geo-distributed, logged-out collection design and comparable key that make breach detection real.

SparkProxy·Use Cases