Skip to main content

Command Palette

Search for a command to run...

The €300 that isn't a salary: parsing pay ranges out of free text

Context windows, perk words, period guessing and why we drop a range rather than guess wrong

Updated
•9 min read•View as Markdown
The €300 that isn't a salary: parsing pay ranges out of free text
S
SpinHire (spinhire.io) is a job board built only for the iGaming industry: casino, sports betting, game studios, affiliates, payments and compliance. We index 6,600+ live roles from 430+ companies every 6 hours and keep the data open: a public jobs API without a key (CC BY 4.0), XML and RSS feeds, and an MCP server for AI assistants. Here we write about building that index, iGaming tech careers and developer salaries.

Most job postings we index do not have a structured salary field. When there is a number, it lives somewhere in the description: in a bullet list, in a legal paragraph at the very end, or squeezed between a gym membership and a free lunch.

We wanted those numbers out of the text and into three columns: a minimum, a maximum, a currency and a period. It looked like an afternoon of regular expressions. It turned out to be a lesson in how many numbers in a job description are not pay.

This post is how the parser works, what it refuses to do, and why refusing is the main feature.

Why a wrong number is worse than no number

A salary we extract ends up in two places. A candidate sees it on the job card, and a search engine receives it in the JobPosting structured data as baseSalary.

If we miss a salary, the card simply shows no range. If we show the wrong one, a candidate sees "€300" on a senior role, and a search engine gets markup that is plainly false. One such error does more damage than a hundred postings without a range.

So the design rule from the first line of the module was: precision over recall. Every check below exists to throw something away.

Step one: find amounts that carry a currency

We only look at numbers that have a currency next to them, before or after. A bare "3000" can be anything: a team size, a postcode, a year range, part of a phone number.

_NUM = r"\d[\d\u00a0\u202f ,.]{2,}\d|\d{3,}|\d{1,3}[.,]\d{2}"
_CUR = r"€|\$|£|zł|₾|EUR|USD|GBP|PLN|GEL|USDT|UAH|грн"

AMOUNT = re.compile(
    rf"(?:(?P<cur1>{_CUR})\s?(?P<num1>{_NUM}))|"
    rf"(?:(?P<num2>{_NUM})\s?(?P<cur2>{_CUR}))",
    re.I,
)

Two details matter here. The number pattern accepts regular spaces, non-breaking spaces and narrow no-break spaces as thousand separators, because "1 200" is written all three ways depending on where the text was copied from. And the alternation catches both "€1 200" and "1200 EUR".

The text is also collapsed to single spaces before matching. Descriptions come from HTML, and line breaks in the middle of a range are common.

Step two: turn "85,000" and "18.25" into numbers

Commas and dots are ambiguous. "85,000" is eighty-five thousand. "1.500" in a European posting is fifteen hundred. "18.25" is an hourly rate with cents.

The rule we settled on is short: a separator followed by exactly two digits at the end, with at most three digits before it, is a decimal point. Anything else is a thousands separator.

def to_number(raw: str) -> float:
    text = raw.strip()
    decimal = re.search(r"[.,](\d{2})$", text)
    if decimal:
        whole = re.sub(r"[^\d]", "", text[: decimal.start()])
        if len(whole) <= 3:
            return float(f"{whole or 0}.{decimal.group(1)}")
    digits = re.sub(r"[^\d]", "", text)
    return float(digits) if digits else 0.0

It is not a locale-aware parser, and it does not need to be. Salaries with cents are hourly rates, and hourly rates have at most three digits before the decimal mark.

Step three: is this number about pay?

This is where most of the false positives die. For each amount we take a window of 140 characters to the left and 60 to the right, and ask two questions.

Is there a word about pay nearby? "salary", "base pay", "pay range", "compensation range", "total cash", and their Russian equivalents. No pay word, no salary. That alone removes prize amounts, revenue figures and "our players won up to €50 000" marketing copy.

Is there a word about a perk closer to the amount than the pay word? "allowance", "budget", "referral", "stipend", "wellness", "training", "relocation package", "sign-on" and so on.

pay = PAY_WORDS.search(left) or PAY_WORDS.search(right)
if not pay:
    continue

perk = PERK_WORDS.search(left)
pay_left = PAY_WORDS.search(left)
if perk and perk.start() > (pay_left.start() if pay_left else -1):
    continue          # a perk word sits between "salary" and the amount
if PERK_WORDS.search(right):
    continue          # "€300 yearly wellness allowance"

"Competitive salary and a €2 000 annual training budget" has a pay word in range. But "budget" sits right after the amount, so it is rejected. The title of this post comes from exactly that bullet: "€300 yearly wellness allowance" was one of the first numbers the naive version proudly published.

The window sizes are asymmetric on purpose. Pay words usually come before the amount ("The salary range for this role is…"), while perk nouns usually come after ("€300 allowance").

Step four: ranges in three spellings

A range can be written in at least three ways, and each one needs its own path:

  1. "€85,000 – €110,000": two currency amounts separated only by a dash, "to", "до" or "and". We check that the text between two matches is nothing but a separator, and that the second currency matches the first (or is missing).

  2. "from 1200 to 1800 EUR": the currency is attached only to the upper bound. The lower number sits to the left of the match, so we look backwards for "number + separator" at the end of the left window.

  3. "€1 200 – 1 800": the currency is attached only to the lower bound. We look forward a few characters for "separator + number".

In every case the second number must be on the right side of the first: a "range" where the upper bound is lower than the lower bound is usually two unrelated numbers that happen to be next to each other.

Step five: month, year or hour?

If the text says "per year", "annual", "p.a.", "/month", "hourly", "в месяц" and so on, we take it. The period regex looks at the left window plus the 60 characters after the amount.

When the period is not stated, we guess from magnitude, and only where the guess is safe:

def finalize(low, high, currency, stated):
    period = stated
    if period is None:
        if high < 10_000:
            period = "MONTH"
        elif low >= 20_000:
            period = "YEAR"
        else:
            return None   # 10k-20k: could be either, so we drop it
    if not (plausible(low, period) and plausible(high, period)):
        return None
    return {"min": low, "max": high, "currency": currency, "period": period}

The gap between 10 000 and 20 000 is deliberate. A monthly figure in a high-cost market and a yearly figure in a low-cost one both land there. Showing a yearly salary as monthly multiplies it by twelve on the card, which is the worst error the parser can make. So a range like "€14,400 – 18,000" with no period gets no range at all.

Plausibility bounds are wide and per period: an hourly rate between 3 and 500, a monthly figure between 400 and 100 000, a yearly one between 8 000 and 2 000 000. They are not there to judge the market. They catch years, phone fragments and typos.

One subtle bug lived here for a while. Before the period is known, the lower bound of an hourly range ("$18.25") fails the monthly check and gets thrown away before we even see "/hour". The fix was a softer check during scanning: accept the number if it is plausible under any of the three periods, and leave the strict check to finalize, which sees the whole range.

Tests that read like a spec

Every rule above is pinned by a test case built from a real description. About half of the suite is about what is not a salary:

def test_wellness_allowance_is_not_salary(self):
    self.assert_salary("• €300 yearly wellness allowance. • Office perks", "")

def test_phone_number_is_not_salary(self):
    self.assert_salary("Salary negotiable, call 380 67 123 45 67", "")

def test_ambiguous_magnitude_is_dropped(self):
    self.assert_salary("Salary for this role can range between "
                       "€14,400 - 18,000+ (including performance bonuses)", "")

The positive cases cover an annual dollar range with commas, a monthly euro range with the currency repeated, an hourly range with cents, a Russian "from … to … EUR" with the currency only on the upper bound, and a single "up to" amount. The whole file runs in under a second.

What it costs: recall

The honest number: on the first run over the whole index, the parser recovered a real range for 54 of 1,954 postings that had no salary field. That is under 3%.

Most of the rest simply do not publish a number, and no parser can fix that. But some of the misses are ours, and we keep them on purpose. "Salary €2 500, plus a €300 wellness allowance" is rejected today, because the perk word appears inside the right-hand window of the real salary. A smarter version would split the text into clauses first. Until we see this pattern often enough to justify it, a missing range is cheaper than a wrong one.

Two more things the parser does not try to do. It does not guess net versus gross: postings rarely say, and the ones that do use local terms we would have to maintain per country. And it does not convert currencies. That happens one layer up, where a fixed rate table turns a stored range into a comparable monthly figure in euros, and anything hourly or in an unknown currency is left out of comparisons.

Takeaways

  • Decide what a wrong answer costs before writing the first regex. For us it decided almost every threshold.

  • Anchor extraction on context words, not on number shapes. A currency sign next to digits is necessary, not sufficient.

  • Negative examples are the most valuable tests. Collect the numbers that fooled you and keep them forever.

  • Leave an explicit "I don't know" zone. Our 10k–20k gap throws away some good data and protects us from the most embarrassing kind of error.

We use this at SpinHire, a job board for the iGaming industry: https://spinhire.io