<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[SpinHire Engineering: iGaming jobs data]]></title><description><![CDATA[How SpinHire indexes 6,600+ live iGaming jobs, plus data on iGaming tech salaries, hiring demand and a free public jobs API.]]></description><link>https://spinhire.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6aad2fa6b30acc60197b8993/e3435165-a898-4cbb-ba82-1248260c3591.png</url><title>SpinHire Engineering: iGaming jobs data</title><link>https://spinhire.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 19 Sep 2026 14:13:40 GMT</lastBuildDate><atom:link href="https://spinhire.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Montana is not Malta: what we learned normalizing thousands of job postings]]></title><description><![CDATA[We run a niche job index: once a day a worker pulls vacancies from company career pages and applicant tracking systems, cleans them up and publishes them. It sounds like a solved problem. In practice,]]></description><link>https://spinhire.hashnode.dev/normalizing-job-postings-lessons</link><guid isPermaLink="true">https://spinhire.hashnode.dev/normalizing-job-postings-lessons</guid><category><![CDATA[Python]]></category><category><![CDATA[data-engineering]]></category><category><![CDATA[SQLite]]></category><category><![CDATA[Regex]]></category><category><![CDATA[backend]]></category><dc:creator><![CDATA[SpinHire]]></dc:creator><pubDate>Fri, 18 Sep 2026 13:33:13 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aad2fa6b30acc60197b8993/227ec897-c81c-4588-af9e-60e34a9c564b.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>We run a niche job index: once a day a worker pulls vacancies from company career pages and applicant tracking systems, cleans them up and publishes them. It sounds like a solved problem. In practice, most of the work is not fetching data. It is deciding what a string means.</p>
<p>Here are the lessons that cost us the most time.</p>
<h2>1. Start with the public ATS APIs</h2>
<p>Many companies host their careers page on an applicant tracking system, and the big ones expose public, unauthenticated JSON for job boards:</p>
<ul>
<li><p><strong>Greenhouse</strong>: <code>boards-api.greenhouse.io/v1/boards/{board}/jobs?content=true</code></p>
</li>
<li><p><strong>Lever</strong>: <code>api.lever.co/v0/postings/{site}?mode=json</code></p>
</li>
<li><p><strong>SmartRecruiters</strong>: <code>api.smartrecruiters.com/v1/companies/{id}/postings</code>, paged 100 at a time, plus one detail call per job</p>
</li>
<li><p><strong>BambooHR</strong>: <code>{account}.bamboohr.com/careers/list</code> and <code>/careers/{id}/detail</code></p>
</li>
</ul>
<p>These are designed to be consumed, they are stable, and they give you a real external ID. Parsing HTML should be the last resort, not the first.</p>
<p>One quirk worth knowing: Greenhouse returns job descriptions HTML-escaped <strong>twice</strong>. Unescape once and you get <code>&amp;lt;p&amp;gt;</code> in your text; unescape twice and you get markup.</p>
<pre><code class="language-python">import html

def clean_description(raw: str) -&gt; str:
    text = html.unescape(html.unescape(raw or ""))
    return text[:20_000]
</code></pre>
<h2>2. Be boring about scheduling</h2>
<p>Our crawler runs in a separate worker process, sequentially, one source after another. A lock prevents two runs from overlapping. Every source gets two attempts, a 25-second timeout and a small delay between requests (0.15–0.6 s). The bot identifies itself with a descriptive User-Agent that links back to the site.</p>
<p>No async, no queue, no fan-out. For a few thousand postings a day, the bottleneck is never throughput. It is correctness, and sequential code is much easier to reason about when something breaks.</p>
<p>The web app and the worker share one SQLite database in WAL mode, so readers never block on the writer. On a single-vCPU server, the thing that actually hurt us was not the crawl but a derived-pages job rebuilding every ten minutes. It pushed the box into swap. Rebuilding hourly fixed it.</p>
<h2>3. Locations: parse from the end</h2>
<p>Location strings are the worst field in any job dataset. Our rule of thumb: <strong>read them right to left</strong>, because the most specific signal about the country is usually last.</p>
<p>The bugs we hit:</p>
<ul>
<li><p><code>Boston, MA</code> was being read as <strong>Morocco</strong> (MA is Morocco's ISO code).</p>
</li>
<li><p><code>Atlanta, Georgia, US</code> was being read as the <strong>country</strong> Georgia.</p>
</li>
<li><p>Two-letter codes are ambiguous: <strong>MT</strong> is Malta or Montana, <strong>CA</strong> is Canada or California. We use letter case and the tokens around the code to decide.</p>
</li>
<li><p>Lowercase English words matched country codes: <strong>at</strong>, <strong>is</strong> and <strong>me</strong> turned into Austria, Iceland and Montenegro.</p>
</li>
</ul>
<p>A simplified version:</p>
<pre><code class="language-python">US_STATES = {"MA", "GA", "CA", "MT", "NJ", "NY", "PA"}  # abbreviated

def country_of(location: str) -&gt; str | None:
    parts = [p.strip() for p in location.split(",") if p.strip()]
    for part in reversed(parts):
        if part in ("US", "USA", "United States"):
            return "US"
        if part.isupper() and part in US_STATES and len(parts) &gt; 1:
            return "US"          # "Boston, MA" is not Morocco
        country = lookup_country(part)  # exact names only, never lowercase 2-letter words
        if country:
            return country
    return None
</code></pre>
<h2>4. Keyword filters need negative context</h2>
<p>We keep only vacancies from one industry, so a lot of the pipeline is keyword classification. Every naive keyword produced a funny false positive:</p>
<ul>
<li><p><strong>"unity"</strong> is inside "opport<strong>unity</strong>", which tagged shop-floor jobs as Unity developer roles. Word boundaries fixed it.</p>
</li>
<li><p><strong>"slot"</strong> in a drone-firmware job description, singular, had nothing to do with slot games.</p>
</li>
<li><p><strong>"betting on Google Ads"</strong> is a figure of speech in marketing copy.</p>
</li>
<li><p>In Ukrainian, "Procter &amp; Gamble" is transliterated as "Проктер енд Гембл", which contains the Cyrillic stem for "gamble". It needed an explicit exception.</p>
</li>
</ul>
<p>What worked better than longer keyword lists:</p>
<ol>
<li><p>Check the <strong>title</strong> before the description. Titles are short and intentional; descriptions are full of boilerplate.</p>
</li>
<li><p>Ignore a keyword when it sits in a list next to unrelated industries ("fintech, forex, crypto, gaming").</p>
</li>
<li><p>Trust a company once it proves itself: at least two matching roles, and at least a third of its roles matching.</p>
</li>
<li><p>Require two independent signals for generic employers such as large IT outsourcers.</p>
</li>
</ol>
<h2>5. Salary parsing is a context problem</h2>
<p>Numbers are easy to find. Knowing which ones are pay is not. Our parser accepts an amount only if a pay word appears within 140 characters <strong>and</strong> no perk word ("allowance", "budget", "bonus scheme") is closer to it. Otherwise "€1,000 learning budget" becomes a salary.</p>
<p>When the period is not stated, we infer it from magnitude: a range topping out under 10k is monthly, one starting at 20k+ is yearly, and anything in between is <strong>dropped</strong>. A missing salary is better than a wrong one. Sanity bounds catch the rest: yearly 8k–2M, monthly 400–100k, hourly 3–500.</p>
<p>Decimal and thousands separators need the same care: <code>18.25</code> is a decimal, <code>85,000</code> is eighty-five thousand. And a shorthand like <code>€4k–6k</code> is a case we still do not parse, which is a good reminder to measure what your parser misses, not just what it finds.</p>
<h2>6. Deduplicate conservatively</h2>
<p>The same job often arrives from two or three sources. We use two layers:</p>
<ul>
<li><p><strong>Exact</strong>: <code>(source, external_id)</code> is the upsert key.</p>
</li>
<li><p><strong>Cross-source</strong>: a normalized key of title + first 12 characters of the company + first 16 of the location, reduced to lowercase letters and digits. Within a group we keep the posting with the longest description and archive the rest.</p>
</li>
</ul>
<p>No fuzzy matching, no MinHash. It misses some duplicates, but it almost never merges two different jobs, and that asymmetry is what we want for a public index.</p>
<h2>7. Only archive what you fully saw</h2>
<p>A job that disappears from its source should be archived. But if a source fails halfway, "missing" does not mean "closed". We only archive missing jobs from sources whose fetch succeeded <strong>and</strong> returned fewer than the per-board cap (300). A truncated response never closes anything. Jobs that reappear are reactivated.</p>
<h2>8. Monitor per source, not per run</h2>
<p>Our worst incident was silent: a source kept returning <strong>+0 new jobs</strong> for about a month, and a total that still looked healthy hid it. Now every run stores per-source counts, attempts, timings and errors, with the last 30 runs kept, so a flat line on one source stands out immediately.</p>
<h2>Takeaways</h2>
<ul>
<li><p>Prefer official, public JSON endpoints over HTML.</p>
</li>
<li><p>Sequential and boring beats clever for small daily batches.</p>
</li>
<li><p>Parse locations from the end and distrust two-letter tokens.</p>
</li>
<li><p>Every keyword needs a negative context rule.</p>
</li>
<li><p>Drop uncertain salaries instead of guessing.</p>
</li>
<li><p>Deduplicate conservatively and archive only what you fully observed.</p>
</li>
<li><p>Alert on each source separately.</p>
</li>
</ul>
<p>We use this pipeline to run SpinHire, an open index of iGaming jobs: <a href="https://spinhire.io/en?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=normalizing">spinhire.io</a></p>
]]></content:encoded></item></channel></rss>