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>
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 | 44 | Job identity = `job_fingerprint(title, location_text, external_id | url)`; structured boards also give each job a stable block |
| 45 | 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 | 83 | ## Adding a connector family |
| 48 | 84 | |
| 49 | 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 & 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 & 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 | 66 | AMBIGUOUS_CODES = (set(US_STATES.values()) | set(CA_PROVINCES.values())) & ISO2 |
| 67 | 67 | REMOTE_RE = re.compile(r"\b(remote|work from home|wfh|anywhere|distributed|télétravail|homeoffice|home office)\b", re.IGNORECASE) |
| 68 | 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 | 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 | 9 | Structured data (JSON-LD / microdata) is used first, DOM heuristics second, and nothing is ever invented: no address, no country, no |
| 10 | 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 | 13 | from __future__ import annotations |
| 13 | 14 | |
@@ -19,6 +20,22 @@ from urllib.parse import urlparse | ||
| 19 | 20 | from selectolax.lexbor import LexborHTMLParser |
| 20 | 21 | |
| 21 | 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 | 39 | from companyatlas.connectors._util import ( |
| 23 | 40 | country_code, |
| 24 | 41 | date_from_text, |
@@ -57,52 +74,22 @@ PRODUCT_SURFACES = {Surface.PRODUCTS, Surface.SERVICES, Surface.SOLUTIONS} | ||
| 57 | 74 | |
| 58 | 75 | # ------------------------------------------------------------------------------------------------------------ people |
| 59 | 76 | |
| 60 | −ROLE_RULES: list[tuple[str, re.Pattern[str]]] = [ | |
| 61 | − ("founder", re.compile(r"\b(co-?founder|founder|fondat(eur|rice)|gründer(in)?)\b", re.IGNORECASE)), | |
| 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)), | |
| 63 | − ("cfo", re.compile(r"\b(chief financial( officer)?|cfo|finanzvorstand|directeur financier)\b", re.IGNORECASE)), | |
| 64 | − ("cto", re.compile(r"\b(chief technology( officer)?|chief technical( officer)?|cto)\b", re.IGNORECASE)), | |
| 65 | − ("coo", re.compile(r"\b(chief operating( officer)?|coo)\b", re.IGNORECASE)), | |
| 66 | − ("chair", re.compile(r"\b(chair(man|woman|person)?|executive chair|présidente? du conseil|vorsitzende[rn]?)\b", re.IGNORECASE)), | |
| 67 | − ("president", re.compile(r"\b(?<!vice )(?<!vice-)president(?! of)|président(?!e? du conseil)\b", re.IGNORECASE)), | |
| 68 | − ("board", re.compile(r"\b(board member|member of the (supervisory |advisory )?board|non-executive director|independent director|" | |
| 69 | − r"administrat(eur|rice)|aufsichtsrat|conseil d'administration|director(?= \(board)|trustee)\b", re.IGNORECASE)), | |
| 70 | − ("vp", re.compile(r"\b(vice[- ]president|vp|evp|svp|avp)\b", re.IGNORECASE)), | |
| 71 | − ("head", re.compile(r"\b(head of|head,|general manager|gm|leiter(in)?|directeur|directrice|director|managing partner|partner)\b", re.IGNORECASE)), | |
| 72 | −] | |
| 73 | −CHIEF_RE = re.compile(r"\b(chief\b|c[a-z]{1,3}o\b)", re.IGNORECASE) | |
| 74 | −EXEC_CATEGORIES = {"ceo", "cfo", "cto", "coo", "founder", "president", "chair"} | |
| 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)" | |
| 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)) | |
| 77 | +# Role vocabulary, name shape and title cleaning live in `_precision` (shared with the pipeline); re-exported here for callers/tests. | |
| 78 | +looks_like_name = looks_like_person_name | |
| 79 | +looks_like_title = looks_like_role_title | |
| 80 | +PERSON_CARD_SCAN_LINES = 4 # a person card states the name within its first lines | |
| 81 | +PERSON_TITLE_WINDOW = 3 # …and the title within the next few | |
| 82 | + | |
| 83 | + | |
| 84 | +def _card_person(lines: list[str]) -> tuple[str, str | None] | None: | |
| 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.""" | |
| 86 | + name_idx = next((i for i, ln in enumerate(lines[:PERSON_CARD_SCAN_LINES]) if looks_like_name(ln)), None) | |
| 87 | + if name_idx is None: | |
| 88 | + return None | |
| 89 | + before = [x for x in lines[:name_idx] if looks_like_title(x)] | |
| 90 | + after = [x for x in lines[name_idx + 1:name_idx + 1 + PERSON_TITLE_WINDOW] if not looks_like_name(x)] | |
| 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) | |
| 92 | + return lines[name_idx], title | |
| 106 | 93 | |
| 107 | 94 | |
| 108 | 95 | def extract_people(page: NormalizedPage) -> list[ExtractedPerson]: |
@@ -110,27 +97,28 @@ def extract_people(page: NormalizedPage) -> list[ExtractedPerson]: | ||
| 110 | 97 | seen: set[str] = set() |
| 111 | 98 | |
| 112 | 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 | 104 | key = norm_name(name) |
| 115 | 105 | if not key or key in seen or len(out) >= MAX_PEOPLE: |
| 116 | 106 | return |
| 117 | 107 | seen.add(key) |
| 118 | 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 | 111 | for p in page.jsonld.get("persons", []) + page.microdata.get("persons", []): |
| 122 | 112 | name = text_of(p.get("name")) |
| 123 | − if name and looks_like_name(name): | |
| 113 | + if name: | |
| 124 | 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 | 115 | for b in page.blocks: |
| 126 | 116 | if b.kind != "person": |
| 127 | 117 | continue |
| 128 | 118 | lines = [ln.strip() for ln in b.text.split("\n") if ln.strip()] |
| 129 | − for i, ln in enumerate(lines[:4]): | |
| 130 | − if looks_like_name(ln): | |
| 131 | − title = next((x for x in lines[i + 1:i + 4] if not looks_like_name(x) and len(x) <= 140), None) | |
| 132 | − add(ln, title, b.attrs.get("href")) | |
| 133 | − break | |
| 119 | + found = _card_person(lines) | |
| 120 | + if found is not None: | |
| 121 | + add(found[0], found[1], b.attrs.get("href")) | |
| 134 | 122 | if len(out) < 2: # fallback: heading = name, next short block = title |
| 135 | 123 | blocks = page.blocks |
| 136 | 124 | for i, b in enumerate(blocks): |
@@ -167,11 +155,11 @@ def parse_price(text: str) -> dict[str, Any] | None: | ||
| 167 | 155 | """→ {price, currency, billing_period, unit, price_text, contact_sales} or None when the text states no price.""" |
| 168 | 156 | t = normalize_whitespace(text) |
| 169 | 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 | 159 | m = PRICE_RE.search(t) |
| 172 | 160 | if m is None: |
| 173 | 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 | 163 | return None |
| 176 | 164 | cur = m.group("cur") or m.group("cur2") |
| 177 | 165 | amt = m.group("amt") or m.group("amt2") |
@@ -213,10 +201,28 @@ def parse_price(text: str) -> dict[str, Any] | None: | ||
| 213 | 201 | elif (u2 in YEAR_UNITS) and not period: |
| 214 | 202 | period = "year" |
| 215 | 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 | 226 | price_info = None |
| 221 | 227 | features: list[str] = [] |
| 222 | 228 | for ln in lines: |
@@ -227,6 +233,8 @@ def _plan_from_lines(name: str, lines: list[str]) -> ExtractedPlan | None: | ||
| 227 | 233 | continue |
| 228 | 234 | if 2 <= len(ln) <= 140 and not parse_price(ln) and len(features) < 25: |
| 229 | 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 | 238 | if price_info is None: |
| 231 | 239 | return None |
| 232 | 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 | 246 | seen: set[str] = set() |
| 239 | 247 | |
| 240 | 248 | def add(plan: ExtractedPlan | None) -> None: |
| 241 | − if plan is None: | |
| 249 | + if plan is None or not plan_verdict(plan).ok: | |
| 242 | 250 | return |
| 243 | 251 | key = norm_name(plan.plan_name) |
| 244 | 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 | 260 | lines = [ln.strip() for ln in b.text.split("\n") if ln.strip()] |
| 253 | 261 | if not lines: |
| 254 | 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") | |
| 256 | − add(_plan_from_lines(name, lines[1:] if lines[0] == name else lines)) | |
| 263 | + name, rest = _plan_name_from(lines, b.path) | |
| 264 | + add(_plan_from_lines(name, rest)) | |
| 257 | 265 | if not out: # fallback: heading followed by a price before the next heading (common in hand-rolled pricing tables) |
| 258 | 266 | blocks = page.blocks |
| 259 | 267 | i = 0 |
| 260 | 268 | while i < len(blocks): |
| 261 | 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 | 271 | j = i + 1 |
| 264 | 272 | lines: list[str] = [] |
| 265 | 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 | 296 | ("data_center", re.compile(r"\b(data ?cent(er|re)|datacenter|server farm)\b", re.IGNORECASE)), |
| 289 | 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 | 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 | 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 | 311 | probe = (ln[:street.start()] + " " + ln[street.end():]).strip(" ,") if street else ln |
| 306 | 312 | if country is None and probe: |
| 307 | 313 | loc = parse_location(probe) |
| 308 | − if loc["country"] or loc["region"]: | |
| 309 | − city, region, country = city or loc["city"], loc["region"], loc["country"] | |
| 314 | + if loc["country"]: # the line that states the country is the authoritative "City, Country" line | |
| 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 | 318 | if country is None: |
| 311 | 319 | c = country_code(name) |
| 312 | 320 | if c: |
@@ -315,7 +323,15 @@ def _location_from_lines(name: str, lines: list[str], href: str | None = None) - | ||
| 315 | 323 | m = CITY_COUNTRY_RE.match(name) |
| 316 | 324 | if m and country_code(m.group(2)): |
| 317 | 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 | 335 | city = name |
| 320 | 336 | if city is None and country is None and address is None and kind == "office": |
| 321 | 337 | return None |
@@ -332,6 +348,7 @@ def extract_locations(page: NormalizedPage) -> list[ExtractedLocation]: | ||
| 332 | 348 | seen: set[str] = set() |
| 333 | 349 | |
| 334 | 350 | def add(loc: ExtractedLocation | None) -> None: |
| 351 | + loc = normalize_location(loc) if loc is not None else None | |
| 335 | 352 | if loc is None: |
| 336 | 353 | return |
| 337 | 354 | key = norm_name(loc.name) |
@@ -371,6 +388,7 @@ def extract_locations(page: NormalizedPage) -> list[ExtractedLocation]: | ||
| 371 | 388 | # ------------------------------------------------------------------------------------------------------------ news |
| 372 | 389 | |
| 373 | 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 | 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 | 426 | def add(title: str, url: str, published: Any, summary: str | None = None) -> None: |
| 409 | 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 | 429 | return |
| 412 | 430 | canon = canonicalize_url(url) |
| 413 | 431 | if canon in seen or canon == canonicalize_url(base_url) or is_static_asset(url): |
| 414 | 432 | return |
| 433 | + published = published or times.get(canon) | |
| 434 | + if not news_verdict(title, url=url, published_at=published).ok: | |
| 435 | + return | |
| 415 | 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, | |
| 417 | − language=page.lang)) | |
| 437 | + out.append(ExtractedNewsItem(title=title[:300], url=url, published_at=published, summary=(summary or None), category=category, language=page.lang)) | |
| 418 | 438 | |
| 419 | 439 | for a in page.jsonld.get("articles", []) + page.microdata.get("articles", []): |
| 420 | 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 | 445 | if b.kind != "news_item" or not href: |
| 426 | 446 | continue |
| 427 | 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 "") | |
| 429 | − rest = [ln for ln in lines if ln != title and not (len(ln) <= 32 and date_from_text(ln))] | |
| 448 | + undated = [ln for ln in lines if 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 | 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 | 452 | if len(out) < 3: # fallback: article-like links in the main region (same site, deeper path, long anchor) |
| 432 | 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 | 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: | |
| 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"]) | |
| 475 | +_states_location = states_job_location | |
| 459 | 476 | |
| 460 | 477 | |
| 461 | 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 | 483 | title = normalize_whitespace(title).strip(" -–—|·") |
| 467 | 484 | if not (4 <= len(title) <= 140) or JOB_NOISE_ANCHOR.match(title) or len(jobs) >= MAX_JOBS: |
| 468 | 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 | 490 | if key in seen: |
| 471 | 491 | return |
| 472 | 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 | 495 | jl_jobs = jobs_from_jsonld(list(page.jsonld.get("job_postings", [])) + list(page.microdata.get("job_postings", [])), page_url=base_url) |
| 476 | 496 | for j in jl_jobs: |
@@ -488,7 +508,7 @@ def extract_jobs_html(page: NormalizedPage, *, base_url: str) -> list[ExtractedJ | ||
| 488 | 508 | if not lines: |
| 489 | 509 | continue |
| 490 | 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 | 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 | 513 | before = len(jobs) |
| 494 | 514 | add(title, href, loc, dept) |
@@ -517,6 +537,8 @@ def extract_products(page: NormalizedPage) -> list[ExtractedProduct]: | ||
| 517 | 537 | key = norm_name(name) |
| 518 | 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 | 539 | return |
| 540 | + if not product_verdict(name).ok: | |
| 541 | + return | |
| 520 | 542 | seen.add(key) |
| 521 | 543 | out.append(ExtractedProduct(name=name, url=url, category=category, description=(description or None))) |
| 522 | 544 | |
modified
src/companyatlas/sdk/normalize.py
+57 −12
@@ -340,12 +340,55 @@ def _card_kind(node: LexborNode, *, allow_container: bool = False) -> str | None | ||
| 340 | 340 | return kind |
| 341 | 341 | |
| 342 | 342 | |
| 343 | +LISTING_LINK_SHARE = 0.6 # a list whose items mostly carry links is a listing (jobs, news, products), not a bullet list | |
| 344 | +LISTING_MIN_ITEMS = 2 | |
| 345 | + | |
| 346 | + | |
| 347 | +def _list_items(node: LexborNode) -> list[LexborNode]: | |
| 348 | + items: list[LexborNode] = [] | |
| 349 | + for c in node.iter(): | |
| 350 | + if c.tag in ("li", "tr"): | |
| 351 | + items.append(c) | |
| 352 | + elif c.tag in ("tbody", "thead", "tfoot"): | |
| 353 | + items.extend(x for x in c.iter() if x.tag == "tr") | |
| 354 | + return items | |
| 355 | + | |
| 356 | + | |
| 357 | +def _is_listing(node: LexborNode) -> bool: | |
| 358 | + """`ul`/`ol`/`table` whose items are card-like or mostly linked — the rows are entities, the wrapper is a container.""" | |
| 359 | + items = _list_items(node) | |
| 360 | + if len(items) < LISTING_MIN_ITEMS: | |
| 361 | + return False | |
| 362 | + if any(_card_kind(i, allow_container=True) for i in items): | |
| 363 | + return True | |
| 364 | + linked = sum(1 for i in items if i.css_first("a[href]") is not None) | |
| 365 | + return linked >= len(items) * LISTING_LINK_SHARE | |
| 366 | + | |
| 367 | + | |
| 368 | +def _card_text(node: LexborNode) -> str: | |
| 369 | + """Card text with one line per direct child element, so inline cells (`<a>title</a><span>city</span>`) do not merge into one line.""" | |
| 370 | + parts: list[str] = [] | |
| 371 | + for c in node.iter(include_text=True): | |
| 372 | + if c.tag == "-text": | |
| 373 | + t = c.text_content | |
| 374 | + if t and t.strip(): | |
| 375 | + parts.append(t) | |
| 376 | + elif c.tag in DROP_TAGS or c.tag == "-comment" or c.tag == "br" or _is_hidden(c) or _is_cookie(c): | |
| 377 | + continue | |
| 378 | + else: | |
| 379 | + parts.append(_node_text(c)) | |
| 380 | + return normalize_whitespace(html_lib.unescape("\n".join(p for p in parts if p))) | |
| 381 | + | |
| 382 | + | |
| 343 | 383 | def _is_card_container(node: LexborNode) -> bool: |
| 344 | − """A grid/list wrapper (`.products`, `.news-list`, `.team-grid`) holds ≥ 2 card-like children — segment the children instead.""" | |
| 384 | + """A grid/list wrapper (`.products`, `.news-list`, `.team-grid`, `.job-list`) holds ≥ 2 card-like children — or a listing-like list/table — | |
| 385 | + so the children are segmented instead of the wrapper becoming a single card. A feature `<ul>` inside a pricing card is not a listing.""" | |
| 345 | 386 | n = 0 |
| 346 | 387 | for child in node.iter(): |
| 347 | 388 | if child.tag not in ("div", "section", "article", "li", "a", "figure", "tr", "ul", "ol", "table"): |
| 348 | 389 | continue |
| 390 | + if child.tag in ("ul", "ol", "table") and _is_listing(child): | |
| 391 | + return True | |
| 349 | 392 | is_card = child.tag in ("ul", "ol", "table") or _card_kind(child, allow_container=True) is not None |
| 350 | 393 | if not is_card and child.tag == "div": |
| 351 | 394 | is_card = any(_card_kind(g, allow_container=True) for g in child.iter() if g.tag in ("div", "article", "li", "a")) |
@@ -388,7 +431,8 @@ class _Segmenter: | ||
| 388 | 431 | self.order += 1 |
| 389 | 432 | |
| 390 | 433 | # ---------------------------------------------------------------- traversal |
| 391 | − def walk(self, node: LexborNode, region: str, depth: int = 0) -> None: | |
| 434 | + def walk(self, node: LexborNode, region: str, depth: int = 0, hint: str | None = None) -> None: | |
| 435 | + """`hint` is the card kind of an enclosing container (`.job-list`), inherited by plain list rows / table rows underneath it.""" | |
| 392 | 436 | if depth > 300: |
| 393 | 437 | return |
| 394 | 438 | for c in node.iter(include_text=True): |
@@ -436,16 +480,16 @@ class _Segmenter: | ||
| 436 | 480 | self.add("faq", _node_text(c)) |
| 437 | 481 | continue |
| 438 | 482 | if tag in ("ul", "ol", "dl", "menu"): |
| 439 | − self.list_block(c) | |
| 483 | + self.list_block(c, hint) | |
| 440 | 484 | continue |
| 441 | 485 | if tag == "table": |
| 442 | − self.table_block(c) | |
| 486 | + self.table_block(c, hint) | |
| 443 | 487 | continue |
| 444 | 488 | if tag in ("hr", "br", "wbr"): |
| 445 | 489 | continue |
| 446 | 490 | card = _card_kind(c) if tag in ("div", "section", "article", "li", "a", "figure", "aside", "tr", "td") else None |
| 447 | 491 | if card is not None: |
| 448 | − txt = _node_text(c) | |
| 492 | + txt = _card_text(c) | |
| 449 | 493 | if txt and len(txt) <= CARD_MAX_TEXT: |
| 450 | 494 | attrs: dict[str, Any] = {} |
| 451 | 495 | href = self._first_href(c) |
@@ -464,7 +508,8 @@ class _Segmenter: | ||
| 464 | 508 | break |
| 465 | 509 | self.add("hero", txt, path="", attrs={"href": self._first_href(c)} if self._first_href(c) else {}) |
| 466 | 510 | continue |
| 467 | − self.walk(c, region, depth + 1) | |
| 511 | + container_kind = _card_kind(c, allow_container=True) if tag in ("div", "section", "article", "ul", "ol") else None | |
| 512 | + self.walk(c, region, depth + 1, container_kind or hint) | |
| 468 | 513 | |
| 469 | 514 | def _has_h1(self, node: LexborNode) -> bool: |
| 470 | 515 | return node.css_first("h1") is not None and len(_node_text(node)) < 2500 |
@@ -477,34 +522,34 @@ class _Segmenter: | ||
| 477 | 522 | |
| 478 | 523 | return absolutize(self.base_url, _attr(a, "href")) |
| 479 | 524 | |
| 480 | − def list_block(self, node: LexborNode) -> None: | |
| 525 | + def list_block(self, node: LexborNode, hint: str | None = None) -> None: | |
| 481 | 526 | items = [li for li in node.iter() if li.tag in ("li", "dt", "dd")] |
| 482 | 527 | if not items: |
| 483 | 528 | txt = _node_text(node) |
| 484 | 529 | if txt: |
| 485 | 530 | self.add("list", txt) |
| 486 | 531 | return |
| 487 | − texts = [_node_text(li) for li in items] | |
| 532 | + texts = [_card_text(li) for li in items] | |
| 488 | 533 | texts_nonempty = [t for t in texts if t] |
| 489 | 534 | if not texts_nonempty: |
| 490 | 535 | return |
| 491 | 536 | avg = sum(len(t) for t in texts_nonempty) / len(texts_nonempty) |
| 492 | 537 | has_links = sum(1 for li in items if li.css_first("a[href]") is not None) |
| 493 | − listing_like = len(items) >= 3 and (has_links >= len(items) * 0.6 or avg > 60) | |
| 538 | + listing_like = len(items) >= 3 and (has_links >= len(items) * LISTING_LINK_SHARE or avg > 60) | |
| 494 | 539 | if not listing_like or len(items) > 400: |
| 495 | 540 | self.add("list", "\n".join(texts_nonempty)[:4000]) |
| 496 | 541 | return |
| 497 | 542 | for li, t in zip(items, texts, strict=False): |
| 498 | 543 | if not t: |
| 499 | 544 | continue |
| 500 | − kind = _card_kind(li, allow_container=True) or _card_kind(node, allow_container=True) or "list" | |
| 545 | + kind = _card_kind(li, allow_container=True) or _card_kind(node, allow_container=True) or hint or "list" | |
| 501 | 546 | attrs: dict[str, Any] = {} |
| 502 | 547 | href = self._first_href(li) |
| 503 | 548 | if href: |
| 504 | 549 | attrs["href"] = href |
| 505 | 550 | self.add(kind, t[:1500], attrs=attrs) |
| 506 | 551 | |
| 507 | − def table_block(self, node: LexborNode) -> None: | |
| 552 | + def table_block(self, node: LexborNode, hint: str | None = None) -> None: | |
| 508 | 553 | rows = [tr for tr in node.css("tr")] |
| 509 | 554 | caption = node.css_first("caption") |
| 510 | 555 | cap = _node_text(caption) if caption is not None else "" |
@@ -522,7 +567,7 @@ class _Segmenter: | ||
| 522 | 567 | txt = " | ".join(c for c in cells if c) |
| 523 | 568 | if not txt: |
| 524 | 569 | continue |
| 525 | − kind = _card_kind(tr, allow_container=True) or "table" | |
| 570 | + kind = _card_kind(tr, allow_container=True) or hint or "table" | |
| 526 | 571 | attrs: dict[str, Any] = {"row": i} |
| 527 | 572 | href = self._first_href(tr) |
| 528 | 573 | if href: |
modified
src/companyatlas/services/pipeline.py
+9 −3
@@ -27,6 +27,7 @@ from typing import Any | ||
| 27 | 27 | |
| 28 | 28 | from companyatlas import archive |
| 29 | 29 | from companyatlas.config import settings |
| 30 | +from companyatlas.connectors._precision import HTML_JOB_CONNECTORS, apply_precision | |
| 30 | 31 | from companyatlas.connectors._util import is_engineering, job_fingerprint, norm_name |
| 31 | 32 | from companyatlas.db import execute, fetch_all, fetch_one, jsonb, transaction |
| 32 | 33 | from companyatlas.fetch import ( |
@@ -526,8 +527,10 @@ def _clean_label(value: str | None) -> str | None: | ||
| 526 | 527 | return v or None |
| 527 | 528 | |
| 528 | 529 | |
| 529 | −def _drop_corrupt_entities(ex: Extraction) -> None: | |
| 530 | − """Remove typed items whose labels carry replacement characters or no letters at all (mis-decoded or empty extractions).""" | |
| 530 | +def _drop_corrupt_entities(ex: Extraction, *, connector_id: str | None = None) -> None: | |
| 531 | + """Remove typed items whose labels carry replacement characters or no letters at all (mis-decoded or empty extractions), then apply the | |
| 532 | + shared precision rules (`connectors/_precision`) as the last line of defence: CTA/nav anchors, cookie categories, marketing headings, | |
| 533 | + swapped person cards. Job rules apply to HTML-scraped listings only — structured boards (ATS) are trusted.""" | |
| 531 | 534 | def ok(label: str | None) -> bool: |
| 532 | 535 | return bool(label) and "\ufffd" not in label and re.search(r"[^\W\d_]", label) is not None |
| 533 | 536 | |
@@ -551,6 +554,9 @@ def _drop_corrupt_entities(ex: Extraction) -> None: | ||
| 551 | 554 | ex.news = [n for n in ex.news if ok(n.title) and "\ufffd" not in (n.url or "")] |
| 552 | 555 | if ex.title: |
| 553 | 556 | ex.title = _clean_label(ex.title) |
| 557 | + dropped = apply_precision(ex, html_jobs=connector_id in HTML_JOB_CONNECTORS) | |
| 558 | + if dropped: | |
| 559 | + log.debug("precision rules dropped items", extra={"connector_id": connector_id, "dropped": dropped}) | |
| 554 | 560 | |
| 555 | 561 | |
| 556 | 562 | # ------------------------------------------------------------------------------------------------------------ the run |
@@ -678,7 +684,7 @@ async def run_sensor(sensor: dict[str, Any], *, fetcher: Fetcher, worker: str = | ||
| 678 | 684 | await _ledger(conn, company_id, connector.connector_id, pages, len(fetched.content)) |
| 679 | 685 | outcome.duration_ms = int((time.perf_counter() - t0) * 1000) |
| 680 | 686 | return outcome |
| 681 | − _drop_corrupt_entities(ex) | |
| 687 | + _drop_corrupt_entities(ex, connector_id=connector.connector_id) | |
| 682 | 688 | ex.normalized_hash = ex.normalized_hash or text_hash(ex.text) |
| 683 | 689 | ex.structured_hash = ex.structured_hash or _structured_hash(ex) |
| 684 | 690 | struct_hash = structural_hash(ex.blocks) |
added
tests/test_extraction_precision.py
+299 −0
@@ -0,0 +1,299 @@ | ||
| 1 | +"""Precision rules (connectors/_precision): the navigation / CTA / cookie-consent / marketing noise observed in production | |
| 2 | +(M2U64, 2026-09-13) is rejected while every real item in the fixtures is still extracted — precision must not cost recall.""" | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from datetime import UTC, datetime | |
| 6 | + | |
| 7 | +import pytest | |
| 8 | +from conftest import fixture_path | |
| 9 | + | |
| 10 | +from companyatlas.connectors import _precision as P | |
| 11 | +from companyatlas.fetch import file_result | |
| 12 | +from companyatlas.sdk import connector as C | |
| 13 | +from companyatlas.sdk.models import ( | |
| 14 | + ExtractedJob, | |
| 15 | + ExtractedLocation, | |
| 16 | + ExtractedNewsItem, | |
| 17 | + ExtractedPerson, | |
| 18 | + ExtractedPlan, | |
| 19 | + ExtractedProduct, | |
| 20 | + Extraction, | |
| 21 | +) | |
| 22 | +from companyatlas.services.pipeline import _drop_corrupt_entities | |
| 23 | + | |
| 24 | +BASE = "https://www.ex.example" | |
| 25 | +CFG = {"canonical_domain": "ex.example"} | |
| 26 | + | |
| 27 | + | |
| 28 | +def _run(surface: str, name: str, path: str): # type: ignore[no-untyped-def] | |
| 29 | + url = BASE + path | |
| 30 | + return C.get("generic-html-v1").extract({"url": url, "surface": surface, "config": CFG}, file_result(fixture_path("generic_html", name), url=url)) | |
| 31 | + | |
| 32 | + | |
| 33 | +# ------------------------------------------------------------------------------------------------------------ fixtures end to end | |
| 34 | + | |
| 35 | + | |
| 36 | +def test_careers_cta_anchors_rejected_real_listings_kept() -> None: | |
| 37 | + ex = _run("careers", "careers_cta_noise.html", "/nl/jobs") | |
| 38 | + titles = [j.title for j in ex.jobs] | |
| 39 | + assert titles == ["Senior Backend Software Engineer - Infrastructure", "Verpleegkundige spoedgevallen", "Data Analyst", "Accountmanager KMO", "Technicien réseau"] | |
| 40 | + assert not {"Voor starters die willen gáán", "Early careers bij Telenet group", "Meer info", "Sales", "Alle vacatures", "Waarom werken bij ons?"} & set(titles) | |
| 41 | + by = {j.title: j for j in ex.jobs} | |
| 42 | + assert by["Senior Backend Software Engineer - Infrastructure"].location_text == "London, United Kingdom" | |
| 43 | + assert by["Senior Backend Software Engineer - Infrastructure"].department == "Engineering" and by["Senior Backend Software Engineer - Infrastructure"].country == "GB" | |
| 44 | + assert by["Data Analyst"].external_id == "12933" and by["Data Analyst"].country == "DE" # id stripped from display, kept for identity | |
| 45 | + assert by["Verpleegkundige spoedgevallen"].city == "Gent" and by["Verpleegkundige spoedgevallen"].country == "BE" | |
| 46 | + assert [b.kind for b in ex.blocks].count("job_listing") == 5 | |
| 47 | + | |
| 48 | + | |
| 49 | +def test_careers_german_gender_markers_and_cta() -> None: | |
| 50 | + ex = _run("careers", "careers_de.html", "/karriere") | |
| 51 | + assert [j.title for j in ex.jobs] == ["Softwareentwickler Backend", "Projektleiter Anlagenbau", "Ausbildung zum Industriemechaniker 2027", | |
| 52 | + "Werkstudent Marketing", "Pflegefachkraft Intensivstation"] | |
| 53 | + assert [j.location_text for j in ex.jobs] == ["München", "Stuttgart", "Hamburg", "Berlin", "Köln"] # bare known cities are locations | |
| 54 | + assert ex.jobs[0].country is None # …but no country is guessed | |
| 55 | + | |
| 56 | + | |
| 57 | +def test_leadership_swapped_cards_and_contact_links() -> None: | |
| 58 | + ex = _run("leadership", "leadership_swapped.html", "/leadership") | |
| 59 | + people = {p.name: p for p in ex.people} | |
| 60 | + assert set(people) == {"Jane Doe", "Brian Jacobson", "Priya Natarajan", "Marc van der Berg", "Samuel Adebayo"} | |
| 61 | + assert people["Jane Doe"].title == "Chief Executive Officer" and people["Jane Doe"].role_category == "ceo" and people["Jane Doe"].is_executive | |
| 62 | + assert people["Samuel Adebayo"].title == "Chair of the Board" and people["Samuel Adebayo"].role_category == "chair" | |
| 63 | + assert people["Brian Jacobson"].title == "Chief Financial Officer" and people["Brian Jacobson"].role_category == "cfo" | |
| 64 | + assert people["Priya Natarajan"].title is None and people["Priya Natarajan"].role_category == "other" # "Contact" is not a title | |
| 65 | + assert people["Marc van der Berg"].role_category == "coo" | |
| 66 | + | |
| 67 | + | |
| 68 | +def test_leadership_japanese() -> None: | |
| 69 | + ex = _run("leadership", "leadership_ja.html", "/company/officers") | |
| 70 | + people = {p.name: p for p in ex.people} | |
| 71 | + assert set(people) == {"山田 太郎", "佐藤 花子", "鈴木 一郎", "高橋 美咲"} | |
| 72 | + assert people["山田 太郎"].role_category == "ceo" and people["山田 太郎"].is_executive | |
| 73 | + assert people["佐藤 花子"].role_category == "cfo" and people["佐藤 花子"].is_executive | |
| 74 | + assert people["鈴木 一郎"].role_category == "board" and not people["鈴木 一郎"].is_executive | |
| 75 | + assert people["高橋 美咲"].role_category == "vp" | |
| 76 | + | |
| 77 | + | |
| 78 | +def test_locations_cookie_categories_and_nav_cards_rejected() -> None: | |
| 79 | + ex = _run("locations", "locations_cookie_noise.html", "/locations") | |
| 80 | + locs = {loc.name: loc for loc in ex.locations} | |
| 81 | + assert set(locs) == {"Hormuz Grand Hotel", "Dubai Office", "London", "Singapore", "Rotterdam Warehouse"} | |
| 82 | + assert locs["Hormuz Grand Hotel"].country == "OM" and locs["Hormuz Grand Hotel"].city is None # "Oman | Hormuz Grand Hotel" repaired | |
| 83 | + assert locs["Dubai Office"].city == "Dubai" and locs["Dubai Office"].country == "AE" # not "Level 12" | |
| 84 | + assert locs["Rotterdam Warehouse"].city == "Rotterdam" and locs["Rotterdam Warehouse"].kind == "warehouse" and locs["Rotterdam Warehouse"].country == "NL" | |
| 85 | + assert locs["London"].address_text.startswith("1 Finsbury Avenue") and locs["Singapore"].city == "Singapore" | |
| 86 | + assert all(loc.kind != "store" for loc in ex.locations) # "…providers store data" is not a shop | |
| 87 | + | |
| 88 | + | |
| 89 | +def test_pricing_marketing_headings_rejected_real_tiers_kept() -> None: | |
| 90 | + ex = _run("pricing", "pricing_marketing_noise.html", "/pricing") | |
| 91 | + plans = {p.plan_name: p for p in ex.plans} | |
| 92 | + assert {"Free", "Starter", "Team", "Business", "Enterprise"} <= set(plans) | |
| 93 | + assert not {"Win your market with Similar Example for businesses", "Worry-free roaming.", "Unlock the full potential of your data", "Most popular", "Plans"} & set(plans) | |
| 94 | + assert plans["Free"].price == 0 and plans["Free"].price_text == "$0 forever" | |
| 95 | + assert plans["Starter"].price == 125 and plans["Starter"].billing_period == "month" and plans["Starter"].price_text == "$125 per month, billed annually" | |
| 96 | + assert plans["Team"].price == 333 and plans["Team"].currency == "EUR" and plans["Team"].price_text == "Starting at €333 / month" | |
| 97 | + assert plans["Business"].price == 1199 and plans["Business"].unit == "user" and plans["Business"].price_text == "US$ 1,199 per user / month" | |
| 98 | + assert plans["Enterprise"].contact_sales and plans["Enterprise"].price is None and plans["Enterprise"].price_text == "Talk to sales" | |
| 99 | + assert all("destina" not in (p.price_text or "") for p in ex.plans) # never a truncated sentence | |
| 100 | + | |
| 101 | + | |
| 102 | +def test_products_nav_words_and_slogans_rejected() -> None: | |
| 103 | + ex = _run("products", "products_news_noise.html", "/products") | |
| 104 | + assert [p.name for p in ex.products] == ["Atlas Metrics™", "Atlas Logs®", "Atlas Traces"] # ® / ™ kept | |
| 105 | + | |
| 106 | + | |
| 107 | +def test_news_pagination_and_category_labels_rejected() -> None: | |
| 108 | + ex = _run("newsroom", "products_news_noise.html", "/news") | |
| 109 | + titles = [n.title for n in ex.news] | |
| 110 | + assert titles == ["Acme launches Atlas AI, an assistant for cloud operations", "Q2 results", "Acme and BigCo announce strategic partnership"] | |
| 111 | + assert ex.news[1].published_at.date().isoformat() == "2026-08-28" # short title kept because a date confirms it | |
| 112 | + | |
| 113 | + | |
| 114 | +def test_existing_fixtures_recall_unchanged() -> None: | |
| 115 | + assert {p.plan_name for p in _run("pricing", "pricing.html", "/pricing").plans} == {"Starter", "Pro", "Enterprise"} | |
| 116 | + assert len(_run("leadership", "leadership.html", "/about/leadership").people) == 7 | |
| 117 | + assert len(_run("locations", "locations.html", "/company/locations").locations) == 8 | |
| 118 | + assert len(_run("careers", "careers.html", "/careers").jobs) == 5 | |
| 119 | + assert len(_run("newsroom", "newsroom.html", "/news").news) == 4 | |
| 120 | + | |
| 121 | + | |
| 122 | +# ------------------------------------------------------------------------------------------------------------ unit rules | |
| 123 | + | |
| 124 | + | |
| 125 | +@pytest.mark.parametrize("title,url,location,ok", [ | |
| 126 | + ("Meer info", "https://x.example/nl/jobs/search?page=2", None, False), | |
| 127 | + ("Voor starters die willen gáán", "https://x.example/nl/jobs/starters", None, False), | |
| 128 | + ("Early careers bij Telenet group", "https://x.example/nl/jobs/early-careers", None, False), | |
| 129 | + ("Mehr erfahren →", "https://x.example/karriere/stellen", None, False), | |
| 130 | + ("Sales", "https://x.example/nl/jobs/12990-sales", None, False), # single word | |
| 131 | + ("Wij zoeken mensen die het verschil willen maken…", "https://x.example/jobs/13001", "Mechelen", False), | |
| 132 | + ("Senior Backend Software Engineer - Infrastructure", None, "London, United Kingdom", True), | |
| 133 | + ("Regional Coordinator", "https://x.example/careers/x", None, True), # role vocabulary | |
| 134 | + ("Something Unusual", "https://x.example/jobs/12345-something-unusual", None, True), # job-like URL | |
| 135 | + ("Something Unusual", None, "Toronto, ON, Canada", True), # explicit location cell | |
| 136 | + ("Something Unusual", None, None, False), # no signal at all | |
| 137 | + ("Infirmier(ère) de nuit", None, None, True), | |
| 138 | + ("Ingeniero de datos", None, None, True), | |
| 139 | + ("ソフトウェアエンジニア", None, None, True), | |
| 140 | +]) | |
| 141 | +def test_job_verdict(title: str, url: str | None, location: str | None, ok: bool) -> None: | |
| 142 | + assert P.job_verdict(title, url=url, location=location).ok is ok | |
| 143 | + | |
| 144 | + | |
| 145 | +@pytest.mark.parametrize("raw,clean,ident", [ | |
| 146 | + ("Data Analyst (m/w/d) [12933]", "Data Analyst", "12933"), | |
| 147 | + ("Accountmanager KMO - Apply now", "Accountmanager KMO", None), | |
| 148 | + ("Technicien réseau (h/f)", "Technicien réseau", None), | |
| 149 | + ("Werkstudent Marketing (all genders)", "Werkstudent Marketing", None), | |
| 150 | + ("Product Manager (Job ID: 44812) →", "Product Manager", "44812"), | |
| 151 | + ("Senior Engineer (REQ-501)", "Senior Engineer", "REQ-501"), | |
| 152 | +]) | |
| 153 | +def test_clean_job_title(raw: str, clean: str, ident: str | None) -> None: | |
| 154 | + assert P.clean_job_title(raw) == (clean, ident) | |
| 155 | + | |
| 156 | + | |
| 157 | +@pytest.mark.parametrize("name,title,expected", [ | |
| 158 | + ("Chief Executive Officer", "Jane Doe", ("Jane Doe", "Chief Executive Officer")), # swapped card | |
| 159 | + ("Chair Emeritus", "Warner Bros. Discovery", None), # role as name, company as title | |
| 160 | + ("Brian Jacobson", "Contact", ("Brian Jacobson", None)), | |
| 161 | + ("Board of Directors", "Meet the people who govern the company", None), | |
| 162 | + ("Leadership", "Read more", None), | |
| 163 | + ("Contact", "Media relations", None), | |
| 164 | + ("Our team", None, None), | |
| 165 | + ("Marc van der Berg", "Chief Operating Officer", ("Marc van der Berg", "Chief Operating Officer")), | |
| 166 | + ("Jane Doe", "Jane leads the company since 2019 and previously ran BigCo.", ("Jane Doe", None)), # bio sentence is not a title | |
| 167 | + ("Dr. Aiko Tanaka", "Head of People", ("Dr. Aiko Tanaka", "Head of People")), | |
| 168 | + ("Jane Doe 2", "CEO", None), | |
| 169 | + ("Edgar S. Woolard, Jr.", "Key person", ("Edgar S. Woolard, Jr.", "Key person")), # abbreviation dot kept | |
| 170 | +]) | |
| 171 | +def test_normalize_person(name: str, title: str | None, expected: tuple[str, str | None] | None) -> None: | |
| 172 | + assert P.normalize_person(name, title) == expected | |
| 173 | + | |
| 174 | + | |
| 175 | +@pytest.mark.parametrize("name", [ | |
| 176 | + "Judy McGrath", "F. William McNabb III", "Michael G. McCaffery", "Catherine MacGregor", "Francis deSouza", "Calvin McDonald", | |
| 177 | + "José Vicente de los Mozos", "Alexander Trotman, Baron Trotman", "Stephen Green, Baron Green of Hurstpierpoint", "Thomas John Watson, Sr.", | |
| 178 | + "Leonardo DiCaprio", "Marc van der Berg", "María García-López", "Tom O'Neill", "山田 太郎", | |
| 179 | +]) | |
| 180 | +def test_real_world_names_are_names(name: str) -> None: | |
| 181 | + assert P.looks_like_person_name(name) | |
| 182 | + | |
| 183 | + | |
| 184 | +@pytest.mark.parametrize("name", ["Meet the team", "Chief Executive Officer", "Read more", "Warner Bros. Discovery", "Key person", "Doe, Jane", "株式会社サンプル"]) | |
| 185 | +def test_non_names_are_rejected(name: str) -> None: | |
| 186 | + assert not P.looks_like_person_name(name) | |
| 187 | + | |
| 188 | + | |
| 189 | +def test_role_category_after_swap_is_consistent() -> None: | |
| 190 | + ex = Extraction(text="", blocks=[], people=[ExtractedPerson(name="Chief Executive Officer", title="Jane Doe", role_category="other", is_executive=False)]) | |
| 191 | + P.apply_precision(ex, html_jobs=False) | |
| 192 | + assert ex.people[0].name == "Jane Doe" and ex.people[0].role_category == "ceo" and ex.people[0].is_executive | |
| 193 | + | |
| 194 | + | |
| 195 | +@pytest.mark.parametrize("loc,ok", [ | |
| 196 | + (ExtractedLocation(name="Performance & Analytics", kind="store", city="Allows use of behavioural data to optimise performance"), False), | |
| 197 | + (ExtractedLocation(name="Contact Us", city="Contact Us", country="KR"), False), | |
| 198 | + (ExtractedLocation(name="Careers", country="KR"), False), | |
| 199 | + (ExtractedLocation(name="Sign up for our newsletter", city="Paris", country="FR"), False), | |
| 200 | + (ExtractedLocation(name="Acme Regional Hub"), False), # no evidence of a place | |
| 201 | + (ExtractedLocation(name="Acme Regional Hub", kind="factory"), True), # explicit kind label | |
| 202 | + (ExtractedLocation(name="Berlin"), True), # known city | |
| 203 | + (ExtractedLocation(name="548 Market Street, Suite 200"), True), # street address | |
| 204 | + (ExtractedLocation(name="Oman", city="Hormuz Grand Hotel", country="OM"), True), | |
| 205 | + (ExtractedLocation(name="We are present in twelve countries across three continents.", country="US"), False), | |
| 206 | +]) | |
| 207 | +def test_location_verdict(loc: ExtractedLocation, ok: bool) -> None: | |
| 208 | + assert P.location_verdict(loc).ok is ok | |
| 209 | + | |
| 210 | + | |
| 211 | +def test_normalize_location_repairs_country_as_name() -> None: | |
| 212 | + fixed = P.normalize_location(ExtractedLocation(name="Oman", city="Hormuz Grand Hotel", country="OM")) | |
| 213 | + assert fixed is not None and (fixed.name, fixed.city, fixed.country) == ("Hormuz Grand Hotel", None, "OM") | |
| 214 | + kept = P.normalize_location(ExtractedLocation(name="Oman", city="Muscat", country="OM")) | |
| 215 | + assert kept is not None and (kept.name, kept.city) == ("Oman", "Muscat") # a real city stays a city | |
| 216 | + cleaned = P.normalize_location(ExtractedLocation(name="Dubai Office", city="Level 12", region="Emirates Towers", country="AE")) | |
| 217 | + assert cleaned is not None and cleaned.city is None # "Level 12" is not a city | |
| 218 | + | |
| 219 | + | |
| 220 | +@pytest.mark.parametrize("text,expected", [ | |
| 221 | + ("Stay connected in the U.S. ($13/day) and over 200 international destinations", "$13/day"), | |
| 222 | + ("$99 per user / month, billed annually", "$99 per user / month, billed annually"), | |
| 223 | + ("Starting at €333 / month", "Starting at €333 / month"), | |
| 224 | + ("US$ 1,199 per user / month", "US$ 1,199 per user / month"), | |
| 225 | + ("Talk to sales", "Talk to sales"), | |
| 226 | + ("Free", "Free"), | |
| 227 | + ("Up to 5 users and email support", None), | |
| 228 | +]) | |
| 229 | +def test_price_text_from(text: str, expected: str | None) -> None: | |
| 230 | + assert P.price_text_from(text) == expected | |
| 231 | + | |
| 232 | + | |
| 233 | +@pytest.mark.parametrize("name,ok", [ | |
| 234 | + ("Win your market with Similarweb for businesses", False), ("Worry-free roaming.", False), ("Most popular", False), ("Plans", False), | |
| 235 | + ("Unlock the full potential of your data", False), ("Talk to sales", False), ("Pro", True), ("Business Plus", True), ("Free", True), | |
| 236 | + ("Enterprise", True), ("Team (annual)", True), | |
| 237 | +]) | |
| 238 | +def test_plan_name_ok(name: str, ok: bool) -> None: | |
| 239 | + assert P.plan_name_ok(name) is ok | |
| 240 | + | |
| 241 | + | |
| 242 | +def test_plan_verdict_requires_price_contact_or_free() -> None: | |
| 243 | + assert P.plan_verdict(ExtractedPlan(plan_name="Pro", price=29.0, price_text="$29 per month")).ok | |
| 244 | + assert P.plan_verdict(ExtractedPlan(plan_name="Enterprise", contact_sales=True, price_text="Contact sales")).ok | |
| 245 | + assert P.plan_verdict(ExtractedPlan(plan_name="Free", price=0.0, price_text="Free")).ok | |
| 246 | + assert not P.plan_verdict(ExtractedPlan(plan_name="Pro", price=None, price_text="Everything you need")).ok | |
| 247 | + assert not P.plan_verdict(ExtractedPlan(plan_name="Worry-free roaming.", price=13.0, price_text="n the U.S. ($13/day) and over 200 international destina")).ok | |
| 248 | + | |
| 249 | + | |
| 250 | +@pytest.mark.parametrize("name,ok", [ | |
| 251 | + ("Overview", False), ("Learn more", False), ("All products", False), ("Solutions", False), ("Discover how Atlas helps teams ship faster.", False), | |
| 252 | + ("Atlas Metrics™", True), ("Atlas Logs®", True), ("Microsoft 365", True), ("Discover", True), ("Produits", False), ("製品一覧", False), | |
| 253 | +]) | |
| 254 | +def test_product_verdict(name: str, ok: bool) -> None: | |
| 255 | + assert P.product_verdict(name).ok is ok | |
| 256 | + | |
| 257 | + | |
| 258 | +def test_news_verdict() -> None: | |
| 259 | + assert not P.news_verdict("Read more").ok and not P.news_verdict("Older posts »").ok and not P.news_verdict("Press releases").ok | |
| 260 | + assert not P.news_verdict("3").ok and not P.news_verdict("Page 2").ok and not P.news_verdict("Actualités").ok | |
| 261 | + assert not P.news_verdict("Q2 results").ok | |
| 262 | + assert P.news_verdict("Q2 results", published_at=datetime(2026, 8, 28, tzinfo=UTC)).ok | |
| 263 | + assert P.news_verdict("Q2 results", url="https://x.example/news/2026/08/q2").ok | |
| 264 | + assert P.news_verdict("Acme launches Atlas AI, an assistant for cloud operations").ok | |
| 265 | + | |
| 266 | + | |
| 267 | +# ------------------------------------------------------------------------------------------------------------ pipeline last line of defence | |
| 268 | + | |
| 269 | + | |
| 270 | +def _noisy_extraction() -> Extraction: | |
| 271 | + return Extraction( | |
| 272 | + text="ok", blocks=[], | |
| 273 | + jobs=[ExtractedJob(title="ML Engineer"), ExtractedJob(title="Meer info", url="https://x.example/jobs"), ExtractedJob(title="Sales")], | |
| 274 | + people=[ExtractedPerson(name="Jane Doe", title="CEO"), ExtractedPerson(name="Chair Emeritus", title="Warner Bros. Discovery"), | |
| 275 | + ExtractedPerson(name="Brian Jacobson", title="Contact")], | |
| 276 | + products=[ExtractedProduct(name="Atlas Metrics"), ExtractedProduct(name="Overview")], | |
| 277 | + plans=[ExtractedPlan(plan_name="Pro", price=29.0, price_text="$29 per month"), | |
| 278 | + ExtractedPlan(plan_name="Win your market with Similarweb for businesses", contact_sales=True, price_text="Talk to sales")], | |
| 279 | + locations=[ExtractedLocation(name="Berlin", country="DE"), ExtractedLocation(name="Performance & Analytics", kind="store")], | |
| 280 | + news=[ExtractedNewsItem(title="Quarterly results published", url="https://x.example/a"), ExtractedNewsItem(title="Read more", url="https://x.example/b")], | |
| 281 | + ) | |
| 282 | + | |
| 283 | + | |
| 284 | +def test_pipeline_filter_applies_precision_for_html_connector() -> None: | |
| 285 | + ex = _noisy_extraction() | |
| 286 | + _drop_corrupt_entities(ex, connector_id="generic-html-v1") | |
| 287 | + assert [j.title for j in ex.jobs] == ["ML Engineer"] | |
| 288 | + assert [(p.name, p.title) for p in ex.people] == [("Jane Doe", "CEO"), ("Brian Jacobson", None)] | |
| 289 | + assert [p.name for p in ex.products] == ["Atlas Metrics"] | |
| 290 | + assert [p.plan_name for p in ex.plans] == ["Pro"] | |
| 291 | + assert [loc.name for loc in ex.locations] == ["Berlin"] | |
| 292 | + assert [n.title for n in ex.news] == ["Quarterly results published"] | |
| 293 | + | |
| 294 | + | |
| 295 | +def test_pipeline_filter_trusts_structured_job_boards() -> None: | |
| 296 | + ex = _noisy_extraction() | |
| 297 | + _drop_corrupt_entities(ex, connector_id="greenhouse-v1") | |
| 298 | + assert [j.title for j in ex.jobs] == ["ML Engineer", "Meer info", "Sales"] # ATS jobs are never filtered by title rules | |
| 299 | + assert [p.name for p in ex.products] == ["Atlas Metrics"] # other rules still apply | |
| 300 | ||