SPB Git

extraction precision: multilingual validators for jobs/people/locations/plans/products/news, extractor fixes, fixtures, audit script

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 13, 2026) parent cf2a0d2 Browse files

15 changed files +1,723 −97

modified docs/CONNECTORS.md +36 −0
@@ -44,6 +44,42 @@ hybrid, `seniority_guess`, `employment_type_norm`. The pipeline sets `is_ai` (`t
44 Job identity = `job_fingerprint(title, location_text, external_id | url)`; structured boards also give each job a stable block 44 Job identity = `job_fingerprint(title, location_text, external_id | url)`; structured boards also give each job a stable block
45 key so the block diff mirrors the job delta. 45 key so the block diff mirrors the job delta.
46 46
47+## Precision rules (`connectors/_precision.py`, `precision-v1`, 2026-09-13)
48+
49+Production samples showed the generic HTML connector capturing navigation / call-to-action anchors as jobs (`Meer info`, `Early careers
50+bij …`), cookie-consent categories as locations (`Performance & Analytics | Allows use of behavioural data…`), swapped person cards
51+(`Chair Emeritus | Warner Bros. Discovery`), marketing headings as plans (`Win your market with … | Talk to sales`) and truncated
52+`price_text`. The precision module holds deterministic, multilingual (EN/FR/DE/NL/ES/IT/PT/JA) validators and normalisers that
53+`generic_html.py` applies while extracting and `services/pipeline._drop_corrupt_entities` re-applies as the last line of defence for
54+every connector (job rules only for `generic-html-v1` — structured boards are trusted). Every rule returns a `Verdict(ok, reason)`, so
55+`scripts/audit_extractions.py` can explain rejections on stored rows. Limits are module constants (no magic numbers).
56+
57+| entity | rejected | required | normalised |
58+|---|---|---|---|
59+| jobs (HTML) | CTA / nav stoplist (`read more`, `apply now`, `meer info`, `en savoir plus`, `mehr erfahren`, `alle stellen`, `ver más`, `scopri di più`, `詳細を見る` …), category prefixes (`early careers`, `careers at …`, `why …`), single-word titles, titles ending with an ellipsis, prose-like titles (≥ 4 words with function words) without role vocabulary, > 140 chars | role vocabulary (engineer, manager, analyst, nurse, driver, `ingénieur`, `entwickler`, `verpleegkundige`, `ingeniero`, `エンジニア` … or a format marker `(m/w/d)`, `Vollzeit`), **or** a job-like URL (`/job/`, `/jobs/<slug>`, `/careers/x/y`, `/vacature`, `/stelle`, `/offre`, `/emploi`, `/empleo`, `/lavoro`, numeric id, ATS hosts), **or** an explicit location / department cell | `(m/w/d)`, `(h/f)`, `(all genders)`, `- Apply now` and bracketed ids stripped from the display title; the id becomes `external_id` (so it stays in the fingerprint). Bare known cities (`München`) count as a location cell |
60+| people | names that are role titles or generic labels (`Contact`, `Team`, `Board of Directors`, `Leadership`, `お問い合わせ`), digits, < 2 or > 5 tokens | name = 2–5 capitalised tokens (particles / initials allowed) or a short CJK name without role words | name ↔ title **swapped** when the card put the title first; link-label titles (`Contact`, `Read more`, `Bio`, `LinkedIn`, `プロフィール`) and bio sentences → `title=None`; `role_category` / `is_executive` recomputed after the swap (JA titles: 代表取締役社長 → ceo, 社外取締役 → board …) |
61+| locations | cookie / consent / nav vocabulary (`performance`, `analytics`, `marketing`, `necessary`, `preferences`, `cookies`, `contact us`, `careers`, `privacy`, `newsletter`, `datenschutz`, `voorkeuren` …), names > 80 chars or > 8 words, sentences | at least one of: ISO country, known city (≈ 400 major cities + exonyms, n-gram match), postal code (UK/NL/CA/numeric), street address (EN/FR/DE/NL/ES/IT patterns), explicit kind (`headquarters`, `factory`, `warehouse`, `lab`, `store` … or a kind word in the name) | `city` cleared unless it is a plausible city (`Level 12` is not); `Oman | Hormuz Grand Hotel` → name = venue, country kept; a known city parsed as region (`3089 JH Rotterdam`) is promoted to `city`; city-states keep `city` |
62+| pricing plans | names > 40 chars or > 5 words, terminal punctuation, `?`, `|`, eyebrows (`Most popular`, `New`), generic headings (`Plans`, `Pricing`, `Tarifs`, `Preise`), marketing verbs (`win`, `get`, `discover`, `unlock`, `découvrez`, `entdecken` …), plans without a parsed price / `contact_sales` / free tier | — | `price_text` = the price phrase only (`$13/day`, `Starting at €333 / month`, `US$ 1,199 per user / month`, `Talk to sales`, `Free`) — never a cut sentence; the plan name is the first valid label before the price line (eyebrows skipped); a tier literally called `Free` is a valid name |
63+| products | nav words (`Overview`, `Products`, `Solutions`, `All products`, `Learn more`, `Produits`, `製品一覧`), > 80 chars / > 10 words, sentences and slogans | — | `®` / `™` kept |
64+| news | pagination (`Next`, `Older posts »`, `Page 2`, `3`), category labels (`Press releases`, `Blog posts`, `Actualités`, `ニュース`), `Read more` | ≥ 3 words or ≥ 15 chars **unless** `published_at` or a `/YYYY/MM/` URL confirms an article | the title is the first non-date line of the card |
65+
66+Normalizer changes made for these rules (`sdk/normalize.py`): a `.job-list`-style wrapper whose only child is a linked / card-like
67+`<ul>` or `<table>` is a container (its rows become `job_listing` blocks, inheriting the wrapper's kind) while a feature `<ul>` inside a
68+pricing card is not; card text has one line per direct child so inline `<a>title</a><span>city</span>` cells do not merge. Stripping
69+gender markers changes the display title (and therefore the fingerprint) of affected HTML jobs once — expect a one-off
70+removed + added pair for `(m/w/d)`-style listings on the first crawl after deployment.
71+
72+Fixtures reproducing the production noise: `generic_html/careers_cta_noise.html` (NL careers page: CTA anchors, single word, ellipsis
73++ 5 real listings incl. `(m/v/x)`, `[12933]`, `- Apply now`), `careers_de.html` (German `(m/w/d)` listings + `Alle Stellen` / `Mehr
74+erfahren`), `leadership_swapped.html` (title-first card, `Contact` link, `Chair Emeritus | Warner Bros. Discovery`, `Board of Directors`),
75+`leadership_ja.html` (Japanese officers page), `locations_cookie_noise.html` (consent categories inside a locations page, `Contact Us | KR`,
76+`Oman | Hormuz Grand Hotel`, Dutch address), `pricing_marketing_noise.html` (marketing hero + `Talk to sales`, `Worry-free roaming.` +
77+prose price, eyebrow, real Free / Starter / Team / Business / Enterprise), `products_news_noise.html`. Tests: `tests/test_extraction_precision.py`
78+(noise gone, every real item still extracted). Audit / purge of stored rows: `scripts/audit_extractions.py` — samples HTML-sourced rows per
79+table, prints a precision estimate (Wilson 95 % interval) and the rejections by reason; `--all-sources` widens the read-only audit to
80+enrichment rows (Wikidata people); `--purge` deletes failing **HTML-sourced** rows of people / products / pricing_plans / locations / jobs only
81+(never ATS jobs, enrichment rows or news). Run it before and after deploying to measure the effect on production.
82+
47 ## Adding a connector family 83 ## Adding a connector family
48 84
49 1. Create `src/companyatlas/connectors/<family>.py`; subclass `Connector` (or `AtsConnector` for JSON/XML boards — implement 85 1. Create `src/companyatlas/connectors/<family>.py`; subclass `Connector` (or `AtsConnector` for JSON/XML boards — implement
added fixtures/connectors/generic_html/careers_cta_noise.html +30 −0
@@ -0,0 +1,30 @@
1+<!doctype html>
2+<html lang="nl">
3+<head><title>Jobs bij Telenet group</title></head>
4+<body>
5+<nav class="navbar"><a href="/">Home</a><a href="/nl/jobs">Jobs</a><a href="/nl/over-ons">Over ons</a></nav>
6+<main>
7+ <section class="hero"><h1>Werken bij Telenet group</h1><p>Ontdek onze openstaande vacatures.</p></section>
8+ <section class="job-teasers">
9+ <h2>Ontdek</h2>
10+ <div class="job-card"><a href="/nl/jobs/starters"><h3>Voor starters die willen gáán</h3></a><p>Start je carrière bij ons.</p></div>
11+ <div class="job-card"><a href="/nl/jobs/early-careers"><h3>Early careers bij Telenet group</h3></a><p>Stages en graduate programma's.</p></div>
12+ <div class="job-card"><a href="/nl/jobs/why-us"><h3>Waarom werken bij ons?</h3></a><a href="/nl/jobs/why-us">Meer info</a></div>
13+ <div class="job-card"><a href="/nl/jobs/search"><h3>Alle vacatures</h3></a><a href="/nl/jobs/search">Bekijk alle vacatures →</a></div>
14+ </section>
15+ <section class="job-list">
16+ <h2>Openstaande vacatures</h2>
17+ <ul>
18+ <li class="job-item"><a href="/nl/jobs/12841-senior-backend-software-engineer">Senior Backend Software Engineer - Infrastructure</a><span class="location">London, United Kingdom</span><span class="dept">Engineering</span></li>
19+ <li class="job-item"><a href="/nl/jobs/12902-verpleegkundige">Verpleegkundige spoedgevallen (m/v/x)</a><span class="location">Gent, België</span><span class="dept">Zorg</span></li>
20+ <li class="job-item"><a href="/nl/jobs/12933-data-analyst">Data Analyst (m/w/d) [12933]</a><span class="location">Berlin, Deutschland</span><span class="dept">Data</span></li>
21+ <li class="job-item"><a href="/nl/jobs/12950-accountmanager">Accountmanager KMO - Apply now</a><span class="location">Mechelen, België</span><span class="dept">Sales</span></li>
22+ <li class="job-item"><a href="/nl/jobs/12977-technicien">Technicien réseau (h/f)</a><span class="location">Bruxelles, Belgique</span><span class="dept">Network</span></li>
23+ <li class="job-item"><a href="/nl/jobs/12990-sales">Sales</a><span class="location"></span></li>
24+ <li class="job-item"><a href="/nl/jobs/search?page=2">Meer info</a><span class="location"></span></li>
25+ <li class="job-item"><a href="/nl/jobs/13001">Wij zoeken mensen die het verschil willen maken voor onze klanten…</a><span class="location">Mechelen</span></li>
26+ </ul>
27+ </section>
28+</main>
29+<footer><p>© 2026 Telenet group</p></footer>
30+</body></html>
added fixtures/connectors/generic_html/careers_de.html +26 −0
@@ -0,0 +1,26 @@
1+<!doctype html>
2+<html lang="de">
3+<head><title>Karriere bei Muster AG</title></head>
4+<body>
5+<nav class="navbar"><a href="/">Start</a><a href="/karriere">Karriere</a><a href="/unternehmen">Unternehmen</a></nav>
6+<main>
7+ <section class="hero"><h1>Karriere bei Muster AG</h1><p>Gestalten Sie mit uns die Zukunft.</p></section>
8+ <section class="job-teasers">
9+ <div class="job-card"><a href="/karriere/stellen"><h3>Alle Stellen</h3></a><a href="/karriere/stellen">Mehr erfahren</a></div>
10+ <div class="job-card"><a href="/karriere/warum-wir"><h3>Warum Muster AG?</h3></a><a href="/karriere/warum-wir">Mehr erfahren →</a></div>
11+ <div class="job-card"><a href="/karriere/bewerbung"><h3>Jetzt bewerben</h3></a></div>
12+ </section>
13+ <section class="job-list">
14+ <h2>Offene Stellen</h2>
15+ <ul>
16+ <li class="job-item"><a href="/karriere/stellen/4471-softwareentwickler">Softwareentwickler Backend (m/w/d)</a><span>München</span><span>IT</span></li>
17+ <li class="job-item"><a href="/karriere/stellen/4488-projektleiter">Projektleiter Anlagenbau (m/w/d)</a><span>Stuttgart</span><span>Engineering</span></li>
18+ <li class="job-item"><a href="/karriere/stellen/4490-ausbildung">Ausbildung zum Industriemechaniker (m/w/d) 2027</a><span>Hamburg</span><span>Ausbildung</span></li>
19+ <li class="job-item"><a href="/karriere/stellen/4501-werkstudent">Werkstudent Marketing (all genders)</a><span>Berlin</span><span>Marketing</span></li>
20+ <li class="job-item"><a href="/karriere/stellen/4510-pflege">Pflegefachkraft Intensivstation (w/m/d)</a><span>Köln</span><span>Pflege</span></li>
21+ <li class="job-item"><a href="/karriere/stellen">Weiterlesen</a><span></span></li>
22+ </ul>
23+ </section>
24+</main>
25+<footer><p>© 2026 Muster AG</p></footer>
26+</body></html>
added fixtures/connectors/generic_html/leadership_ja.html +19 −0
@@ -0,0 +1,19 @@
1+<!doctype html>
2+<html lang="ja">
3+<head><title>役員紹介 | 株式会社サンプル</title></head>
4+<body>
5+<nav class="navbar"><a href="/">ホーム</a><a href="/company/officers">役員紹介</a><a href="/contact">お問い合わせ</a></nav>
6+<main id="main">
7+ <h1>役員紹介</h1>
8+ <section class="team-grid">
9+ <h2>取締役</h2>
10+ <div class="team-member"><h3>山田 太郎</h3><p class="role">代表取締役社長</p></div>
11+ <div class="team-member"><h3>佐藤 花子</h3><p class="role">取締役 最高財務責任者(CFO)</p></div>
12+ <div class="team-member"><h3>鈴木 一郎</h3><p class="role">社外取締役</p></div>
13+ <div class="team-member"><h3>高橋 美咲</h3><p class="role">執行役員 技術本部長</p><a href="/company/officers/takahashi">プロフィール</a></div>
14+ <div class="team-member"><h3>お問い合わせ</h3><p class="role">詳細を見る</p></div>
15+ <div class="team-member"><h3>株式会社サンプル</h3><p class="role">会社概要</p></div>
16+ </section>
17+</main>
18+<footer><p>© 2026 株式会社サンプル</p></footer>
19+</body></html>
added fixtures/connectors/generic_html/leadership_swapped.html +22 −0
@@ -0,0 +1,22 @@
1+<!doctype html>
2+<html lang="en">
3+<head><title>Leadership | Warner Example</title></head>
4+<body>
5+<nav class="navbar"><a href="/">Home</a><a href="/leadership">Leadership</a><a href="/contact">Contact</a></nav>
6+<main id="main">
7+ <h1>Our leadership</h1>
8+ <section class="team-grid">
9+ <h2>Executive team</h2>
10+ <div class="team-member"><p class="role">Chief Executive Officer</p><h3>Jane Doe</h3><p>Jane leads the company since 2019.</p></div>
11+ <div class="team-member"><h3>Brian Jacobson</h3><p class="role">Chief Financial Officer</p><a href="/contact/brian">Contact</a></div>
12+ <div class="team-member"><h3>Priya Natarajan</h3><a href="/contact/priya">Contact</a></div>
13+ <div class="team-member"><h3>Chair Emeritus</h3><p class="role">Warner Bros. Discovery</p></div>
14+ <div class="team-member"><h3>Board of Directors</h3><p class="role">Meet the people who govern the company</p></div>
15+ <div class="team-member"><h3>Leadership</h3><p class="role">Read more</p></div>
16+ <div class="team-member"><h3>Contact</h3><p class="role">Media relations</p></div>
17+ <div class="team-member"><h3>Marc van der Berg</h3><p class="role">Chief Operating Officer</p><a href="/bio/marc">Read bio</a></div>
18+ <div class="team-member"><p class="role">Chair of the Board</p><h3>Samuel Adebayo</h3></div>
19+ </section>
20+</main>
21+<footer><p>© 2026 Warner Example</p></footer>
22+</body></html>
added fixtures/connectors/generic_html/locations_cookie_noise.html +30 −0
@@ -0,0 +1,30 @@
1+<!doctype html>
2+<html lang="en">
3+<head><title>Our locations — Hormuz Example Group</title></head>
4+<body>
5+<header><nav><a href="/">Home</a><a href="/locations">Locations</a><a href="/contact-us">Contact Us</a></nav></header>
6+<div class="preferences-modal" role="dialog">
7+ <h2>Manage your preferences</h2>
8+ <table class="preferences-table">
9+ <tr class="preference location"><td>Strictly Necessary</td><td>These are required for the website to function and cannot be switched off.</td></tr>
10+ <tr class="preference location"><td>Performance &amp; Analytics</td><td>Allows use of behavioural data to optimise performance. Third-party providers store aggregated data.</td></tr>
11+ <tr class="preference location"><td>Functional</td><td>Enables enhanced functionality such as videos and live chat. Providers may store your settings.</td></tr>
12+ <tr class="preference location"><td>Marketing</td><td>Used by advertising partners to build a profile of your interests and show relevant ads on other sites. Data is stored by the store.</td></tr>
13+ </table>
14+</div>
15+<main>
16+ <h1>Where to find us</h1>
17+ <section class="offices">
18+ <h2>Offices</h2>
19+ <div class="office-card"><h3>Oman</h3><p>Hormuz Grand Hotel</p><p>Oman</p></div>
20+ <div class="office-card"><h3>Contact Us</h3><p>Contact Us</p><p>Seoul, KR</p></div>
21+ <div class="office-card"><h3>Dubai Office</h3><p>Level 12, Emirates Towers<br>Dubai, United Arab Emirates</p></div>
22+ <div class="office-card"><h3>London</h3><p>1 Finsbury Avenue, London EC2M 2PF, United Kingdom</p></div>
23+ <div class="office-card"><h3>Singapore</h3><p>Singapore</p></div>
24+ <div class="office-card"><h3>Rotterdam Warehouse</h3><p>Waalhaven Zuidzijde 21, 3089 JH Rotterdam, Nederland</p></div>
25+ <div class="office-card"><h3>Careers</h3><p>Join our team in one of our offices around the world.</p><p>Seoul, KR</p></div>
26+ <div class="office-card"><h3>Sign up for our newsletter</h3><p>Receive updates from our offices around the world.</p><p>Paris, France</p></div>
27+ </section>
28+</main>
29+<footer><p>© 2026 Hormuz Example Group</p></footer>
30+</body></html>
added fixtures/connectors/generic_html/pricing_marketing_noise.html +56 −0
@@ -0,0 +1,56 @@
1+<!doctype html>
2+<html lang="en">
3+<head><title>Pricing — Similar Example</title></head>
4+<body>
5+<header class="site-header"><nav><a href="/">Home</a><a href="/pricing">Pricing</a></nav></header>
6+<main>
7+ <section class="hero pricing-card">
8+ <h1>Win your market with Similar Example for businesses</h1>
9+ <p>Get the insights you need to outperform the competition.</p>
10+ <a href="/contact-sales">Talk to sales</a>
11+ </section>
12+ <section class="pricing-grid">
13+ <h2>Plans</h2>
14+ <div class="pricing-card">
15+ <h3>Free</h3>
16+ <p class="price">$0 forever</p>
17+ <ul><li>1 user</li><li>Basic reports</li></ul>
18+ </div>
19+ <div class="pricing-card">
20+ <h3>Starter</h3>
21+ <p class="price">$125 per month, billed annually</p>
22+ <ul><li>1 user</li><li>3 months of data</li></ul>
23+ <a href="/signup?plan=starter">Start now</a>
24+ </div>
25+ <div class="pricing-card">
26+ <h3>Team</h3>
27+ <p class="price">Starting at €333 / month</p>
28+ <ul><li>3 users</li><li>15 months of data</li></ul>
29+ </div>
30+ <div class="pricing-card">
31+ <p class="eyebrow">Most popular</p>
32+ <h3>Business</h3>
33+ <p class="price">US$ 1,199 per user / month</p>
34+ <ul><li>5 users</li><li>Custom data</li></ul>
35+ </div>
36+ <div class="pricing-card">
37+ <h3>Enterprise</h3>
38+ <p class="price">Talk to sales</p>
39+ <ul><li>Unlimited users</li><li>Dedicated success manager</li></ul>
40+ </div>
41+ <div class="pricing-card">
42+ <h3>Worry-free roaming.</h3>
43+ <p>Stay connected in the U.S. ($13/day) and over 200 international destinations with our roaming add-on for travellers.</p>
44+ </div>
45+ <div class="pricing-card">
46+ <h3>Unlock the full potential of your data</h3>
47+ <p class="price">$49 per month</p>
48+ </div>
49+ <div class="pricing-card">
50+ <h3>Add-on: extra seats</h3>
51+ <p>Bring the whole team along, more information on request.</p>
52+ </div>
53+ </section>
54+</main>
55+<footer class="site-footer"><p>© 2026 Similar Example</p></footer>
56+</body></html>
added fixtures/connectors/generic_html/products_news_noise.html +29 −0
@@ -0,0 +1,29 @@
1+<!doctype html>
2+<html lang="en">
3+<head><title>Products &amp; News — Acme Example</title></head>
4+<body>
5+<nav class="navbar"><a href="/">Home</a><a href="/products">Products</a><a href="/news">News</a></nav>
6+<main>
7+ <section class="products">
8+ <h2>Products</h2>
9+ <div class="product-card"><h3>Overview</h3><p>Everything we make, in one place.</p><a href="/products">All products</a></div>
10+ <div class="product-card"><h3>Atlas Metrics™</h3><p>High-cardinality metrics at any scale.</p><a href="/products/metrics">Learn more</a></div>
11+ <div class="product-card"><h3>Atlas Logs®</h3><p>Search petabytes in seconds.</p><a href="/products/logs">Learn more</a></div>
12+ <div class="product-card"><h3>Discover how Atlas helps teams ship faster.</h3><p>Slogan card</p><a href="/why">Learn more</a></div>
13+ <div class="product-card"><h3>Learn more</h3><a href="/products/more">Learn more</a></div>
14+ <div class="product-card"><h3>Solutions</h3><a href="/solutions">Solutions</a></div>
15+ <div class="product-card"><h3>Atlas Traces</h3><p>Distributed tracing without sampling.</p><a href="/products/traces">Learn more</a></div>
16+ </section>
17+ <section class="news-list">
18+ <h2>Latest news</h2>
19+ <article class="news-item"><time datetime="2026-09-10">September 10, 2026</time><h3><a href="/news/2026/09/acme-launches-atlas-ai">Acme launches Atlas AI, an assistant for cloud operations</a></h3></article>
20+ <article class="news-item"><time datetime="2026-08-28">August 28, 2026</time><h3><a href="/news/2026/08/q2-results">Q2 results</a></h3></article>
21+ <article class="news-item"><h3><a href="/news/press-releases">Press releases</a></h3></article>
22+ <article class="news-item"><h3><a href="/news/category/blog">Blog posts</a></h3></article>
23+ <article class="news-item"><h3><a href="/news/2026/07/acme-partners-with-bigco">Acme and BigCo announce strategic partnership</a></h3></article>
24+ <article class="news-item"><h3><a href="/news?page=2">Older posts »</a></h3></article>
25+ <article class="news-item"><h3><a href="/news/all">Read more news</a></h3></article>
26+ </section>
27+</main>
28+<footer><p>© 2026 Acme Example</p></footer>
29+</body></html>
added scripts/audit_extractions.py +242 −0
@@ -0,0 +1,242 @@
1+"""Audit stored typed entities against the precision rules (`connectors/_precision`) — read-only by default.
2+
3+For each table it samples N rows (seeded, reproducible), applies the same validators the connector and the pipeline now apply, and
4+prints a precision estimate (share of rows the rules accept, with a 95 % interval) plus the rows that would now be rejected, grouped
5+by reason. Run it before and after a deploy to measure the effect on production.
6+
7+ DATABASE_URL=… .venv/bin/python scripts/audit_extractions.py # sample 200 rows per table
8+ .venv/bin/python scripts/audit_extractions.py --sample 500 --table people,locations --show 40
9+ .venv/bin/python scripts/audit_extractions.py --purge # delete rows failing the rules (see below)
10+
11+Scope: by default only rows attached to a `generic-html-v1` sensor are audited (the rows the rules were written for); `--all-sources`
12+widens the read-only audit to enrichment-sourced rows (e.g. Wikidata people) as a recall check on the validators.
13+`--purge` scans the *whole* table (not a sample) and deletes HTML-sourced rows that fail the validators, for `people`, `products`,
14+`pricing_plans`, `locations` and `jobs` (`sensors.connector_id = 'generic-html-v1'`); it never touches ATS jobs, enrichment rows or `news_items`.
15+People whose only defect is a stoplisted title ("Contact", "Read more") are kept with `title = null`. Rows the rules would *rename*
16+(swapped person cards, country-as-name locations) are reported, not rewritten: the next crawl re-extracts them correctly and the
17+reconciliation marks the old row `no_longer_listed`. Events derived from purged rows are history and stay untouched.
18+"""
19+from __future__ import annotations
20+
21+import argparse
22+import asyncio
23+import math
24+from collections import Counter, defaultdict
25+from collections.abc import Callable, Sequence
26+from dataclasses import dataclass, field
27+from typing import Any
28+
29+from companyatlas.connectors import _precision as P
30+from companyatlas.db import dispose, execute, fetch_all, transaction
31+from companyatlas.sdk.models import ExtractedLocation, ExtractedPlan
32+
33+DEFAULT_SAMPLE = 200
34+DEFAULT_SHOW = 25
35+DEFAULT_SEED = 0.42
36+Z_95 = 1.96
37+LABEL_WIDTH = 70
38+HTML_CONNECTOR = "generic-html-v1"
39+PURGE_BATCH = 500
40+
41+
42+@dataclass(slots=True)
43+class Outcome:
44+ """What the rules say about one stored row."""
45+ status: str # accept | reject | normalise
46+ reason: str | None = None
47+ fix: str | None = None # normalise: what would change (for the report) — "title → null" is applied under --purge
48+
49+
50+@dataclass(slots=True)
51+class TableSpec:
52+ name: str
53+ label_col: str
54+ select_sql: str # must select id + the columns `judge` reads; `{where}` and `{limit}` are filled in
55+ judge: Callable[[dict[str, Any]], Outcome]
56+ purgeable: bool
57+ label: Callable[[dict[str, Any]], str]
58+ summary_cols: Sequence[str] = field(default_factory=tuple)
59+
60+
61+# ------------------------------------------------------------------------------------------------------------ judges (one per table)
62+
63+
64+def _judge_person(row: dict[str, Any]) -> Outcome:
65+ fixed = P.normalize_person(row["name"] or "", row.get("title"))
66+ if fixed is None:
67+ return Outcome("reject", P.person_verdict(row["name"] or "", row.get("title")).reason)
68+ name, title = fixed
69+ if name != (row["name"] or "").strip():
70+ return Outcome("normalise", "name/title swapped", fix=f"name → {name!r}, title → {title!r}")
71+ if (title or None) != (row.get("title") or None):
72+ return Outcome("normalise", "title is a link label", fix="title → null")
73+ return Outcome("accept")
74+
75+
76+def _judge_product(row: dict[str, Any]) -> Outcome:
77+ v = P.product_verdict(row["name"] or "")
78+ return Outcome("accept") if v.ok else Outcome("reject", v.reason)
79+
80+
81+def _judge_plan(row: dict[str, Any]) -> Outcome:
82+ plan = ExtractedPlan(plan_name=row["plan_name"] or "", price=float(row["price"]) if row.get("price") is not None else None,
83+ price_text=row.get("price_text"), contact_sales=bool(row.get("contact_sales")))
84+ v = P.plan_verdict(plan)
85+ return Outcome("accept") if v.ok else Outcome("reject", v.reason)
86+
87+
88+def _judge_location(row: dict[str, Any]) -> Outcome:
89+ loc = ExtractedLocation(name=row["name"] or "", kind=row.get("kind") or "office", city=row.get("city"), region=row.get("region"),
90+ country=row.get("country"), address_text=None)
91+ v = P.location_verdict(loc)
92+ if not v.ok:
93+ return Outcome("reject", v.reason)
94+ fixed = P.normalize_location(loc)
95+ if fixed is not None and fixed.name != loc.name:
96+ return Outcome("normalise", "country used as the name", fix=f"name → {fixed.name!r}, city → {fixed.city!r}")
97+ if fixed is not None and fixed.city != loc.city:
98+ return Outcome("normalise", "city cell is not a city", fix=f"city → {fixed.city!r}")
99+ return Outcome("accept")
100+
101+
102+def _judge_job(row: dict[str, Any]) -> Outcome:
103+ v = P.job_verdict(row["title"] or "", url=row.get("url"), location=row.get("location_text"), department=row.get("department"))
104+ if not v.ok:
105+ return Outcome("reject", v.reason)
106+ clean, _ = P.clean_job_title(row["title"] or "")
107+ if clean and clean != (row["title"] or "").strip():
108+ return Outcome("normalise", "display title carries a marker / id", fix=f"title → {clean!r}")
109+ return Outcome("accept")
110+
111+
112+def _judge_news(row: dict[str, Any]) -> Outcome:
113+ v = P.news_verdict(row["title"] or "", url=row.get("url"), published_at=row.get("published_at"))
114+ return Outcome("accept") if v.ok else Outcome("reject", v.reason)
115+
116+
117+def _lbl(*parts: Any) -> str:
118+ return " | ".join(str(p) for p in parts if p not in (None, ""))
119+
120+
121+# Every select joins `sensors` so rows can be scoped to the HTML connector (`{where}`): enrichment-sourced rows (Wikidata people…)
122+# have no HTML sensor and are only ever *audited* with --all-sources, never purged.
123+TABLES: dict[str, TableSpec] = {
124+ "people": TableSpec("people", "name", "select t.id, t.name, t.title from people t left join sensors s on s.id = t.sensor_id {where} {limit}",
125+ _judge_person, True, lambda r: _lbl(r["name"], r.get("title"))),
126+ "products": TableSpec("products", "name", "select t.id, t.name from products t left join sensors s on s.id = t.sensor_id {where} {limit}",
127+ _judge_product, True, lambda r: _lbl(r["name"])),
128+ "pricing_plans": TableSpec("pricing_plans", "plan_name",
129+ "select t.id, t.plan_name, t.price, t.price_text, t.contact_sales from pricing_plans t left join sensors s on s.id = t.sensor_id "
130+ "{where} {limit}", _judge_plan, True, lambda r: _lbl(r["plan_name"], r.get("price_text"))),
131+ "locations": TableSpec("locations", "name",
132+ "select t.id, t.name, t.kind, t.city, t.region, t.country from locations t left join sensors s on s.id = t.sensor_id {where} {limit}",
133+ _judge_location, True, lambda r: _lbl(r["name"], r.get("city"), r.get("country"), r.get("kind"))),
134+ "jobs": TableSpec("jobs", "title",
135+ "select t.id, t.title, t.url, t.location_text, t.department from jobs t left join sensors s on s.id = t.sensor_id {where} {limit}",
136+ _judge_job, True, lambda r: _lbl(r["title"], r.get("location_text"))),
137+ "news_items": TableSpec("news_items", "title",
138+ "select t.id, t.title, t.url, t.published_at from news_items t left join sensors s on s.id = t.sensor_id {where} {limit}",
139+ _judge_news, False, lambda r: _lbl(r["title"], r.get("url"))),
140+}
141+PURGEABLE = [t for t, spec in TABLES.items() if spec.purgeable]
142+HTML_ONLY_WHERE = f"where s.connector_id = '{HTML_CONNECTOR}'"
143+
144+
145+# ------------------------------------------------------------------------------------------------------------ reporting
146+
147+
148+def _interval(k: int, n: int) -> tuple[float, float]:
149+ """Wilson 95 % interval for a proportion k/n."""
150+ if n == 0:
151+ return 0.0, 0.0
152+ p = k / n
153+ denom = 1 + Z_95 ** 2 / n
154+ centre = (p + Z_95 ** 2 / (2 * n)) / denom
155+ half = Z_95 * math.sqrt(p * (1 - p) / n + Z_95 ** 2 / (4 * n * n)) / denom
156+ return max(0.0, centre - half), min(1.0, centre + half)
157+
158+
159+def _sql(spec: TableSpec, *, sample: int | None, html_only: bool) -> str:
160+ where = HTML_ONLY_WHERE if (html_only or spec.name == "jobs") else "" # jobs: ATS boards are always out of scope
161+ limit = f"order by random() limit {int(sample)}" if sample else ""
162+ return spec.select_sql.format(where=where, limit=limit)
163+
164+
165+async def _audit_table(conn: Any, spec: TableSpec, *, sample: int | None, show: int, html_only: bool) -> tuple[list[str], list[str]]:
166+ rows = await fetch_all(conn, _sql(spec, sample=sample, html_only=html_only))
167+ outcomes = [(r, spec.judge(r)) for r in rows]
168+ accepted = sum(1 for _, o in outcomes if o.status == "accept")
169+ normalised = [(r, o) for r, o in outcomes if o.status == "normalise"]
170+ rejected = [(r, o) for r, o in outcomes if o.status == "reject"]
171+ n = len(rows)
172+ lo, hi = _interval(accepted + len(normalised), n)
173+ print(f"\n== {spec.name}: sampled {n} · accepted {accepted} · normalised {len(normalised)} · rejected {len(rejected)}")
174+ if n:
175+ print(f" precision estimate (accepted + normalisable) = {(accepted + len(normalised)) / n:.1%} [{lo:.1%}, {hi:.1%}]"
176+ f" rejected share = {len(rejected) / n:.1%}")
177+ reasons: Counter[str] = Counter(o.reason or "?" for _, o in rejected)
178+ by_reason: dict[str, list[str]] = defaultdict(list)
179+ for r, o in rejected:
180+ by_reason[o.reason or "?"].append(spec.label(r))
181+ for reason, count in reasons.most_common():
182+ print(f" - {reason}: {count}")
183+ for lbl in by_reason[reason][:show]:
184+ print(f" ✗ {lbl[:LABEL_WIDTH]}")
185+ if normalised:
186+ print(f" ~ would be normalised ({len(normalised)}):")
187+ for r, o in normalised[:show]:
188+ print(f" ~ {spec.label(r)[:LABEL_WIDTH]} → {o.fix}")
189+ return [r["id"] for r, _ in rejected], [r["id"] for r, o in normalised if o.fix == "title → null"]
190+
191+
192+async def _purge(conn: Any, spec: TableSpec, reject_ids: list[str], null_title_ids: list[str]) -> None:
193+ for i in range(0, len(reject_ids), PURGE_BATCH):
194+ await execute(conn, f"delete from {spec.name} where id = any(cast(:ids as text[]))", ids=reject_ids[i:i + PURGE_BATCH])
195+ if spec.name == "people":
196+ for i in range(0, len(null_title_ids), PURGE_BATCH):
197+ await execute(conn, "update people set title = null where id = any(cast(:ids as text[]))", ids=null_title_ids[i:i + PURGE_BATCH])
198+ print(f" purged {len(reject_ids)} row(s) from {spec.name}" + (f", cleared {len(null_title_ids)} title(s)" if spec.name == "people" else ""))
199+
200+
201+async def main(args: argparse.Namespace) -> None:
202+ names = [t.strip() for t in args.table.split(",")] if args.table else list(TABLES)
203+ unknown = [t for t in names if t not in TABLES]
204+ if unknown:
205+ raise SystemExit(f"unknown table(s): {unknown}; choose from {list(TABLES)}")
206+ if args.purge:
207+ names = [t for t in names if TABLES[t].purgeable]
208+ print(f"PURGE mode: full scan of {names}; rows failing the precision rules will be deleted (news and ATS jobs are never touched)")
209+ sample = None if args.purge else args.sample
210+ html_only = args.purge or not args.all_sources # a purge is always scoped to HTML-sourced rows
211+ print(f"precision rules {P.PRECISION_VERSION} · sample={'all' if sample is None else sample} · seed={args.seed} · "
212+ f"scope={'HTML-sourced rows (generic-html-v1)' if html_only else 'all sources'}")
213+ async with transaction() as conn:
214+ await execute(conn, "select setseed(:s)", s=args.seed)
215+ for name in names:
216+ spec = TABLES[name]
217+ reject_ids, null_title_ids = await _audit_table(conn, spec, sample=sample, show=args.show, html_only=html_only)
218+ if args.purge:
219+ await _purge(conn, spec, reject_ids, null_title_ids)
220+ print("\ndone" if args.purge else "\nread-only audit done (pass --purge to delete the rejected rows of the purgeable tables)")
221+
222+
223+def _parse() -> argparse.Namespace:
224+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
225+ ap.add_argument("--sample", type=int, default=DEFAULT_SAMPLE, help=f"rows sampled per table (default {DEFAULT_SAMPLE})")
226+ ap.add_argument("--table", type=str, default="", help=f"comma-separated subset of {list(TABLES)}")
227+ ap.add_argument("--show", type=int, default=DEFAULT_SHOW, help="examples printed per reason")
228+ ap.add_argument("--seed", type=float, default=DEFAULT_SEED, help="Postgres setseed() value in [-1, 1] for reproducible samples")
229+ ap.add_argument("--purge", action="store_true", help=f"delete rows failing the rules in {PURGEABLE} (full scan, not a sample, HTML-sourced only)")
230+ ap.add_argument("--all-sources", action="store_true", help="read-only audit over every source (enrichment rows included); ignored with --purge")
231+ return ap.parse_args()
232+
233+
234+async def _run() -> None:
235+ try:
236+ await main(_parse())
237+ finally:
238+ await dispose()
239+
240+
241+if __name__ == "__main__":
242+ asyncio.run(_run())
added src/companyatlas/connectors/_precision.py +764 −0
@@ -0,0 +1,764 @@
1+"""Precision rules for typed extractions (spec §107, "never fabricate"): deterministic validators and normalisers that reject the
2+navigation / call-to-action / cookie-consent / marketing noise the generic HTML connector picks up on real corporate pages, and
3+repair the classic confusions (person name ↔ title swapped, country used as a location name, truncated price text).
4+
5+Shared by `connectors/generic_html.py` (applied while extracting) and `services/pipeline._drop_corrupt_entities` (last line of
6+defence for every connector) and by `scripts/audit_extractions.py` (measurement / purge on stored rows). Every rule is a small pure
7+function returning a `Verdict`, so the same code explains *why* a row is rejected. Multilingual where cheap (EN/FR/DE/NL/ES/IT/PT/JA).
8+No network, no LLM.
9+"""
10+from __future__ import annotations
11+
12+import re
13+import unicodedata
14+from collections.abc import Iterable
15+from dataclasses import dataclass
16+from datetime import datetime
17+from typing import Any
18+
19+from companyatlas.connectors._util import POSTAL_RE, country_code, date_from_url, parse_location
20+from companyatlas.sdk.models import (
21+ ExtractedJob,
22+ ExtractedLocation,
23+ ExtractedNewsItem,
24+ ExtractedPerson,
25+ ExtractedPlan,
26+ ExtractedProduct,
27+ Extraction,
28+)
29+from companyatlas.sdk.normalize import normalize_whitespace
30+
31+PRECISION_VERSION = "precision-v1"
32+
33+# ------------------------------------------------------------------------------------------------------------ limits (no magic numbers)
34+
35+MAX_JOB_TITLE_CHARS = 140
36+MIN_JOB_TITLE_CHARS = 4
37+MIN_JOB_TITLE_WORDS = 2
38+PROSE_MIN_WORDS = 4 # a "title" with ≥ 4 words and function words reads like a sentence
39+PERSON_NAME_MIN_TOKENS, PERSON_NAME_MAX_TOKENS = 2, 5
40+MIN_PERSON_NAME_CHARS, MAX_PERSON_NAME_CHARS = 4, 60
41+MAX_PERSON_TITLE_CHARS = 100
42+SENTENCE_TITLE_MIN_WORDS = 6 # a person "title" of ≥ 6 words ending with a full stop is a bio sentence
43+MAX_LOCATION_NAME_CHARS = 80
44+MAX_LOCATION_NAME_WORDS = 8
45+MAX_CITY_CHARS, MAX_CITY_WORDS = 40, 4
46+VENUE_MIN_WORDS = 3 # "Hormuz Grand Hotel": ≥ 3 words in the city slot is a venue, not a city
47+MAX_PLAN_NAME_CHARS, MAX_PLAN_NAME_WORDS = 40, 5
48+MAX_PRICE_TEXT_CHARS = 60
49+MAX_PRODUCT_NAME_CHARS, MAX_PRODUCT_NAME_WORDS = 80, 10
50+NEWS_MIN_WORDS, NEWS_MIN_CHARS = 3, 15
51+NEWS_MIN_CJK_CHARS = 6 # CJK titles have no word boundaries: 6 characters already carry a headline
52+MAX_DEPARTMENT_CHARS, MAX_DEPARTMENT_WORDS = 40, 4
53+CITY_NGRAM_MAX = 3
54+YEAR_RE = re.compile(r"^(?:19|20)\d{2}$")
55+
56+
57+@dataclass(slots=True, frozen=True)
58+class Verdict:
59+ ok: bool
60+ reason: str | None = None
61+
62+ @staticmethod
63+ def accept() -> Verdict:
64+ return Verdict(True, None)
65+
66+ @staticmethod
67+ def reject(reason: str) -> Verdict:
68+ return Verdict(False, reason)
69+
70+
71+# ------------------------------------------------------------------------------------------------------------ text helpers
72+
73+_WORD_RE = re.compile(r"[^\W_]+", re.UNICODE)
74+_CJK_RE = re.compile(r"[぀-ヿ㐀-鿿]")
75+TERMINAL_PUNCT = ".!?:;,"
76+TRAILING_MARKS_RE = re.compile(r"[\s→➔➡›»>\-–—|·•]+$") # arrows, chevrons, dashes, bullets
77+ELLIPSIS_RE = re.compile(r"(?:\.\.\.|…)\s*$")
78+PROSE_FUNCTION_WORDS_RE = re.compile(
79+ r"\b(?:who|that|which|want|wants|with|for|your|you|we|our|are|is|the|die|dat|willen|voor|met|jouw|onze|wij|zijn|qui|que|pour|avec|votre|nous|"
80+ r"sont|est|les|der|das|mit|für|und|ihre|wir|sind|para|con|nuestro|somos|per|che|nostro|siamo|com|nosso|somos)\b", re.IGNORECASE)
81+SENTENCE_VERB_RE = re.compile(
82+ r"\b(?:allows?|enables?|helps?|uses?|provides?|lets?|makes?|gives?|ensures?|collects?|stores?|can|will|would|should|permet|permettent|utilise|"
83+ r"erlaubt|ermöglicht|verwendet|gebruikt|permite|consente|permitem)\b", re.IGNORECASE)
84+MARKETING_VERB_RE = re.compile(
85+ r"^(?:win|get|start|discover|unlock|boost|grow|try|choose|compare|see|learn|find|build|join|save|upgrade|buy|sign up|request|book|schedule|"
86+ r"contact|talk|let'?s|ready|why|how|what|explore|meet|introducing|welcome|enjoy|make|take|achieve|transform|power|scale|drive|accelerate|"
87+ r"supercharge|level up|bring|stay|experience|gagnez|obtenez|découvrez|commencez|essayez|choisissez|comparez|rejoignez|débloquez|profitez|"
88+ r"gewinnen|holen|starten|entdecken|testen|wählen|vergleichen|jetzt|ontdek|start|kies|vergelijk|probeer|gana|obtén|empieza|descubre|prueba|"
89+ r"elige|compara|scopri|inizia|prova|scegli|confronta|ottieni|ganhe|obtenha|comece|descubra|experimente|escolha)\b", re.IGNORECASE)
90+
91+
92+def word_count(text: str) -> int:
93+ return len(_WORD_RE.findall(text))
94+
95+
96+def key_of(text: str | None) -> str:
97+ """Accent-insensitive, lower-case, single-spaced key (keeps CJK) used for stoplists and the city table."""
98+ if not text:
99+ return ""
100+ nfkd = unicodedata.normalize("NFKD", text)
101+ stripped = "".join(ch for ch in nfkd if not unicodedata.combining(ch))
102+ return " ".join(_WORD_RE.findall(stripped.lower()))
103+
104+
105+def strip_trailing_marks(text: str) -> str:
106+ return TRAILING_MARKS_RE.sub("", normalize_whitespace(text)).strip()
107+
108+
109+def has_terminal_punct(text: str) -> bool:
110+ t = text.rstrip("®™ ")
111+ return bool(t) and t[-1] in TERMINAL_PUNCT
112+
113+
114+def is_prose(text: str) -> bool:
115+ """A label that reads like a sentence: several words with function words, a sentence verb, or a full stop at the end."""
116+ words = word_count(text)
117+ if words < PROSE_MIN_WORDS:
118+ return False
119+ if text.rstrip().endswith((".", "!", "?")):
120+ return True
121+ return bool(SENTENCE_VERB_RE.search(text)) or len(PROSE_FUNCTION_WORDS_RE.findall(text)) >= 2
122+
123+
124+def is_slogan(text: str) -> bool:
125+ return bool(MARKETING_VERB_RE.match(text)) and word_count(text) >= PROSE_MIN_WORDS
126+
127+
128+def _phrase_re(phrases: Iterable[str]) -> re.Pattern[str]:
129+ alts = sorted({p.strip().lower() for p in phrases if p.strip()}, key=len, reverse=True)
130+ return re.compile(r"^(?:" + "|".join(re.escape(a) for a in alts) + r")$", re.IGNORECASE)
131+
132+
133+# ------------------------------------------------------------------------------------------------------------ role vocabulary (people)
134+
135+ROLE_RULES: list[tuple[str, re.Pattern[str]]] = [
136+ ("founder", re.compile(r"\b(co-?founder|founder|fondat(eur|rice)|gründer(in)?|fondator[ei]|fundador(a)?|oprichter|medeoprichter)\b", re.IGNORECASE)),
137+ ("ceo", re.compile(r"\b(chief executive( officer)?|ceo|pdg|président-directeur|geschäftsführer(in)?|managing director|directeur général|"
138+ r"algemeen directeur|consejero delegado|amministratore delegato|diretor executivo)\b", re.IGNORECASE)),
139+ ("cfo", re.compile(r"\b(chief financial( officer)?|cfo|finanzvorstand|directeur financier|financieel directeur|director financiero|direttore finanziario)\b", re.IGNORECASE)),
140+ ("cto", re.compile(r"\b(chief technology( officer)?|chief technical( officer)?|cto|directeur technique)\b", re.IGNORECASE)),
141+ ("coo", re.compile(r"\b(chief operating( officer)?|coo|directeur des opérations)\b", re.IGNORECASE)),
142+ ("chair", re.compile(r"\b(chair(man|woman|person)?|executive chair|présidente? du conseil|vorsitzende[rn]?|voorzitter|presidente del consejo|"
143+ r"presidente del consiglio)\b", re.IGNORECASE)),
144+ ("president", re.compile(r"\b(?<!vice )(?<!vice-)president(?! of)|président(?!e? du conseil)\b", re.IGNORECASE)),
145+ ("board", re.compile(r"\b(board member|member of the (supervisory |advisory )?board|non-executive director|independent director|"
146+ r"administrat(eur|rice)|aufsichtsrat|conseil d'administration|director(?= \(board)|trustee|bestuurslid|consejer[oa]|consigliere)\b", re.IGNORECASE)),
147+ ("vp", re.compile(r"\b(vice[- ]president|vp|evp|svp|avp|vice-président(e)?)\b", re.IGNORECASE)),
148+ ("head", re.compile(r"\b(head of|head,|general manager|gm|leiter(in)?|directeur|directrice|director|managing partner|partner|responsable|hoofd|"
149+ r"direttore|diretor(a)?|directora)\b", re.IGNORECASE)),
150+]
151+# Japanese titles have no word boundaries: plain substring rules, most specific first.
152+ROLE_RULES_JA: list[tuple[str, re.Pattern[str]]] = [
153+ ("founder", re.compile(r"創業者|共同創業者|ファウンダー")),
154+ ("cfo", re.compile(r"最高財務責任者|CFO")),
155+ ("cto", re.compile(r"最高技術責任者|CTO")),
156+ ("coo", re.compile(r"最高執行責任者|COO")),
157+ ("ceo", re.compile(r"代表取締役|最高経営責任者|社長|CEO")),
158+ ("chair", re.compile(r"取締役会長|会長|議長")),
159+ ("vp", re.compile(r"副社長|執行役員|バイスプレジデント")),
160+ ("board", re.compile(r"社外取締役|取締役|監査役|理事")),
161+ ("head", re.compile(r"本部長|事業部長|部長|統括|責任者|マネージャー|ディレクター")),
162+]
163+CHIEF_RE = re.compile(r"\b(chief\b|c[a-z]{1,3}o\b)|最高.{1,8}責任者", re.IGNORECASE)
164+EXEC_CATEGORIES = frozenset({"ceo", "cfo", "cto", "coo", "founder", "president", "chair"})
165+TITLE_WORD_RE = re.compile(r"\b(officer|manager|engineer|lead|counsel|scientist|architect|analyst|controller|secretary|treasurer|advisor|adviser|"
166+ r"strategist|evangelist|designer|emeritus|emerita|executive|principal|fellow|associate|specialist|consultant|"
167+ r"ingénieur|responsable|chargée?|gérant(e)?|associée?|manager|leiter(in)?|mitglied|vorstand|directeur|directrice|"
168+ r"adviseur|bestuurder|gerente|socio|sócio|membro|miembro)\b", re.IGNORECASE)
169+TITLE_WORD_JA_RE = re.compile(r"取締役|執行役員|部長|社長|会長|役員|担当|責任者|マネージャー|ディレクター|エンジニア|顧問|監査役")
170+
171+
172+def role_category(title: str | None) -> tuple[str, bool]:
173+ """(category, is_executive) for a person's title — 'other' when nothing in the vocabulary matches."""
174+ if not title:
175+ return "other", False
176+ for cat, pat in ROLE_RULES:
177+ if pat.search(title):
178+ return cat, cat in EXEC_CATEGORIES or bool(CHIEF_RE.search(title))
179+ for cat, pat in ROLE_RULES_JA:
180+ if pat.search(title):
181+ return cat, cat in EXEC_CATEGORIES or bool(CHIEF_RE.search(title))
182+ return "other", bool(CHIEF_RE.search(title))
183+
184+
185+def looks_like_role_title(text: str) -> bool:
186+ t = normalize_whitespace(text)
187+ if not (2 <= len(t) <= MAX_PERSON_TITLE_CHARS):
188+ return False
189+ return role_category(t)[0] != "other" or bool(TITLE_WORD_RE.search(t)) or bool(TITLE_WORD_JA_RE.search(t)) or bool(CHIEF_RE.search(t))
190+
191+
192+# ------------------------------------------------------------------------------------------------------------ people
193+
194+_UP, _LOW = r"A-ZÀ-ÝĀ-Ž", r"a-zà-ÿā-ž'’"
195+NAME_TOKEN = (rf"(?:[{_UP}](?:['’][{_UP}])?[{_LOW}]*(?:[{_UP}][{_LOW}]+)?(?:-[{_UP}{_LOW}][{_LOW}]*)*|" # Jane · O'Neill · McGrath · García-López
196+ rf"[{_UP}]\.|" # initial
197+ rf"(?:de|van|von|der|den|ter|ten|da|di|do|dos|das|le|la|los|las|du|del|della|bin|ibn|al|el|y|e|of|op)\b|"
198+ rf"(?:de|van|von|da|di|le|la|du|d|l)[{_UP}][{_LOW}]+)") # deSouza · DiCaprio · LeBlanc
199+NAME_SUFFIX = r"(?:,?\s+(?:Jr\.?|Sr\.?|II|III|IV|PhD|MD|MBA|CPA|Esq\.?))?"
200+NAME_RE = re.compile(rf"^(?:(?:Dr|Prof|Mr|Mrs|Ms|Sir|Dame|Hon)\.?\s+)?{NAME_TOKEN}(?:\s+{NAME_TOKEN}){{{PERSON_NAME_MIN_TOKENS - 1},{PERSON_NAME_MAX_TOKENS - 1}}}"
201+ rf"{NAME_SUFFIX}$")
202+NAME_SUFFIX_TAIL_RE = re.compile(r"^\s*(?:Jr\.?|Sr\.?|II|III|IV|PhD|MD|MBA|CPA|Esq\.?)\s*$", re.IGNORECASE)
203+ABBREVIATION_END_RE = re.compile(r"\b(?:Jr|Sr|[A-Z])\.$")
204+JA_NAME_RE = re.compile(r"^[一-鿿぀-ヿ]{1,5}(?:[\s ・]+[一-鿿぀-ヿ]{1,5})?$")
205+JA_NOT_NAME_RE = re.compile(r"代表|取締役|社長|会長|役員|部長|執行|責任者|最高|担当|マネージャー|ディレクター|チーム|株式会社|有限会社|会社|事業|本部|営業|経営|"
206+ r"お問い合わせ|プロフィール|詳細|一覧|紹介|概要|採用|ニュース|ホーム")
207+NOT_NAME_RE = re.compile(
208+ r"\b(team|our|meet|leadership|board|executive|management|contact|about|officer|director|directors|president|manager|head|chief|founder|"
209+ r"partner|partners|group|company|global|senior|vice|read|more|learn|view|profile|bio|linkedin|email|join|careers|news|press|the|and|of|for|"
210+ r"at|in|on|to|with|chair|chairman|emeritus|emerita|vp|advisors?|advisory|committee|members?|staff|people|employees|équipe|direction|"
211+ r"conseil|kontakt|vorstand|geschäftsführung|équipe de direction|equipo|dirección|squadra|direzione|diretoria|equipe)\b", re.IGNORECASE)
212+GENERIC_PERSON_NAMES_RE = _phrase_re([
213+ "contact", "contact us", "team", "our team", "the team", "meet the team", "board", "board of directors", "directors", "leadership", "our leadership",
214+ "leadership team", "senior leadership", "executive team", "executive committee", "management", "management team", "our people", "advisors",
215+ "advisory board", "about us", "kontakt", "unser team", "vorstand", "geschäftsführung", "aufsichtsrat", "équipe", "notre équipe", "direction",
216+ "conseil d'administration", "comité de direction", "equipo", "nuestro equipo", "dirección", "consejo", "squadra", "direzione", "equipe",
217+ "diretoria", "役員", "経営陣", "チーム", "お問い合わせ", "会社概要",
218+])
219+PERSON_TITLE_STOPLIST_RE = _phrase_re([
220+ "contact", "contact us", "read more", "read bio", "view bio", "full bio", "bio", "biography", "profile", "view profile", "linkedin", "twitter", "x",
221+ "email", "e-mail", "phone", "download", "download photo", "download bio", "website", "more", "learn more", "details", "en savoir plus", "voir le profil",
222+ "profil", "kontakt", "lebenslauf", "mehr erfahren", "biografie", "biografía", "ver perfil", "leggi la biografia", "meer info", "lees meer",
223+ "プロフィール", "詳細を見る", "詳細",
224+])
225+
226+
227+def _strip_name(text: str) -> str:
228+ """Trim punctuation around a name but keep a final abbreviation dot ("Edgar S. Woolard, Jr.")."""
229+ t = normalize_whitespace(text).strip(" ,:;-–—|")
230+ return t if ABBREVIATION_END_RE.search(t) else t.rstrip(".").strip()
231+
232+
233+def looks_like_person_name(text: str) -> bool:
234+ """2–5 capitalised tokens (particles, initials, Mc/Mac, glued particles allowed), no digits, no role vocabulary; a peerage or
235+ post-nominal tail after a comma ("…, Baron Trotman") is ignored; or a short CJK name without role words."""
236+ t = _strip_name(text)
237+ if not t or any(ch.isdigit() for ch in t):
238+ return False
239+ if _CJK_RE.search(t):
240+ return bool(JA_NAME_RE.match(t)) and not JA_NOT_NAME_RE.search(t) and 2 <= len(t.replace(" ", "").replace(" ", "")) <= 10
241+ head, _, tail = t.partition(",")
242+ core = t if (not tail or NAME_SUFFIX_TAIL_RE.match(tail)) else head.strip()
243+ if not (MIN_PERSON_NAME_CHARS <= len(core) <= MAX_PERSON_NAME_CHARS):
244+ return False
245+ if NOT_NAME_RE.search(core) or GENERIC_PERSON_NAMES_RE.match(core):
246+ return False
247+ return bool(NAME_RE.match(core))
248+
249+
250+def clean_person_title(title: str | None) -> str | None:
251+ """Stoplisted link labels (Contact, Read more, LinkedIn…), bio sentences and over-long strings are not titles."""
252+ if not title:
253+ return None
254+ t = strip_trailing_marks(title)
255+ if not t or PERSON_TITLE_STOPLIST_RE.match(t) or len(t) > MAX_PERSON_TITLE_CHARS:
256+ return None
257+ if word_count(t) >= SENTENCE_TITLE_MIN_WORDS and t.endswith((".", "!", "?")):
258+ return None
259+ if is_prose(t) and not looks_like_role_title(t):
260+ return None
261+ return t.rstrip(".") if not t.endswith("...") else t
262+
263+
264+def normalize_person(name: str, title: str | None) -> tuple[str, str | None] | None:
265+ """Validated (name, title) — swapped when the card put the title first — or None when the row is not a person."""
266+ n = _strip_name(name)
267+ t = normalize_whitespace(title).strip(" ,:;-–—|") if title else None # keep a final "." — it marks a bio sentence
268+ if not looks_like_person_name(n):
269+ if t and looks_like_person_name(t) and looks_like_role_title(n):
270+ n, t = t, n
271+ else:
272+ return None
273+ return n, clean_person_title(t)
274+
275+
276+def person_verdict(name: str, title: str | None) -> Verdict:
277+ fixed = normalize_person(name, title)
278+ if fixed is None:
279+ if looks_like_role_title(name):
280+ return Verdict.reject("name is a role title")
281+ if GENERIC_PERSON_NAMES_RE.match(_strip_name(name)):
282+ return Verdict.reject("generic label, not a person")
283+ return Verdict.reject("not a person name")
284+ return Verdict.accept()
285+
286+
287+# ------------------------------------------------------------------------------------------------------------ jobs
288+
289+JOB_ROLE_RE = re.compile(
290+ r"\b(?:engineer|ingénieur|ingenieur|ingeniero|ingegnere|engenheiro|developer|développeur|entwickler|desarrollador|sviluppatore|desenvolvedor|"
291+ r"ontwikkelaar|programmer|programmeur|manager|gestionnaire|gerente|analyst|analyste|analista|designer|director|directeur|directrice|direktor|"
292+ r"diretor|direttore|specialist|spécialiste|spezialist|especialista|specialista|intern|internship|stagiaire|praktikant|praktikum|stagiair|stagista|"
293+ r"tirocinio|tirocinante|becario|estagiário|estágio|alternan(?:t|ce)|apprenti|apprentice|auszubildende[rn]?|ausbildung|azubi|werkstudent|"
294+ r"sales|consultant|consulente|consultor|berater|adviseur|technician|technicien|techniker|técnico|tecnico|monteur|nurse|infirmi(?:er|ère)|"
295+ r"krankenpfleger|verpleegkundige|enfermer[oa]|infermier[ea]|pflegefachkraft|pflegekraft|driver|chauffeur|fahrer|conductor|autista|motorista|officer|"
296+ r"lead|head|associate|coordinator|coordinat(?:eur|rice)|koordinator|coördinator|coordinador|coordinatore|coordenador|architect|architecte|"
297+ r"architekt|arquitecto|architetto|arquiteto|scientist|scientifique|wissenschaftler|científico|scienziato|cientista|accountant|comptable|"
298+ r"buchhalter|contable|contabile|contador|operator|opérateur|operador|operatore|mechanic|mécanicien|mechaniker|mecánico|meccanico|mecânico|"
299+ r"representative|représentant|assistant|assistent|asistente|assistente|administrator|administrateur|administrador|amministratore|advisor|"
300+ r"adviser|counsel|attorney|lawyer|avocat|jurist|recruiter|controller|supervisor|superviseur|planner|buyer|acheteur|einkäufer|trainee|graduate|"
301+ r"student|étudiant|studierende|estudiante|responsable|chargée?|chef de|leiter(?:in)?|mitarbeiter(?:in)?|referent(?:in)?|sachbearbeiter(?:in)?|"
302+ r"kaufmann|kauffrau|fachkraft|medewerker|teamleider|projectleider|projektleiter|accountmanager|jefe|addetto|impiegato|responsabile|executive|"
303+ r"principal|partner|paralegal|pharmacist|physician|therapist|teacher|professor|researcher|chercheur|forscher|electrician|électricien|elektriker|"
304+ r"plumber|welder|soudeur|schweißer|machinist|cook|chef|barista|cashier|clerk|agent|guard|cleaner|handler|installer|inspector|auditor|actuary|"
305+ r"underwriter|banker|trader|economist|strategist|producer|editor|writer|rédacteur|redakteur|translator|marketer|merchandiser|dispatcher|steward|"
306+ r"pilot|captain|technologist|veterinarian|dentist|dietitian|paramedic|caregiver|educator|industriemechaniker|softwareentwickler|elektroniker|"
307+ r"mechatroniker|pfleger|erzieher(?:in)?|verkäufer(?:in)?|vendeur|vendeuse|verkoper|commercial|comercial|venditore|vendedor)s?\b|"
308+ r"エンジニア|マネージャー|マネジャー|デザイナー|ディレクター|コンサルタント|スペシャリスト|アナリスト|セールス|営業|開発|正社員|契約社員|インターン|職|担当", re.IGNORECASE)
309+JOB_FORMAT_RE = re.compile(r"\(\s*(?:[mfwhvdx]|all genders|alle geschlechter|tous genres)(?:\s*/\s*[mfwhvdx*])*\s*\)|\b(?:full[- ]?time|part[- ]?time|vollzeit|"
310+ r"teilzeit|temps plein|temps partiel|fulltime|parttime|cdi|cdd|freelance|\d{2,3}\s?%)\b", re.IGNORECASE)
311+GENDER_MARK_RE = re.compile(r"\s*\(\s*(?:[mfwhvdx](?:\s*/\s*[mfwhvdx*]){1,3}|all genders|alle geschlechter|tous genres|m/f/d|h/f)\s*\)", re.IGNORECASE)
312+APPLY_SUFFIX_RE = re.compile(r"\s*[-–—|·]\s*(?:apply(?: now)?|postuler|jetzt bewerben|bewerben|solliciteer(?: nu)?|candidati|candidatar|aplicar)\s*$", re.IGNORECASE)
313+BRACKET_ID_RE = re.compile(r"\s*[\[(]\s*(?:#\s*|(?:job\s*id|ref\.?|id|req)(?:\s*[:#]\s*|\s+))?([A-Z]{0,4}[-_]?\d{3,})\s*[\])]", re.IGNORECASE)
314+JOB_STOP_EXACT_RE = _phrase_re([
315+ "read more", "learn more", "more info", "more information", "more", "info", "details", "view details", "view job", "view all", "view all jobs",
316+ "view all openings", "view openings", "see all", "see all jobs", "see more", "see open roles", "search jobs", "search", "job search", "all jobs",
317+ "all openings", "all positions", "open positions", "open roles", "openings", "jobs", "careers", "career", "apply", "apply now", "apply here",
318+ "join us", "join our team", "join the team", "find out more", "discover more", "explore", "explore roles", "back", "back to top", "load more",
319+ "show more", "next", "previous", "home", "filter", "filters", "sort by", "share", "save", "print", "benefits", "culture", "our culture",
320+ "our values", "diversity", "faq", "hiring process", "talent community", "talent network", "job alerts", "job alert", "sign up for job alerts",
321+ "meer info", "meer informatie", "lees meer", "solliciteer", "solliciteer nu", "alle vacatures", "bekijk alle vacatures", "bekijk vacature",
322+ "vacatures", "ontdek meer", "meer weten", "en savoir plus", "lire la suite", "postuler", "postulez", "voir toutes les offres", "toutes les offres",
323+ "voir l'offre", "voir plus", "découvrir", "nos offres", "offres d'emploi", "mehr erfahren", "mehr infos", "jetzt bewerben", "bewerben",
324+ "alle stellen", "alle stellenangebote", "stellenangebote", "alle jobs", "offene stellen", "weiterlesen", "mehr anzeigen", "zur stelle", "karriere",
325+ "ver más", "leer más", "aplicar", "aplica ahora", "postúlate", "ver todas las ofertas", "todas las ofertas", "más información", "saber más",
326+ "empleo", "ofertas de empleo", "scopri di più", "leggi tutto", "candidati", "candidati ora", "tutte le posizioni", "tutte le offerte",
327+ "maggiori informazioni", "posizioni aperte", "lavora con noi", "saiba mais", "ler mais", "candidatar", "candidate-se", "ver todas as vagas",
328+ "todas as vagas", "mais informações", "vagas", "詳細を見る", "もっと見る", "応募する", "エントリー", "募集一覧", "採用情報", "募集要項",
329+])
330+JOB_STOP_PREFIX_RE = re.compile(r"^(?:early careers?|graduate programm?e|graduates?\b|students?\b|starters\b|careers? (?:at|bij|chez|bei)\b|jobs? (?:at|bij|chez|bei)\b|"
331+ r"working at\b|life at\b|why (?:join|work|us)\b|join us\b|about us\b|who we are\b|what we do\b|meet our\b|our (?:story|teams?|culture|"
332+ r"values|benefits|people)\b|werken bij\b|waarom\b|pourquoi\b|warum\b|karriere bei\b|travailler chez\b|arbeiten bei\b|"
333+ r"trabajar en\b|lavorare in\b|trabalhar na\b)", re.IGNORECASE)
334+JOB_URL_STRONG_RE = re.compile(
335+ r"(?:/jobs?/[^/?#]+|/careers?/[^/?#]+/[^/?#]+|/positions?/|/openings?/|/vacanc(?:y|ies)/[^/?#]+|/vacatures?/[^/?#]+|/stellen(?:angebote?)?/[^/?#]+|"
336+ r"/stelle/|/offres?(?:-d-?emploi)?/[^/?#]+|/emplois?/[^/?#]+|/empleos?/[^/?#]+|/lavoro/[^/?#]+|/vagas?/[^/?#]+|/opportunit(?:y|ies)/[^/?#]+|"
337+ r"/\d{4,}(?:[/-]|$)|[?&](?:gh_jid|jobid|job_id|jid|reqid|req_id|id)=\d+|lever\.co/|greenhouse\.io/|ashbyhq\.com/|myworkdayjobs\.com/|"
338+ r"smartrecruiters\.com/|workable\.com/|recruitee\.com/|personio\.(?:de|com)/|teamtailor\.com/|bamboohr\.com/|jobvite\.com/|icims\.com/|"
339+ r"taleo\.net/|successfactors\.(?:com|eu)/|breezy\.hr/|applytojob\.com/|eightfold\.ai/|phenompeople\.com/|avature\.net/|csod\.com/|oraclecloud\.com/)",
340+ re.IGNORECASE)
341+KNOWN_LOCATION_WORDS_RE = re.compile(r"\b(?:remote|hybrid|on-?site|télétravail|homeoffice|home office|thuiswerken|worldwide|anywhere)\b", re.IGNORECASE)
342+
343+
344+def clean_job_title(title: str) -> tuple[str, str | None]:
345+ """Display title without gender markers, "- Apply" suffixes and bracketed ids; the id found (if any) is returned for the fingerprint."""
346+ t = strip_trailing_marks(title)
347+ found_id: str | None = None
348+ m = BRACKET_ID_RE.search(t)
349+ if m:
350+ found_id = m.group(1)
351+ t = (t[:m.start()] + " " + t[m.end():]).strip()
352+ t = GENDER_MARK_RE.sub("", t)
353+ t = APPLY_SUFFIX_RE.sub("", t)
354+ return normalize_whitespace(t).strip(" -–—|·,"), found_id
355+
356+
357+def job_url_is_joblike(url: str | None) -> bool:
358+ return bool(url) and bool(JOB_URL_STRONG_RE.search(url or ""))
359+
360+
361+def states_job_location(text: str | None) -> bool:
362+ if not text or len(text) > MAX_LOCATION_NAME_CHARS:
363+ return False
364+ p = parse_location(text)
365+ return bool(p["city"] or p["country"] or p["remote"] or p["region"]) or is_known_city(text) or bool(KNOWN_LOCATION_WORDS_RE.search(text))
366+
367+
368+def _department_like(text: str | None) -> bool:
369+ return bool(text) and len(text or "") <= MAX_DEPARTMENT_CHARS and word_count(text or "") <= MAX_DEPARTMENT_WORDS and not any(ch.isdigit() for ch in text or "")
370+
371+
372+def job_verdict(title: str, *, url: str | None = None, location: str | None = None, department: str | None = None) -> Verdict:
373+ """CTA / navigation anchors are not jobs; a job needs role vocabulary, a job-like URL or an explicit location/department cell."""
374+ raw = normalize_whitespace(title)
375+ if ELLIPSIS_RE.search(raw):
376+ return Verdict.reject("truncated title (ellipsis)")
377+ t, _ = clean_job_title(raw)
378+ if not (MIN_JOB_TITLE_CHARS <= len(t) <= MAX_JOB_TITLE_CHARS):
379+ return Verdict.reject("title length")
380+ if JOB_STOP_EXACT_RE.match(t) or JOB_STOP_PREFIX_RE.match(t):
381+ return Verdict.reject("call-to-action / navigation label")
382+ if word_count(t) < MIN_JOB_TITLE_WORDS and not _CJK_RE.search(t):
383+ return Verdict.reject("single-word title")
384+ if JOB_ROLE_RE.search(t) or JOB_FORMAT_RE.search(raw):
385+ return Verdict.accept()
386+ if is_prose(t):
387+ return Verdict.reject("reads like a sentence")
388+ if job_url_is_joblike(url) or states_job_location(location) or _department_like(department):
389+ return Verdict.accept()
390+ return Verdict.reject("no job-like signal (vocabulary, url, location)")
391+
392+
393+def refine_job(job: ExtractedJob) -> ExtractedJob | None:
394+ """Apply `job_verdict`, clean the display title and keep a bracketed id as `external_id` (so the fingerprint keeps it)."""
395+ if not job_verdict(job.title, url=job.url, location=job.location_text, department=job.department).ok:
396+ return None
397+ clean, found_id = clean_job_title(job.title)
398+ if clean:
399+ job.title = clean
400+ if found_id and not job.external_id:
401+ job.external_id = found_id
402+ return job
403+
404+
405+# ------------------------------------------------------------------------------------------------------------ locations
406+
407+NAV_COOKIE_RE = re.compile(
408+ r"\b(?:performance|analytics|analytical|marketing|functional|functionality|necessary|essential|preferences?|strictly|targeting|advertising|"
409+ r"advertisement|statistics?|statistik(?:en)?|statistiques?|statistieken|estadisticas?|statistiche|tracking|cookies?|consent|privacy|privacybeleid|"
410+ r"datenschutz|confidentialite|privacidad|gdpr|contact us|contact|contactez|contacto|contatti|kontakt|careers?|jobs?|sitemap|terms|legal|"
411+ r"mentions legales|impressum|accessibility|newsletter|subscribe|login|log in|sign in|sign up|register|search|menu|language|select|filter|"
412+ r"view all|see all|all locations|find (?:a|an|your)|more|back|home|about us|faq|help|support|settings|einstellungen|notwendig|funktional|"
413+ r"necessaires?|fonctionnels?|preferences|noodzakelijk|functioneel|voorkeuren|necesarias?|funcionales?|preferencias|necessari|funzionali|"
414+ r"preferenze|publicidad|pubblicita|werbung|publicite|social media|session|third party|unclassified|uncategori[sz]ed|others?)\b", re.IGNORECASE)
415+LOCATION_KIND_WORD_RE = re.compile(
416+ r"\b(?:headquarters|head office|hq|office|offices|bureau|büro|kantoor|oficina|ufficio|escritório|store|shop|boutique|showroom|plant|factory|usine|"
417+ r"werk|fabrik|fabriek|fábrica|fabbrica|warehouse|entrepôt|lager|magazijn|almacén|lab|laboratory|laboratoire|labor|campus|branch|agence|filiale|"
418+ r"succursale|niederlassung|vestiging|sede|siège|hauptsitz|data ?cent(?:er|re)|distribution cent(?:er|re))\b|本社|支社|営業所|工場|オフィス|拠点", re.IGNORECASE)
419+VENUE_RE = re.compile(r"\b(?:hotel|tower|towers|building|centre|center|plaza|mall|park|campus|house|hall|street|avenue|road|floor|level|suite|"
420+ r"hôtel|gebäude|gebouw|edificio|torre|palazzo)\b", re.IGNORECASE)
421+STREET_RE = re.compile(
422+ r"\b\d{1,5}[a-z]?\s+[^\n,]{2,40}\b(?:street|st\.?|avenue|ave\.?|road|rd\.?|boulevard|blvd\.?|drive|dr\.?|lane|ln\.?|way|place|pl\.?|square|plaza|"
423+ r"court|ct\.?|parkway|highway|straße|strasse|str\.|allee|platz|weg|rue|avenida|calle|via|piazza)\b"
424+ r"|\b(?:rue|avenue|boulevard|via|calle|avenida|carrer|rua|praça)\s+[^\n,]{2,40}\s\d{1,5}\b"
425+ r"|\b(?:[^\s,\d]+\s){0,2}[^\s,]{3,40}(?:straat|weg|laan|plein|kade|zijde|gracht|singel|markt|dijk|straße|strasse|str\.|allee|platz|gasse|ring|damm|ufer|"
426+ r"vej|gade|gatan|vägen|katu|tie)\s+\d{1,5}[a-z]?\b", re.IGNORECASE)
427+
428+# ~400 major cities and business hubs with common exonyms/endonyms; keys are accent-insensitive (see `key_of`).
429+_CITIES = """
430+new york, los angeles, chicago, houston, phoenix, philadelphia, san antonio, san diego, dallas, san jose, austin, jacksonville, fort worth, columbus,
431+charlotte, san francisco, indianapolis, seattle, denver, washington, boston, nashville, detroit, portland, las vegas, memphis, louisville, baltimore,
432+milwaukee, albuquerque, tucson, fresno, sacramento, kansas city, atlanta, miami, oakland, minneapolis, cleveland, raleigh, omaha, tampa, orlando,
433+pittsburgh, cincinnati, st. louis, salt lake city, richmond, new orleans, buffalo, hartford, providence, durham, boulder, palo alto, mountain view,
434+menlo park, redmond, bellevue, cambridge, princeton, stamford, irvine, santa clara, sunnyvale, cupertino, redwood city, san mateo, arlington, reston,
435+mclean, plano, scottsdale, tempe, ann arbor, madison, des moines, boise, honolulu, anchorage, charleston, savannah, oklahoma city, tulsa, el paso,
436+long beach, colorado springs, newark, jersey city, brooklyn, manhattan, santa monica, pasadena, burbank, anaheim, riverside, henderson, reno, spokane,
437+tacoma, rochester, syracuse, albany, wilmington, trenton, grand rapids, toledo, dayton, lexington, knoxville, chattanooga, huntsville, baton rouge,
438+little rock, tallahassee, st. petersburg, fort lauderdale, west palm beach, boca raton, greenville, columbia, greensboro, norfolk, alexandria, bethesda,
439+rockville, toronto, montréal, montreal, vancouver, calgary, edmonton, ottawa, winnipeg, québec, quebec city, hamilton, kitchener, waterloo, halifax,
440+victoria, saskatoon, regina, mississauga, brampton, markham, vaughan, burnaby, surrey, laval, gatineau, kelowna, oakville, burlington,
441+mexico city, ciudad de méxico, guadalajara, monterrey, puebla, tijuana, querétaro, cancún, mérida, são paulo, sao paulo, rio de janeiro, brasília, brasilia,
442+belo horizonte, curitiba, porto alegre, salvador, recife, fortaleza, campinas, florianópolis, manaus, buenos aires, córdoba, rosario, mendoza, santiago,
443+valparaíso, lima, bogotá, bogota, medellín, cali, cartagena, quito, guayaquil, caracas, montevideo, asunción, la paz, san josé, panama city,
444+ciudad de panamá, guatemala city, san salvador, tegucigalpa, managua, santo domingo, san juan, havana, kingston, nassau,
445+london, manchester, birmingham, leeds, glasgow, edinburgh, liverpool, bristol, sheffield, newcastle, nottingham, cardiff, belfast, leicester, coventry,
446+oxford, reading, milton keynes, brighton, southampton, portsmouth, aberdeen, dundee, york, bath, exeter, plymouth, norwich, swindon, slough, watford,
447+guildford, basingstoke, warrington, derby, sunderland, hull, bradford, dublin, cork, galway, limerick, waterford,
448+paris, marseille, lyon, toulouse, nice, nantes, strasbourg, montpellier, bordeaux, lille, rennes, reims, le havre, saint-étienne, toulon, grenoble,
449+dijon, angers, nîmes, villeurbanne, clermont-ferrand, le mans, aix-en-provence, brest, tours, amiens, limoges, annecy, perpignan, metz, besançon,
450+orléans, rouen, mulhouse, caen, nancy, boulogne-billancourt, issy-les-moulineaux, la défense, courbevoie, neuilly-sur-seine, levallois-perret, nanterre,
451+puteaux, saint-denis, versailles, sophia antipolis, cannes, monaco,
452+berlin, hamburg, münchen, munich, köln, cologne, frankfurt, frankfurt am main, stuttgart, düsseldorf, dortmund, essen, leipzig, bremen, dresden, hannover,
453+hanover, nürnberg, nuremberg, duisburg, bochum, wuppertal, bielefeld, bonn, münster, karlsruhe, mannheim, augsburg, wiesbaden, gelsenkirchen,
454+mönchengladbach, braunschweig, chemnitz, kiel, aachen, halle, magdeburg, freiburg, krefeld, lübeck, mainz, erfurt, oberhausen, rostock, kassel, hagen,
455+saarbrücken, potsdam, ludwigshafen, oldenburg, leverkusen, heidelberg, darmstadt, regensburg, ingolstadt, würzburg, ulm, wolfsburg, göttingen,
456+paderborn, heilbronn, erlangen, jena, walldorf, böblingen, sindelfingen, neckarsulm, herzogenaurach, gütersloh,
457+wien, vienna, graz, linz, salzburg, innsbruck, zürich, zurich, genève, geneva, genf, basel, bâle, bern, berne, lausanne, winterthur, luzern, lucerne,
458+st. gallen, lugano, zug, baar, vevey, neuchâtel,
459+amsterdam, rotterdam, den haag, the hague, utrecht, eindhoven, groningen, tilburg, almere, breda, nijmegen, arnhem, haarlem, amersfoort, enschede,
460+apeldoorn, 's-hertogenbosch, leiden, delft, maastricht, zwolle, hilversum, hoofddorp, schiphol, amstelveen, veldhoven, brussels, bruxelles, brussel,
461+antwerp, antwerpen, anvers, ghent, gent, gand, charleroi, liège, luik, bruges, brugge, namur, leuven, louvain, mechelen, mons, hasselt, kortrijk,
462+zaventem, diegem, luxembourg, esch-sur-alzette,
463+stockholm, göteborg, gothenburg, malmö, uppsala, västerås, linköping, lund, helsingborg, örebro, oslo, bergen, trondheim, stavanger, drammen,
464+copenhagen, københavn, aarhus, århus, odense, aalborg, helsinki, helsingfors, espoo, tampere, vantaa, oulu, turku, reykjavík, reykjavik, tallinn, riga,
465+vilnius, kaunas,
466+madrid, barcelona, valencia, sevilla, seville, zaragoza, málaga, murcia, palma, las palmas, bilbao, alicante, valladolid, vigo, gijón, a coruña,
467+granada, vitoria-gasteiz, san sebastián, donostia, pamplona, santander, lisboa, lisbon, porto, oporto, braga, coimbra, faro, funchal, roma, rome,
468+milano, milan, napoli, naples, torino, turin, palermo, genova, genoa, bologna, firenze, florence, bari, catania, venezia, venice, verona, messina,
469+padova, padua, trieste, brescia, parma, modena, reggio emilia, perugia, bergamo, vicenza, monza, athens, athina, thessaloniki, piraeus, nicosia,
470+limassol, valletta, ljubljana, zagreb, split, belgrade, beograd, sarajevo, skopje, tirana, podgorica,
471+warsaw, warszawa, kraków, krakow, cracow, łódź, lodz, wrocław, wroclaw, poznań, poznan, gdańsk, gdansk, szczecin, katowice, lublin, bydgoszcz, prague,
472+praha, brno, ostrava, plzeň, bratislava, košice, budapest, debrecen, szeged, bucharest, bucurești, cluj-napoca, timișoara, iași, sofia, plovdiv,
473+varna, kyiv, kiev, kharkiv, lviv, odesa, odessa, dnipro, minsk, moscow, moskva, saint petersburg, st. petersburg, novosibirsk, yekaterinburg, kazan,
474+chișinău, tbilisi, yerevan, baku,
475+istanbul, ankara, izmir, bursa, antalya, tel aviv, jerusalem, haifa, herzliya, petah tikva, beersheba, dubai, abu dhabi, sharjah, doha, riyadh, jeddah,
476+dammam, kuwait city, manama, muscat, amman, beirut, baghdad, tehran, cairo, giza, casablanca, rabat, marrakech, tangier, tunis, algiers, lagos, abuja,
477+nairobi, mombasa, accra, addis ababa, dar es salaam, kampala, kigali, johannesburg, cape town, durban, pretoria, gqeberha, luanda, maputo, lusaka,
478+harare, dakar, abidjan, kinshasa, douala, yaoundé, windhoek, gaborone, antananarivo, port louis, khartoum,
479+tokyo, 東京, osaka, 大阪, yokohama, 横浜, nagoya, 名古屋, sapporo, 札幌, fukuoka, 福岡, kobe, 神戸, kyoto, 京都, kawasaki, 川崎, saitama, hiroshima, 広島,
480+sendai, 仙台, chiba, 千葉, seoul, 서울, busan, 부산, incheon, 인천, daegu, daejeon, gwangju, suwon, pangyo, seongnam, beijing, 北京, peking, shanghai, 上海,
481+guangzhou, 广州, shenzhen, 深圳, chengdu, 成都, hangzhou, 杭州, wuhan, 武汉, xi'an, 西安, chongqing, 重庆, tianjin, 天津, nanjing, 南京, suzhou, 苏州,
482+qingdao, 青岛, dalian, 大连, xiamen, 厦门, shenyang, changsha, zhengzhou, dongguan, ningbo, hong kong, 香港, macau, macao, 澳门, taipei, 台北, 臺北,
483+taichung, kaohsiung, hsinchu, tainan, singapore, kuala lumpur, penang, george town, johor bahru, cyberjaya, petaling jaya, bangkok, กรุงเทพ,
484+chiang mai, jakarta, surabaya, bandung, denpasar, medan, manila, makati, quezon city, cebu, taguig, bonifacio global city, pasig, hanoi, hà nội,
485+ho chi minh city, hồ chí minh, saigon, da nang, phnom penh, yangon, vientiane, dhaka, chittagong, colombo, kathmandu, karachi, lahore, islamabad,
486+rawalpindi, faisalabad, mumbai, bombay, delhi, new delhi, bengaluru, bangalore, hyderabad, chennai, madras, kolkata, calcutta, pune, ahmedabad, jaipur,
487+surat, lucknow, kanpur, nagpur, indore, thane, bhopal, visakhapatnam, vadodara, coimbatore, kochi, cochin, gurgaon, gurugram, noida, chandigarh,
488+mysore, mysuru, thiruvananthapuram, trivandrum, bhubaneswar, mohali, navi mumbai, sydney, melbourne, brisbane, perth, adelaide, canberra,
489+gold coast, hobart, darwin, wollongong, geelong, auckland, wellington, christchurch, dunedin, tauranga, ulaanbaatar, almaty, astana, tashkent, bishkek
490+"""
491+CITY_KEYS: frozenset[str] = frozenset(key_of(c) for c in _CITIES.replace("\n", ",").split(",") if c.strip())
492+
493+
494+def is_known_city(text: str | None) -> bool:
495+ """Whole string or any 1–3-word n-gram of a short string names a city in the table."""
496+ if not text:
497+ return False
498+ k = key_of(text)
499+ if not k:
500+ return False
501+ if k in CITY_KEYS:
502+ return True
503+ if _CJK_RE.search(text):
504+ return any(city in k for city in CITY_KEYS if _CJK_RE.search(city))
505+ toks = k.split()
506+ if len(toks) > MAX_LOCATION_NAME_WORDS:
507+ return False
508+ for n in range(1, min(CITY_NGRAM_MAX, len(toks)) + 1):
509+ for i in range(len(toks) - n + 1):
510+ if " ".join(toks[i:i + n]) in CITY_KEYS:
511+ return True
512+ return False
513+
514+
515+CITY_DISTRICT_RE = re.compile(r"\s\d{1,2}$") # "Dublin 2", "Paris 8" — a postal district after a known city
516+
517+
518+def has_postal_code(text: str | None) -> bool:
519+ if not text:
520+ return False
521+ return any(not YEAR_RE.match(m.group(0)) for m in POSTAL_RE.finditer(text))
522+
523+
524+def looks_like_city_value(text: str | None) -> bool:
525+ """A plausible `city` cell: short, capitalised words, no digits, no nav/cookie vocabulary, no sentence."""
526+ if not text:
527+ return False
528+ t = normalize_whitespace(text)
529+ if not (2 <= len(t) <= MAX_CITY_CHARS) or word_count(t) > MAX_CITY_WORDS:
530+ return False
531+ if any(ch.isdigit() for ch in t) and not (CITY_DISTRICT_RE.search(t) and is_known_city(CITY_DISTRICT_RE.sub("", t))):
532+ return False # "Level 12" no · "Dublin 2" (postal district) yes
533+ if NAV_COOKIE_RE.search(key_of(t)) or is_prose(t):
534+ return False
535+ return t[0].isupper() or bool(_CJK_RE.search(t[0]))
536+
537+
538+def _looks_like_venue(text: str) -> bool:
539+ return word_count(text) >= VENUE_MIN_WORDS or bool(VENUE_RE.search(text))
540+
541+
542+def location_signal(*, name: str, city: str | None, region: str | None, country: str | None, kind: str | None, address: str | None) -> str | None:
543+ """The evidence that makes this a real place, or None: country / known city / postal code / street address / explicit kind label."""
544+ if country:
545+ return "country"
546+ if is_known_city(city) or is_known_city(region) or is_known_city(name):
547+ return "known city"
548+ if has_postal_code(address) or (has_postal_code(name) and "," in name):
549+ return "postal code"
550+ if STREET_RE.search(address or "") or STREET_RE.search(name):
551+ return "street address"
552+ if (kind and kind not in ("office", "other")) or LOCATION_KIND_WORD_RE.search(name):
553+ return "kind label"
554+ return None
555+
556+
557+def normalize_location(loc: ExtractedLocation) -> ExtractedLocation | None:
558+ """Validated copy of `loc` (city cell cleaned, country-as-name repaired) or None when it is not a place."""
559+ name = strip_trailing_marks(loc.name or "")
560+ if not name or len(name) > MAX_LOCATION_NAME_CHARS or word_count(name) > MAX_LOCATION_NAME_WORDS:
561+ return None
562+ if NAV_COOKIE_RE.search(key_of(name)) or is_prose(name):
563+ return None
564+ city = loc.city if looks_like_city_value(loc.city) else None
565+ region = loc.region
566+ country = loc.country
567+ name_country = country_code(name)
568+ if name_country and city and key_of(city) == key_of(name) and not is_known_city(name):
569+ city = None # "Oman | Oman": a country is not its own city (Singapore is)
570+ if name_country and loc.city and (city is None or _looks_like_venue(loc.city)) and not NAV_COOKIE_RE.search(key_of(loc.city)) and not is_prose(loc.city) \
571+ and len(loc.city) <= MAX_LOCATION_NAME_CHARS and key_of(loc.city) != key_of(name):
572+ name, city, country = normalize_whitespace(loc.city), None, country or name_country # "Oman | Hormuz Grand Hotel" → venue named, country kept
573+ if region and is_known_city(region) and looks_like_city_value(region) and (city is None or not is_known_city(city)):
574+ city, region = region, None # "3089 JH Rotterdam" parsed as a region
575+ if location_signal(name=name, city=city, region=region, country=country, kind=loc.kind, address=loc.address_text) is None:
576+ return None
577+ return ExtractedLocation(name=name, kind=loc.kind or "office", city=city, region=region, country=country, address_text=loc.address_text)
578+
579+
580+def location_verdict(loc: ExtractedLocation) -> Verdict:
581+ name = strip_trailing_marks(loc.name or "")
582+ if not name:
583+ return Verdict.reject("empty name")
584+ if len(name) > MAX_LOCATION_NAME_CHARS or word_count(name) > MAX_LOCATION_NAME_WORDS:
585+ return Verdict.reject("name too long")
586+ if NAV_COOKIE_RE.search(key_of(name)):
587+ return Verdict.reject("cookie/consent/navigation vocabulary")
588+ if is_prose(name):
589+ return Verdict.reject("name is a sentence")
590+ fixed = normalize_location(loc)
591+ if fixed is None:
592+ return Verdict.reject("no place evidence (country, city, postal code, street, kind)")
593+ return Verdict.accept()
594+
595+
596+# ------------------------------------------------------------------------------------------------------------ pricing plans
597+
598+PLAN_EYEBROW_RE = _phrase_re(["most popular", "popular", "best value", "recommended", "new", "beta", "coming soon", "limited offer", "best seller",
599+ "le plus populaire", "beliebt", "am beliebtesten", "meest gekozen", "más popular", "più popolare", "mais popular", "人気",
600+ # generic section headings that are not tiers
601+ "plans", "plan", "pricing", "prices", "price", "plans & pricing", "plans and pricing", "our plans", "compare plans",
602+ "choose your plan", "tarifs", "nos tarifs", "abonnements", "preise", "unsere preise", "tarife", "prijzen", "abonnementen",
603+ "precios", "planes", "prezzi", "piani", "preços", "planos", "料金", "料金プラン", "プラン"])
604+PRICE_TOKEN_RE = re.compile(
605+ r"(?:(?:US\$|CA\$|C\$|A\$|NZ\$|HK\$|S\$|MX\$|R\$|USD|EUR|GBP|CAD|AUD|CHF|JPY|INR|SEK|NOK|DKK|PLN|BRL|CNY|RMB|SGD|[$€£¥₹₩₺])\s?"
606+ r"\d{1,3}(?:[,.\s]\d{3})*(?:[.,]\d{1,2})?|\d{1,3}(?:[,.\s]\d{3})*(?:[.,]\d{1,2})?\s?(?:€|£|USD|EUR|GBP|CHF|kr|zł|元|\$))", re.IGNORECASE)
607+PRICE_LEAD_RE = re.compile(r"(?:from|starting at|starts at|as low as|à partir de|ab|desde|a partire da|vanaf|only|just)\s*$", re.IGNORECASE)
608+PRICE_UNIT = (r"(?:month|mo|year|yr|annum|week|wk|day|hour|hr|user|seat|member|license|licence|agent|editor|contact|1,?000|1k|GB|TB|request|"
609+ r"transaction|call|minute|mois|an|année|monat|jahr|nutzer|benutzer|utilisateur|usuario|utente|mese|anno|mes|año|maand|jaar|gebruiker|"
610+ r"dag|tag|giorno|día|dia|jour)")
611+PRICE_TAIL_RE = re.compile(rf"^\s*(?:(?:/|per|a|an|each|every|par|pro|pour|por|al|je)\s*{PRICE_UNIT}\b(?:\s*(?:/|per|par|pro|pour|por)\s*{PRICE_UNIT}\b)?|"
612+ rf"(?:monthly|annually|yearly|mensuel|annuel|monatlich|jährlich|one[- ]time|lifetime|forever))?"
613+ r"(?:,?\s*billed (?:monthly|annually|yearly))?", re.IGNORECASE)
614+CONTACT_PHRASE_RE = re.compile(r"\b(contact (?:us|sales)|talk to (?:us|sales|an expert)|custom pricing|custom quote|get a quote|request (?:a )?(?:quote|demo)|"
615+ r"let'?s talk|on request|upon request|sur devis|nous contacter|contactez-nous|auf anfrage|individuell|tailored|bespoke|call us)\b",
616+ re.IGNORECASE)
617+FREE_TOKEN_RE = re.compile(r"^(free|gratuit|kostenlos|gratis|\$\s?0(?:\.00)?|0\s?€|€\s?0)\b", re.IGNORECASE)
618+
619+
620+def price_text_from(text: str) -> str | None:
621+ """The price phrase stated in `text` — currency + amount + period/unit, or the contact-sales / free wording — never a cut sentence."""
622+ t = normalize_whitespace(text)
623+ m = PRICE_TOKEN_RE.search(t)
624+ if m:
625+ lead = PRICE_LEAD_RE.search(t[:m.start()])
626+ start = lead.start() if lead else m.start()
627+ tail = PRICE_TAIL_RE.match(t[m.end():])
628+ end = m.end() + (tail.end() if tail else 0)
629+ return t[start:end].strip(" ,")[:MAX_PRICE_TEXT_CHARS]
630+ c = CONTACT_PHRASE_RE.search(t)
631+ if c:
632+ phrase = c.group(1)
633+ return phrase[0].upper() + phrase[1:]
634+ f = FREE_TOKEN_RE.match(t)
635+ if f:
636+ return f.group(1)
637+ return None
638+
639+
640+def plan_name_ok(name: str | None) -> bool:
641+ if not name:
642+ return False
643+ n = normalize_whitespace(name)
644+ if not (1 <= len(n) <= MAX_PLAN_NAME_CHARS) or word_count(n) > MAX_PLAN_NAME_WORDS:
645+ return False
646+ if has_terminal_punct(n) or "|" in n or "?" in n or PLAN_EYEBROW_RE.match(n) or re.search(r"\d+\s?%", n):
647+ return False
648+ if MARKETING_VERB_RE.match(n) and word_count(n) > 1:
649+ return False
650+ return not PRICE_TOKEN_RE.search(n)
651+
652+
653+def plan_verdict(plan: ExtractedPlan) -> Verdict:
654+ if not plan_name_ok(plan.plan_name):
655+ return Verdict.reject("plan name is a heading / marketing phrase")
656+ if plan.price is None and not plan.contact_sales and not (plan.price_text and FREE_TOKEN_RE.match(plan.price_text)):
657+ return Verdict.reject("no price, no contact-sales, no free tier")
658+ if plan.price_text and price_text_from(plan.price_text) is None:
659+ return Verdict.reject("price text does not state a price")
660+ return Verdict.accept()
661+
662+
663+# ------------------------------------------------------------------------------------------------------------ products
664+
665+PRODUCT_NAV_RE = _phrase_re([
666+ "overview", "products", "product", "all products", "our products", "solutions", "solution", "all solutions", "services", "our services", "features",
667+ "pricing", "resources", "support", "docs", "documentation", "blog", "contact", "contact us", "home", "learn more", "read more", "view all", "see all",
668+ "explore", "more", "get started", "sign up", "log in", "download", "compare", "industries", "platform", "use cases", "customers", "partners",
669+ "produits", "nos produits", "tous les produits", "en savoir plus", "produkte", "alle produkte", "unsere produkte", "lösungen", "mehr erfahren",
670+ "producten", "alle producten", "oplossingen", "meer info", "productos", "todos los productos", "soluciones", "ver más", "prodotti", "tutti i prodotti",
671+ "soluzioni", "scopri di più", "produtos", "todos os produtos", "soluções", "saiba mais", "製品", "製品一覧", "ソリューション", "サービス", "詳細", "もっと見る",
672+])
673+
674+
675+def product_verdict(name: str) -> Verdict:
676+ n = strip_trailing_marks(name)
677+ if not (2 <= len(n) <= MAX_PRODUCT_NAME_CHARS):
678+ return Verdict.reject("name length")
679+ if word_count(n) > MAX_PRODUCT_NAME_WORDS:
680+ return Verdict.reject("name too long")
681+ if PRODUCT_NAV_RE.match(n.rstrip("®™ ")):
682+ return Verdict.reject("navigation label")
683+ if has_terminal_punct(n) and word_count(n) >= 3:
684+ return Verdict.reject("name is a sentence")
685+ if is_slogan(n) or is_prose(n):
686+ return Verdict.reject("marketing slogan")
687+ return Verdict.accept()
688+
689+
690+# ------------------------------------------------------------------------------------------------------------ news
691+
692+NEWS_NOISE_RE = _phrase_re([
693+ "read more", "read more news", "learn more", "more", "more news", "continue reading", "view all", "see all", "all news", "all posts", "all articles",
694+ "next", "previous", "older", "newer", "older posts", "newer posts", "load more", "show more", "page", "news", "press", "press releases", "press release",
695+ "blog", "blog posts", "articles", "insights", "events", "media", "in the news", "case studies", "webinars", "whitepapers", "podcasts", "videos",
696+ "newsroom", "latest news", "category", "categories", "archive", "archives", "tags", "share", "rss", "subscribe", "newsletter", "actualités",
697+ "toutes les actualités", "communiqués de presse", "lire la suite", "en savoir plus", "alle news", "pressemitteilungen", "aktuelles", "weiterlesen",
698+ "mehr erfahren", "nieuws", "alle berichten", "persberichten", "lees meer", "noticias", "todas las noticias", "notas de prensa", "leer más", "notizie",
699+ "comunicati stampa", "leggi tutto", "notícias", "ler mais", "ニュース", "お知らせ", "プレスリリース", "一覧", "もっと見る",
700+])
701+PAGINATION_RE = re.compile(r"^(?:page\s*)?\d{1,4}$|^(?:[«»‹›<>]|\.\.\.|…)+$", re.IGNORECASE)
702+
703+
704+def news_verdict(title: str, *, url: str | None = None, published_at: datetime | None = None) -> Verdict:
705+ t = strip_trailing_marks(title)
706+ if not t or PAGINATION_RE.match(t):
707+ return Verdict.reject("pagination")
708+ if NEWS_NOISE_RE.match(t):
709+ return Verdict.reject("navigation / category label")
710+ confirmed = published_at is not None or date_from_url(url) is not None
711+ long_enough = word_count(t) >= NEWS_MIN_WORDS or len(t) >= NEWS_MIN_CHARS or (bool(_CJK_RE.search(t)) and len(t) >= NEWS_MIN_CJK_CHARS)
712+ if not confirmed and not long_enough:
713+ return Verdict.reject("too short without a date")
714+ return Verdict.accept()
715+
716+
717+# ------------------------------------------------------------------------------------------------------------ whole extraction
718+
719+HTML_JOB_CONNECTORS = frozenset({"generic-html-v1"})
720+
721+
722+def apply_precision(ex: Extraction, *, html_jobs: bool) -> dict[str, int]:
723+ """Filter/normalise every typed list in place; returns how many items each list lost. Job rules only for HTML-scraped listings."""
724+ dropped: dict[str, int] = {}
725+ if html_jobs:
726+ kept_jobs = [j for j in (refine_job(j) for j in ex.jobs) if j is not None]
727+ dropped["jobs"] = len(ex.jobs) - len(kept_jobs)
728+ ex.jobs = kept_jobs
729+ people: list[ExtractedPerson] = []
730+ for p in ex.people:
731+ fixed = normalize_person(p.name, p.title)
732+ if fixed is None:
733+ continue
734+ p.name, p.title = fixed
735+ p.role_category, p.is_executive = role_category(p.title)
736+ people.append(p)
737+ dropped["people"] = len(ex.people) - len(people)
738+ ex.people = people
739+ products: list[ExtractedProduct] = [p for p in ex.products if product_verdict(p.name).ok]
740+ dropped["products"] = len(ex.products) - len(products)
741+ ex.products = products
742+ plans: list[ExtractedPlan] = [p for p in ex.plans if plan_verdict(p).ok]
743+ dropped["plans"] = len(ex.plans) - len(plans)
744+ ex.plans = plans
745+ locations: list[ExtractedLocation] = [loc for loc in (normalize_location(loc) for loc in ex.locations) if loc is not None]
746+ dropped["locations"] = len(ex.locations) - len(locations)
747+ ex.locations = locations
748+ news: list[ExtractedNewsItem] = [n for n in ex.news if news_verdict(n.title, url=n.url, published_at=n.published_at).ok]
749+ dropped["news"] = len(ex.news) - len(news)
750+ ex.news = news
751+ return {k: v for k, v in dropped.items() if v}
752+
753+
754+def describe(verdict: Verdict) -> dict[str, Any]:
755+ return {"ok": verdict.ok, "reason": verdict.reason}
756+
757+
758+__all__ = [
759+ "CHIEF_RE", "CITY_KEYS", "EXEC_CATEGORIES", "HTML_JOB_CONNECTORS", "NAV_COOKIE_RE", "PRECISION_VERSION", "ROLE_RULES", "STREET_RE", "Verdict",
760+ "apply_precision", "clean_job_title", "clean_person_title", "describe", "has_postal_code", "is_known_city", "is_prose", "is_slogan", "job_url_is_joblike",
761+ "job_verdict", "key_of", "location_signal", "location_verdict", "looks_like_city_value", "looks_like_person_name", "looks_like_role_title", "news_verdict",
762+ "normalize_location", "normalize_person", "person_verdict", "plan_name_ok", "plan_verdict", "price_text_from", "product_verdict", "refine_job",
763+ "role_category", "states_job_location", "strip_trailing_marks", "word_count",
764+]
modified src/companyatlas/connectors/_util.py +1 −1
@@ -66,7 +66,7 @@ CA_PROVINCE_CODES_SAFE = {"ON", "QC", "BC", "AB", "MB", "SK", "NS", "NB"}
66 AMBIGUOUS_CODES = (set(US_STATES.values()) | set(CA_PROVINCES.values())) & ISO2 66 AMBIGUOUS_CODES = (set(US_STATES.values()) | set(CA_PROVINCES.values())) & ISO2
67 REMOTE_RE = re.compile(r"\b(remote|work from home|wfh|anywhere|distributed|télétravail|homeoffice|home office)\b", re.IGNORECASE) 67 REMOTE_RE = re.compile(r"\b(remote|work from home|wfh|anywhere|distributed|télétravail|homeoffice|home office)\b", re.IGNORECASE)
68 HYBRID_RE = re.compile(r"\bhybrid\b", re.IGNORECASE) 68 HYBRID_RE = re.compile(r"\bhybrid\b", re.IGNORECASE)
69−POSTAL_RE = re.compile(r"\b[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}\b|\b\d{4,6}(?:-\d{4})?\b|\b[A-Z]\d[A-Z]\s*\d[A-Z]\d\b") 69+POSTAL_RE = re.compile(r"\b[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}\b|\b\d{4}\s?[A-Z]{2}\b|\b\d{4,6}(?:-\d{4})?\b|\b[A-Z]\d[A-Z]\s*\d[A-Z]\d\b") # UK · NL · numeric · CA
70 70
71 71
72 def country_code(text: str | None) -> str | None: 72 def country_code(text: str | None) -> str | None:
modified src/companyatlas/connectors/generic_html.py +103 −81
@@ -8,6 +8,7 @@ metadata, JSON-LD and links; this module adds *surface-aware* typed extraction:
8 8
9 Structured data (JSON-LD / microdata) is used first, DOM heuristics second, and nothing is ever invented: no address, no country, no 9 Structured data (JSON-LD / microdata) is used first, DOM heuristics second, and nothing is ever invented: no address, no country, no
10 date that the page does not state. Every surface also yields `discovered` URLs (classified links) for the discovery feedback loop. 10 date that the page does not state. Every surface also yields `discovered` URLs (classified links) for the discovery feedback loop.
11+Precision rules (`connectors/_precision.py`) reject navigation / CTA / cookie-consent / marketing noise at the point of extraction.
11 """ 12 """
12 from __future__ import annotations 13 from __future__ import annotations
13 14
@@ -19,6 +20,22 @@ from urllib.parse import urlparse
19 from selectolax.lexbor import LexborHTMLParser 20 from selectolax.lexbor import LexborHTMLParser
20 21
21 from companyatlas.config import settings 22 from companyatlas.config import settings
23+from companyatlas.connectors._precision import (
24+ STREET_RE,
25+ is_known_city,
26+ looks_like_person_name,
27+ looks_like_role_title,
28+ news_verdict,
29+ normalize_location,
30+ normalize_person,
31+ plan_name_ok,
32+ plan_verdict,
33+ price_text_from,
34+ product_verdict,
35+ refine_job,
36+ role_category,
37+ states_job_location,
38+)
22 from companyatlas.connectors._util import ( 39 from companyatlas.connectors._util import (
23 country_code, 40 country_code,
24 date_from_text, 41 date_from_text,
@@ -57,52 +74,22 @@ PRODUCT_SURFACES = {Surface.PRODUCTS, Surface.SERVICES, Surface.SOLUTIONS}
57 74
58 # ------------------------------------------------------------------------------------------------------------ people 75 # ------------------------------------------------------------------------------------------------------------ people
59 76
60−ROLE_RULES: list[tuple[str, re.Pattern[str]]] = [ 77+# Role vocabulary, name shape and title cleaning live in `_precision` (shared with the pipeline); re-exported here for callers/tests.
61− ("founder", re.compile(r"\b(co-?founder|founder|fondat(eur|rice)|gründer(in)?)\b", re.IGNORECASE)), 78+looks_like_name = looks_like_person_name
62− ("ceo", re.compile(r"\b(chief executive( officer)?|ceo|pdg|président-directeur|geschäftsführer(in)?|managing director|directeur général)\b", re.IGNORECASE)), 79+looks_like_title = looks_like_role_title
63− ("cfo", re.compile(r"\b(chief financial( officer)?|cfo|finanzvorstand|directeur financier)\b", re.IGNORECASE)), 80+PERSON_CARD_SCAN_LINES = 4 # a person card states the name within its first lines
64− ("cto", re.compile(r"\b(chief technology( officer)?|chief technical( officer)?|cto)\b", re.IGNORECASE)), 81+PERSON_TITLE_WINDOW = 3 # …and the title within the next few
65− ("coo", re.compile(r"\b(chief operating( officer)?|coo)\b", re.IGNORECASE)), 82+
66− ("chair", re.compile(r"\b(chair(man|woman|person)?|executive chair|présidente? du conseil|vorsitzende[rn]?)\b", re.IGNORECASE)), 83+
67− ("president", re.compile(r"\b(?<!vice )(?<!vice-)president(?! of)|président(?!e? du conseil)\b", re.IGNORECASE)), 84+def _card_person(lines: list[str]) -> tuple[str, str | None] | None:
68− ("board", re.compile(r"\b(board member|member of the (supervisory |advisory )?board|non-executive director|independent director|" 85+ """(name, title) from a card's lines: the title may precede the name (swapped cards) or follow it; role-like lines win over prose."""
69− r"administrat(eur|rice)|aufsichtsrat|conseil d'administration|director(?= \(board)|trustee)\b", re.IGNORECASE)), 86+ name_idx = next((i for i, ln in enumerate(lines[:PERSON_CARD_SCAN_LINES]) if looks_like_name(ln)), None)
70− ("vp", re.compile(r"\b(vice[- ]president|vp|evp|svp|avp)\b", re.IGNORECASE)), 87+ if name_idx is None:
71− ("head", re.compile(r"\b(head of|head,|general manager|gm|leiter(in)?|directeur|directrice|director|managing partner|partner)\b", re.IGNORECASE)), 88+ return None
72−] 89+ before = [x for x in lines[:name_idx] if looks_like_title(x)]
73−CHIEF_RE = re.compile(r"\b(chief\b|c[a-z]{1,3}o\b)", re.IGNORECASE) 90+ after = [x for x in lines[name_idx + 1:name_idx + 1 + PERSON_TITLE_WINDOW] if not looks_like_name(x)]
74−EXEC_CATEGORIES = {"ceo", "cfo", "cto", "coo", "founder", "president", "chair"} 91+ title = (before[-1] if before else None) or next((x for x in after if looks_like_title(x)), None) or (after[0] if after else None)
75−NAME_TOKEN = r"(?:[A-ZÀ-ÝĀ-Ž](?:['’][A-ZÀ-ÝĀ-Ž])?[a-zà-ÿā-ž'’]+(?:-[A-ZÀ-ÝĀ-Ža-zà-ÿā-ž][a-zà-ÿā-ž'’]*)*|[A-ZÀ-ÝĀ-Ž]\.|(?:de|van|von|der|den|da|di|le|la|du|del|bin|al)\b)" 92+ return lines[name_idx], title
76−NAME_RE = re.compile(rf"^(?:(?:Dr|Prof|Mr|Mrs|Ms|Sir|Dame|Hon)\.?\s+)?{NAME_TOKEN}(?:\s+{NAME_TOKEN}){{1,4}}(?:,?\s+(?:Jr\.?|Sr\.?|II|III|IV|PhD|MD|MBA|CPA|Esq\.?))?$")
77−NOT_NAME_RE = re.compile(r"\b(team|our|meet|leadership|board|executive|management|contact|about|officer|director|president|manager|head|chief|"
78− r"founder|partner|group|company|global|senior|vice|read|more|learn|view|profile|bio|linkedin|email|join|careers|"
79− r"news|press|the|and|of|for|at|in|on|to|with)\b", re.IGNORECASE)
80−
81−
82−def role_category(title: str | None) -> tuple[str, bool]:
83− if not title:
84− return "other", False
85− for cat, pat in ROLE_RULES:
86− if pat.search(title):
87− return cat, cat in EXEC_CATEGORIES or bool(CHIEF_RE.search(title))
88− return "other", bool(CHIEF_RE.search(title))
89−
90−
91−def looks_like_name(text: str) -> bool:
92− t = normalize_whitespace(text).strip(" .,:;-–—|")
93− if not (4 <= len(t) <= 60) or any(ch.isdigit() for ch in t):
94− return False
95− if NOT_NAME_RE.search(t):
96− return False
97− return bool(NAME_RE.match(t))
98−
99−
100−def looks_like_title(text: str) -> bool:
101− t = normalize_whitespace(text)
102− if not (2 <= len(t) <= 140):
103− return False
104− return role_category(t)[0] != "other" or bool(re.search(r"\b(officer|manager|engineer|lead|counsel|scientist|architect|analyst|controller|"
105− r"secretary|treasurer|advisor|adviser|strategist|evangelist|designer)\b", t, re.IGNORECASE))
106 93
107 94
108 def extract_people(page: NormalizedPage) -> list[ExtractedPerson]: 95 def extract_people(page: NormalizedPage) -> list[ExtractedPerson]:
@@ -110,27 +97,28 @@ def extract_people(page: NormalizedPage) -> list[ExtractedPerson]:
110 seen: set[str] = set() 97 seen: set[str] = set()
111 98
112 def add(name: str, title: str | None, url: str | None = None) -> None: 99 def add(name: str, title: str | None, url: str | None = None) -> None:
113− name = normalize_whitespace(name).strip(" .,:;-–—|") 100+ fixed = normalize_person(name, title)
101+ if fixed is None:
102+ return
103+ name, title = fixed
114 key = norm_name(name) 104 key = norm_name(name)
115 if not key or key in seen or len(out) >= MAX_PEOPLE: 105 if not key or key in seen or len(out) >= MAX_PEOPLE:
116 return 106 return
117 seen.add(key) 107 seen.add(key)
118 cat, is_exec = role_category(title) 108 cat, is_exec = role_category(title)
119− out.append(ExtractedPerson(name=name[:120], title=(normalize_whitespace(title)[:160] if title else None), role_category=cat, is_executive=is_exec, url=url)) 109+ out.append(ExtractedPerson(name=name[:120], title=(title[:160] if title else None), role_category=cat, is_executive=is_exec, url=url))
120 110
121 for p in page.jsonld.get("persons", []) + page.microdata.get("persons", []): 111 for p in page.jsonld.get("persons", []) + page.microdata.get("persons", []):
122 name = text_of(p.get("name")) 112 name = text_of(p.get("name"))
123− if name and looks_like_name(name): 113+ if name:
124 add(name, text_of(p.get("jobTitle")) or text_of(p.get("title")), text_of(p.get("url")) if isinstance(p.get("url"), str) else None) 114 add(name, text_of(p.get("jobTitle")) or text_of(p.get("title")), text_of(p.get("url")) if isinstance(p.get("url"), str) else None)
125 for b in page.blocks: 115 for b in page.blocks:
126 if b.kind != "person": 116 if b.kind != "person":
127 continue 117 continue
128 lines = [ln.strip() for ln in b.text.split("\n") if ln.strip()] 118 lines = [ln.strip() for ln in b.text.split("\n") if ln.strip()]
129− for i, ln in enumerate(lines[:4]): 119+ found = _card_person(lines)
130− if looks_like_name(ln): 120+ if found is not None:
131− title = next((x for x in lines[i + 1:i + 4] if not looks_like_name(x) and len(x) <= 140), None) 121+ add(found[0], found[1], b.attrs.get("href"))
132− add(ln, title, b.attrs.get("href"))
133− break
134 if len(out) < 2: # fallback: heading = name, next short block = title 122 if len(out) < 2: # fallback: heading = name, next short block = title
135 blocks = page.blocks 123 blocks = page.blocks
136 for i, b in enumerate(blocks): 124 for i, b in enumerate(blocks):
@@ -167,11 +155,11 @@ def parse_price(text: str) -> dict[str, Any] | None:
167 """→ {price, currency, billing_period, unit, price_text, contact_sales} or None when the text states no price.""" 155 """→ {price, currency, billing_period, unit, price_text, contact_sales} or None when the text states no price."""
168 t = normalize_whitespace(text) 156 t = normalize_whitespace(text)
169 if CONTACT_RE.search(t) and not PRICE_RE.search(t): 157 if CONTACT_RE.search(t) and not PRICE_RE.search(t):
170− return {"price": None, "currency": None, "billing_period": "contact", "unit": None, "price_text": t[:80], "contact_sales": True} 158+ return {"price": None, "currency": None, "billing_period": "contact", "unit": None, "price_text": price_text_from(t) or t[:80], "contact_sales": True}
171 m = PRICE_RE.search(t) 159 m = PRICE_RE.search(t)
172 if m is None: 160 if m is None:
173 if FREE_RE.match(t): 161 if FREE_RE.match(t):
174− return {"price": 0.0, "currency": None, "billing_period": None, "unit": None, "price_text": t[:80], "contact_sales": False} 162+ return {"price": 0.0, "currency": None, "billing_period": None, "unit": None, "price_text": price_text_from(t) or t[:80], "contact_sales": False}
175 return None 163 return None
176 cur = m.group("cur") or m.group("cur2") 164 cur = m.group("cur") or m.group("cur2")
177 amt = m.group("amt") or m.group("amt2") 165 amt = m.group("amt") or m.group("amt2")
@@ -213,10 +201,28 @@ def parse_price(text: str) -> dict[str, Any] | None:
213 elif (u2 in YEAR_UNITS) and not period: 201 elif (u2 in YEAR_UNITS) and not period:
214 period = "year" 202 period = "year"
215 return {"price": price, "currency": CURRENCY_SYMBOLS.get(cur, cur.upper() if cur and cur.isalpha() else None), "billing_period": period, "unit": unit, 203 return {"price": price, "currency": CURRENCY_SYMBOLS.get(cur, cur.upper() if cur and cur.isalpha() else None), "billing_period": period, "unit": unit,
216− "price_text": t[max(0, m.start() - 12):m.end() + 40].strip()[:80], "contact_sales": bool(CONTACT_RE.search(t))} 204+ "price_text": price_text_from(t) or t[m.start():m.end()].strip()[:80], "contact_sales": bool(CONTACT_RE.search(t))}
217 205
218 206
219−def _plan_from_lines(name: str, lines: list[str]) -> ExtractedPlan | None: 207+PLAN_NAME_SCAN_LINES = 3 # the plan name is one of the first lines before the price (eyebrows / marketing headings are skipped)
208+
209+
210+def _plan_name_from(lines: list[str], path: str) -> tuple[str | None, list[str]]:
211+ """(plan name, remaining lines): the first valid short label before the price line (eyebrows such as "Most popular" are skipped).
212+ Otherwise the heading just above the card (its path tail) names the tier — unless that heading is generic ("Plans", "Pricing") or a
213+ marketing sentence, in which case the card yields no plan."""
214+ for i, ln in enumerate(lines[:PLAN_NAME_SCAN_LINES]):
215+ if plan_name_ok(ln): # "Free" is a valid tier name even though it also reads as a price
216+ return ln, lines[i + 1:]
217+ if PRICE_RE.search(ln) or parse_price(ln) is not None:
218+ break
219+ tail = path.split(" > ")[-1] if path else ""
220+ return (tail if plan_name_ok(tail) else None), lines
221+
222+
223+def _plan_from_lines(name: str | None, lines: list[str]) -> ExtractedPlan | None:
224+ if not name:
225+ return None
220 price_info = None 226 price_info = None
221 features: list[str] = [] 227 features: list[str] = []
222 for ln in lines: 228 for ln in lines:
@@ -227,6 +233,8 @@ def _plan_from_lines(name: str, lines: list[str]) -> ExtractedPlan | None:
227 continue 233 continue
228 if 2 <= len(ln) <= 140 and not parse_price(ln) and len(features) < 25: 234 if 2 <= len(ln) <= 140 and not parse_price(ln) and len(features) < 25:
229 features.append(ln) 235 features.append(ln)
236+ if price_info is None and FREE_RE.match(name):
237+ price_info = parse_price(name) # a tier literally called "Free" with no separate price line
230 if price_info is None: 238 if price_info is None:
231 return None 239 return None
232 return ExtractedPlan(plan_name=name[:80], price=price_info["price"], price_text=price_info["price_text"], currency=price_info["currency"], 240 return ExtractedPlan(plan_name=name[:80], price=price_info["price"], price_text=price_info["price_text"], currency=price_info["currency"],
@@ -238,7 +246,7 @@ def extract_plans(page: NormalizedPage) -> list[ExtractedPlan]:
238 seen: set[str] = set() 246 seen: set[str] = set()
239 247
240 def add(plan: ExtractedPlan | None) -> None: 248 def add(plan: ExtractedPlan | None) -> None:
241− if plan is None: 249+ if plan is None or not plan_verdict(plan).ok:
242 return 250 return
243 key = norm_name(plan.plan_name) 251 key = norm_name(plan.plan_name)
244 if not key or key in seen or len(out) >= MAX_PLANS: 252 if not key or key in seen or len(out) >= MAX_PLANS:
@@ -252,14 +260,14 @@ def extract_plans(page: NormalizedPage) -> list[ExtractedPlan]:
252 lines = [ln.strip() for ln in b.text.split("\n") if ln.strip()] 260 lines = [ln.strip() for ln in b.text.split("\n") if ln.strip()]
253 if not lines: 261 if not lines:
254 continue 262 continue
255− name = lines[0] if parse_price(lines[0]) is None and len(lines[0]) <= 60 else (b.path.split(" > ")[-1] if b.path else "Plan") 263+ name, rest = _plan_name_from(lines, b.path)
256− add(_plan_from_lines(name, lines[1:] if lines[0] == name else lines)) 264+ add(_plan_from_lines(name, rest))
257 if not out: # fallback: heading followed by a price before the next heading (common in hand-rolled pricing tables) 265 if not out: # fallback: heading followed by a price before the next heading (common in hand-rolled pricing tables)
258 blocks = page.blocks 266 blocks = page.blocks
259 i = 0 267 i = 0
260 while i < len(blocks): 268 while i < len(blocks):
261 b = blocks[i] 269 b = blocks[i]
262− if b.kind == "heading" and int(b.attrs.get("level", 2)) >= 2 and len(b.text) <= 60: 270+ if b.kind == "heading" and int(b.attrs.get("level", 2)) >= 2 and plan_name_ok(b.text):
263 j = i + 1 271 j = i + 1
264 lines: list[str] = [] 272 lines: list[str] = []
265 while j < len(blocks) and blocks[j].kind != "heading" and j - i <= 10: 273 while j < len(blocks) and blocks[j].kind != "heading" and j - i <= 10:
@@ -288,10 +296,8 @@ LOCATION_KIND_RULES: list[tuple[str, re.Pattern[str]]] = [
288 ("data_center", re.compile(r"\b(data ?cent(er|re)|datacenter|server farm)\b", re.IGNORECASE)), 296 ("data_center", re.compile(r"\b(data ?cent(er|re)|datacenter|server farm)\b", re.IGNORECASE)),
289 ("store", re.compile(r"\b(store|shop|boutique|showroom|outlet|dealer(ship)?|branch|agence|filiale)\b", re.IGNORECASE)), 297 ("store", re.compile(r"\b(store|shop|boutique|showroom|outlet|dealer(ship)?|branch|agence|filiale)\b", re.IGNORECASE)),
290 ] 298 ]
291−STREET_RE = re.compile(r"\b\d{1,5}[a-z]?\s+[^\n,]{2,40}\b(street|st\.?|avenue|ave\.?|road|rd\.?|boulevard|blvd\.?|drive|dr\.?|lane|ln\.?|way|place|pl\.?|"
292− r"square|plaza|court|ct\.?|parkway|highway|straße|strasse|str\.|allee|platz|weg|rue|avenue|boulevard|avenida|calle|via|piazza)\b"
293− r"|\b(rue|avenue|boulevard|via|calle|avenida)\s+[^\n,]{2,40}\s\d{1,5}\b", re.IGNORECASE)
294 CITY_COUNTRY_RE = re.compile(r"^([A-ZÀ-Ý][\w'’.\- ]{1,40}),\s*([A-Za-zÀ-ÿ .]{2,40})$") 299 CITY_COUNTRY_RE = re.compile(r"^([A-ZÀ-Ý][\w'’.\- ]{1,40}),\s*([A-Za-zÀ-ÿ .]{2,40})$")
300+MAX_VENUE_LINE_CHARS = 60 # "Hormuz Grand Hotel" — a venue line under a country heading
295 301
296 302
297 def _location_from_lines(name: str, lines: list[str], href: str | None = None) -> ExtractedLocation | None: 303 def _location_from_lines(name: str, lines: list[str], href: str | None = None) -> ExtractedLocation | None:
@@ -305,8 +311,10 @@ def _location_from_lines(name: str, lines: list[str], href: str | None = None) -
305 probe = (ln[:street.start()] + " " + ln[street.end():]).strip(" ,") if street else ln 311 probe = (ln[:street.start()] + " " + ln[street.end():]).strip(" ,") if street else ln
306 if country is None and probe: 312 if country is None and probe:
307 loc = parse_location(probe) 313 loc = parse_location(probe)
308− if loc["country"] or loc["region"]: 314+ if loc["country"]: # the line that states the country is the authoritative "City, Country" line
309− city, region, country = city or loc["city"], loc["region"], loc["country"] 315+ city, region, country = loc["city"] or city, loc["region"], loc["country"]
316+ elif loc["region"]:
317+ city, region = city or loc["city"], loc["region"]
310 if country is None: 318 if country is None:
311 c = country_code(name) 319 c = country_code(name)
312 if c: 320 if c:
@@ -315,7 +323,15 @@ def _location_from_lines(name: str, lines: list[str], href: str | None = None) -
315 m = CITY_COUNTRY_RE.match(name) 323 m = CITY_COUNTRY_RE.match(name)
316 if m and country_code(m.group(2)): 324 if m and country_code(m.group(2)):
317 city, country = m.group(1).strip(), country_code(m.group(2)) 325 city, country = m.group(1).strip(), country_code(m.group(2))
318− if city is None and country is not None and looks_like_city(name): 326+ if country_code(name) and city is None:
327+ # heading is a country ("Oman") — the first short line that is neither an address nor a place is the venue / office name
328+ venue = next((ln for ln in lines if ln != address and len(ln) <= MAX_VENUE_LINE_CHARS and not parse_location(ln)["country"]
329+ and not country_code(ln) and not any(ch.isdigit() for ch in ln)), None)
330+ if venue:
331+ name = venue
332+ elif is_known_city(name):
333+ city = name # city-states: Singapore, Monaco, Hong Kong
334+ elif city is None and country is not None and looks_like_city(name):
319 city = name 335 city = name
320 if city is None and country is None and address is None and kind == "office": 336 if city is None and country is None and address is None and kind == "office":
321 return None 337 return None
@@ -332,6 +348,7 @@ def extract_locations(page: NormalizedPage) -> list[ExtractedLocation]:
332 seen: set[str] = set() 348 seen: set[str] = set()
333 349
334 def add(loc: ExtractedLocation | None) -> None: 350 def add(loc: ExtractedLocation | None) -> None:
351+ loc = normalize_location(loc) if loc is not None else None
335 if loc is None: 352 if loc is None:
336 return 353 return
337 key = norm_name(loc.name) 354 key = norm_name(loc.name)
@@ -371,6 +388,7 @@ def extract_locations(page: NormalizedPage) -> list[ExtractedLocation]:
371 # ------------------------------------------------------------------------------------------------------------ news 388 # ------------------------------------------------------------------------------------------------------------ news
372 389
373 NEWS_NOISE_ANCHOR = re.compile(r"^(read more|learn more|more|continue reading|view all|see all|all news|all posts|next|previous|older|newer|\d+)$", re.IGNORECASE) 390 NEWS_NOISE_ANCHOR = re.compile(r"^(read more|learn more|more|continue reading|view all|see all|all news|all posts|next|previous|older|newer|\d+)$", re.IGNORECASE)
391+MIN_NEWS_TITLE_CHARS = 3 # anything shorter is a glyph or a counter; the precision rule decides the rest (words / date)
374 392
375 393
376 def _time_index(html: str, base_url: str) -> dict[str, Any]: 394 def _time_index(html: str, base_url: str) -> dict[str, Any]:
@@ -407,14 +425,16 @@ def extract_news(page: NormalizedPage, html: str, *, surface: str, base_url: str
407 425
408 def add(title: str, url: str, published: Any, summary: str | None = None) -> None: 426 def add(title: str, url: str, published: Any, summary: str | None = None) -> None:
409 title = normalize_whitespace(title) 427 title = normalize_whitespace(title)
410− if len(title) < 8 or NEWS_NOISE_ANCHOR.match(title) or len(out) >= MAX_NEWS: 428+ if len(title) < MIN_NEWS_TITLE_CHARS or NEWS_NOISE_ANCHOR.match(title) or len(out) >= MAX_NEWS:
411 return 429 return
412 canon = canonicalize_url(url) 430 canon = canonicalize_url(url)
413 if canon in seen or canon == canonicalize_url(base_url) or is_static_asset(url): 431 if canon in seen or canon == canonicalize_url(base_url) or is_static_asset(url):
414 return 432 return
433+ published = published or times.get(canon)
434+ if not news_verdict(title, url=url, published_at=published).ok:
435+ return
415 seen.add(canon) 436 seen.add(canon)
416− out.append(ExtractedNewsItem(title=title[:300], url=url, published_at=published or times.get(canon), summary=(summary or None), category=category, 437+ out.append(ExtractedNewsItem(title=title[:300], url=url, published_at=published, summary=(summary or None), category=category, language=page.lang))
417− language=page.lang))
418 438
419 for a in page.jsonld.get("articles", []) + page.microdata.get("articles", []): 439 for a in page.jsonld.get("articles", []) + page.microdata.get("articles", []):
420 title, url = text_of(a.get("headline")) or text_of(a.get("name")), a.get("url") or (a.get("mainEntityOfPage") if isinstance(a.get("mainEntityOfPage"), str) else None) 440 title, url = text_of(a.get("headline")) or text_of(a.get("name")), a.get("url") or (a.get("mainEntityOfPage") if isinstance(a.get("mainEntityOfPage"), str) else None)
@@ -425,8 +445,9 @@ def extract_news(page: NormalizedPage, html: str, *, surface: str, base_url: str
425 if b.kind != "news_item" or not href: 445 if b.kind != "news_item" or not href:
426 continue 446 continue
427 lines = [ln.strip() for ln in b.text.split("\n") if ln.strip()] 447 lines = [ln.strip() for ln in b.text.split("\n") if ln.strip()]
428− title = next((ln for ln in lines if len(ln) >= 12 and not (len(ln) <= 32 and date_from_text(ln))), lines[0] if lines else "") 448+ undated = [ln for ln in lines if not (len(ln) <= 32 and date_from_text(ln))]
429− rest = [ln for ln in lines if ln != title and not (len(ln) <= 32 and date_from_text(ln))] 449+ title = next((ln for ln in undated if len(ln) >= 12), undated[0] if undated else "")
450+ rest = [ln for ln in undated if ln != title]
430 add(title, href, date_from_text(b.text) or times.get(canonicalize_url(href)) or date_from_url(href), " ".join(rest[:2])[:300] or None) 451 add(title, href, date_from_text(b.text) or times.get(canonicalize_url(href)) or date_from_url(href), " ".join(rest[:2])[:300] or None)
431 if len(out) < 3: # fallback: article-like links in the main region (same site, deeper path, long anchor) 452 if len(out) < 3: # fallback: article-like links in the main region (same site, deeper path, long anchor)
432 base_depth = urlparse(base_url).path.strip("/").count("/") 453 base_depth = urlparse(base_url).path.strip("/").count("/")
@@ -451,11 +472,7 @@ JOB_NOISE_ANCHOR = re.compile(r"^(apply( now)?|view( all)?( jobs| openings| role
451 r"browse jobs|explore|more|back|home|filter|next|previous|\d+)$", re.IGNORECASE) 472 r"browse jobs|explore|more|back|home|filter|next|previous|\d+)$", re.IGNORECASE)
452 473
453 474
454−def _states_location(text: str) -> bool: 475+_states_location = states_job_location
455− if len(text) > 80:
456− return False
457− p = parse_location(text)
458− return bool(p["city"] or p["country"] or p["remote"] or p["region"])
459 476
460 477
461 def extract_jobs_html(page: NormalizedPage, *, base_url: str) -> list[ExtractedJob]: 478 def extract_jobs_html(page: NormalizedPage, *, base_url: str) -> list[ExtractedJob]:
@@ -466,11 +483,14 @@ def extract_jobs_html(page: NormalizedPage, *, base_url: str) -> list[ExtractedJ
466 title = normalize_whitespace(title).strip(" -–—|·") 483 title = normalize_whitespace(title).strip(" -–—|·")
467 if not (4 <= len(title) <= 140) or JOB_NOISE_ANCHOR.match(title) or len(jobs) >= MAX_JOBS: 484 if not (4 <= len(title) <= 140) or JOB_NOISE_ANCHOR.match(title) or len(jobs) >= MAX_JOBS:
468 return 485 return
469− key = (canonicalize_url(url) if url else "") + "|" + title.lower() 486+ job = refine_job(ExtractedJob(title=title, url=url, location_text=location, department=department))
487+ if job is None:
488+ return
489+ key = (canonicalize_url(url) if url else "") + "|" + job.title.lower()
470 if key in seen: 490 if key in seen:
471 return 491 return
472 seen.add(key) 492 seen.add(key)
473− jobs.append(finish_job(ExtractedJob(title=title, url=url, location_text=location, department=department))) 493+ jobs.append(finish_job(job))
474 494
475 jl_jobs = jobs_from_jsonld(list(page.jsonld.get("job_postings", [])) + list(page.microdata.get("job_postings", [])), page_url=base_url) 495 jl_jobs = jobs_from_jsonld(list(page.jsonld.get("job_postings", [])) + list(page.microdata.get("job_postings", [])), page_url=base_url)
476 for j in jl_jobs: 496 for j in jl_jobs:
@@ -488,7 +508,7 @@ def extract_jobs_html(page: NormalizedPage, *, base_url: str) -> list[ExtractedJ
488 if not lines: 508 if not lines:
489 continue 509 continue
490 title = lines[0] 510 title = lines[0]
491− loc = next((ln for ln in lines[1:6] if _states_location(ln)), None) 511+ loc = next((ln for ln in lines[1:6] if _states_location(ln) or is_known_city(ln)), None)
492 dept = next((ln for ln in lines[1:6] if ln != loc and 2 <= len(ln) <= 40 and not _states_location(ln) and not date_from_text(ln)), None) 512 dept = next((ln for ln in lines[1:6] if ln != loc and 2 <= len(ln) <= 40 and not _states_location(ln) and not date_from_text(ln)), None)
493 before = len(jobs) 513 before = len(jobs)
494 add(title, href, loc, dept) 514 add(title, href, loc, dept)
@@ -517,6 +537,8 @@ def extract_products(page: NormalizedPage) -> list[ExtractedProduct]:
517 key = norm_name(name) 537 key = norm_name(name)
518 if not (2 <= len(name) <= 90) or not key or key in seen or len(out) >= MAX_PRODUCTS or NEWS_NOISE_ANCHOR.match(name): 538 if not (2 <= len(name) <= 90) or not key or key in seen or len(out) >= MAX_PRODUCTS or NEWS_NOISE_ANCHOR.match(name):
519 return 539 return
540+ if not product_verdict(name).ok:
541+ return
520 seen.add(key) 542 seen.add(key)
521 out.append(ExtractedProduct(name=name, url=url, category=category, description=(description or None))) 543 out.append(ExtractedProduct(name=name, url=url, category=category, description=(description or None)))
522 544
modified src/companyatlas/sdk/normalize.py +57 −12
Large diff (140 lines) is not loaded by default to keep the page fast.Load diff
modified src/companyatlas/services/pipeline.py +9 −3
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added tests/test_extraction_precision.py +299 −0
Large diff (300 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme