SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%

Company Atlas kernel: config, taxonomy, ids, urls, fetch, archive, db, schema 0001, SDK models, CLI, API skeleton, docs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 12 days ago (Sep 12, 2026)

34 changed files +3,598 −0

added .env.example +21 −0
@@ -0,0 +1,21 @@
1 +# Company Atlas — local development. Copy to .env (never commit .env).
2 +APP_ENV=development
3 +CA_SITE_URL=http://localhost:8370
4 +DATABASE_URL=postgresql+asyncpg://companyatlas:companyatlas@127.0.0.1:5432/companyatlas
5 +CA_DATA_DIR=./data
6 +CA_API_HOST=127.0.0.1
7 +CA_API_PORT=8371
8 +CA_ADMIN_TOKEN=dev-admin-token
9 +CA_LOG_JSON=0
10 +# Crawler identity (robots.txt honoured; contact address public)
11 +CA_USER_AGENT="CompanyAtlasBot/0.1 (+https://www.company-atlas.co/bot; contact@spboucher.ai)"
12 +CA_FETCH_CONCURRENCY=16
13 +# LLM enrichment (OpenAI-compatible; MacLustr llm-api.io). Optional — deterministic pipeline works without it.
14 +CA_LLM_BASE_URL=https://www.llm-api.io/v1
15 +CA_LLM_API_KEY=
16 +CA_LLM_SMALL_MODEL=qwen3-4b-instruct-2507-4bit
17 +CA_LLM_MEDIUM_MODEL=qwen3.6-35b-a3b-4bit
18 +CA_LLM_LARGE_MODEL=qwen3.8-27b-4bit
19 +# Web
20 +API_URL=http://127.0.0.1:8371
21 +NEXT_PUBLIC_SITE_URL=http://localhost:8370
added .gitignore +23 −0
@@ -0,0 +1,23 @@
1 +.venv/
2 +__pycache__/
3 +*.pyc
4 +.pytest_cache/
5 +.ruff_cache/
6 +*.egg-info/
7 +.env
8 +.env.*
9 +!.env.example
10 +node_modules/
11 +apps/web/.next/
12 +apps/web/.next-*/
13 +apps/web/next-env.d.ts
14 +*.tsbuildinfo
15 +/data/
16 +/tmp/
17 +logs/
18 +.DS_Store
19 +.claude/
20 +deploy/.admin-token
21 +deploy/.llm-key
22 +deploy/rendered/
23 +apps/web/qa/screens/
added CLAUDE.md +57 −0
@@ -0,0 +1,57 @@
1 +# CLAUDE.md — Company Atlas (repo guide)
2 +
3 +**Product:** Company Atlas — *The Live Atlas of Global Companies*. A distributed public-web sensor network attached to companies:
4 +observations → snapshots → changes → structured events → metrics → intelligence. The accumulated history is the product.
5 +Full product specification (200 sections, non-negotiable principles): **`docs/PRODUCT-SPEC.md`**. Read it before changing behaviour.
6 +
7 +**Domains:** canonical `https://www.company-atlas.co` today (DNS A → MacLustr Tunnel 51.161.112.61). `www.company-atlas.com` is the
8 +spec's primary hostname but its nameservers are at Vercel, not GoDaddy — once its A records point to the gateway, add it as a tunnel
9 +route/redirect and flip `CA_SITE_URL`. Apex → www 308.
10 +
11 +## Stack
12 +- Python 3.12 `src/companyatlas` (FastAPI, SQLAlchemy Core + asyncpg, Alembic SQL forward-only, httpx, selectolax, feedparser, zstd),
13 + CLI **`catlas`** (commands auto-discovered from `companyatlas/commands/*.py`).
14 +- Postgres 17 only (no Redis): queue = `queue_jobs` with `SKIP LOCKED`; caches in-process (`api/common.TTLCache`).
15 +- Objects: content-addressed zstd store `CA_DATA_DIR/objects/ab/cd/<sha256>.zst` (`archive.py`).
16 +- Web: `apps/web` Next 16 + React 19 + Tailwind v4 (Geist), SSR, rewrites `/api/v1/*` → FastAPI loopback. Ports: prod web **8360** / api **8361**;
17 + dev web **8370** / api **8371** (see `.env.example`).
18 +- LLM enrichment: OpenAI-compatible `CA_LLM_BASE_URL` (MacLustr llm-api.io, key "company-atlas" in `deploy/.llm-key`, git-ignored). Optional; deterministic first.
19 +
20 +## Layout
21 +```
22 +src/companyatlas/ config.py taxonomy.py ids.py urls.py fetch.py archive.py db/ logging.py cli.py
23 + sdk/ (models, normalize, diff, connector) connectors/ services/ (discovery, pipeline, scheduler, events, llm, metrics…)
24 + api/ (main, common, routers/*) commands/ (CLI groups) registry/ (seed loader)
25 +migrations/versions/0001_initial.py registry/ (companies ndjson, industries.yaml, countries.csv) prompts/ fixtures/ tests/
26 +apps/web/ docs/ (PRODUCT-SPEC, ARCHITECTURE, API, DATA-MODEL, CONNECTORS, SCORING, DEPLOY, OPERATIONS) deploy/ (mld manifest, scripts)
27 +```
28 +Architecture, ownership map and boundaries: `docs/ARCHITECTURE.md`. API contract: `docs/API.md`.
29 +
30 +## Operating rules (spec §182)
31 +1. Inspect existing architecture first; preserve working functionality; avoid rewrites; reuse the SDK.
32 +2. Never fabricate data. No inference presented as fact: careful language (*detected*, *no longer listed*, *signal*), confidence labels.
33 +3. Historical-first: never overwrite or delete history; new versions, `status` columns, forward-only migrations.
34 +4. Raw vs interpreted stay separate (objects ↔ snapshots ↔ changes ↔ events). Reprocessing must never require re-fetching.
35 +5. LLMs are enrichment, not the crawler: deterministic fetch → normalize → hash → diff → significance → LLM only if useful, budgeted.
36 +6. Only `fetch.Fetcher` talks to the network: SSRF guard, robots, per-domain rate/concurrency, size and redirect caps. Never bypass
37 + authentication or challenges; never collect private data.
38 +7. No magic numbers: tunables in `config.Settings` / `taxonomy.py`; bump formula/prompt/connector versions when behaviour changes.
39 +8. Mobile is first-class; dark and light both designed; dense, readable, terminal-grade UI.
40 +9. Tests use fixtures (`fixtures/`), never the live web (`-m live` opt-in). Run `.venv/bin/pytest -q`, `.venv/bin/ruff check src tests`,
41 + `pnpm typecheck` after meaningful changes.
42 +10. Secrets only in env / git-ignored files (`deploy/.admin-token`, `deploy/.llm-key`); the rendered manifest lives on M1M32.
43 +
44 +## Dev quickstart
45 +```bash
46 +uv venv --python 3.12 .venv && uv pip install --python .venv/bin/python -e '.[dev]'
47 +createdb -O companyatlas companyatlas # role companyatlas/companyatlas, extensions pg_trgm + uuid-ossp
48 +cp .env.example .env && .venv/bin/catlas migrate && .venv/bin/catlas seed
49 +.venv/bin/catlas onboard --limit 50 # discovery + sensors for the first companies
50 +.venv/bin/catlas schedule # scheduler + workers (Ctrl-C to stop)
51 +.venv/bin/catlas api # http://127.0.0.1:8371/api/v1/docs
52 +pnpm install && pnpm dev:web # http://localhost:8370
53 +```
54 +
55 +## Deploy (MacLustr, via mld — see docs/DEPLOY.md)
56 +Node **M2U64** (pin), dir `~/apps/company-atlas`, data `~/company-atlas-data`, PM2 `company-atlas-api` / `company-atlas-scheduler` /
57 +`company-atlas-web`. `deploy/render-manifest.sh --push``mld stage . company-atlas``mld deploy company-atlas --node M2U64`.
added alembic.ini +31 −0
@@ -0,0 +1,31 @@
1 +[alembic]
2 +script_location = migrations
3 +prepend_sys_path = src
4 +path_separator = os
5 +
6 +[loggers]
7 +keys = root,alembic
8 +
9 +[handlers]
10 +keys = console
11 +
12 +[formatters]
13 +keys = generic
14 +
15 +[logger_root]
16 +level = WARN
17 +handlers = console
18 +
19 +[logger_alembic]
20 +level = INFO
21 +handlers =
22 +qualname = alembic
23 +
24 +[handler_console]
25 +class = StreamHandler
26 +args = (sys.stderr,)
27 +level = NOTSET
28 +formatter = generic
29 +
30 +[formatter_generic]
31 +format = %(levelname)-5.5s [%(name)s] %(message)s
added docs/API.md +176 −0
@@ -0,0 +1,176 @@
1 +# Company Atlas API — contract (v1)
2 +
3 +Base: `/api/v1` · JSON (orjson) · UTC ISO-8601 timestamps (`Z`) · immutable ids (`co_…`, `sen_…`, `evt_…`) · slugs for public URLs.
4 +Public GET endpoints need no auth. Owner endpoints (watchlists, alerts) take `X-CA-Owner-Token` (client-generated random string ≥ 24 chars, stored hashed).
5 +Admin endpoints take `X-CA-Admin-Token`. Errors: `{ "detail": string }` with 4xx/5xx. Pagination: `?page=1&per_page=25` (max 200) →
6 +`{ items, page, per_page, total, pages }`. Lists accept `sort` where documented; unknown params are ignored.
7 +
8 +Interpretive language is careful by design (spec §167–168): events say *"no longer listed"*, never *"fired"*; anything inferred carries a
9 +`confidence` (0–1) and a `confidence_label` (`VERIFIED | HIGH_CONFIDENCE | LIKELY | INFERRED | LOW_CONFIDENCE`).
10 +
11 +## Shapes
12 +
13 +```ts
14 +type CompanyCard = {
15 + id: string; slug: string; display_name: string; legal_name: string | null; canonical_domain: string; website: string;
16 + description: string | null; industries: string[]; industry_primary: string | null; country: string | null; hq_city: string | null;
17 + hq_region: string | null; public_company: boolean; ticker: string | null; exchange: string | null; founded_year: number | null;
18 + employees_band: string | null; logo_url: string | null; status: string; onboarding_status: string; importance: number; tier: 1|2|3|4;
19 + metrics: Partial<Record<Metric, number>>; // activity_score, hiring_momentum_30d, product_velocity, ai_adoption, corporate_change_index, open_jobs …
20 + counts: { sensors: number; observations: number; changes: number; events: number; jobs_open: number };
21 + last_event_at: string | null; last_observed_at: string | null;
22 + sparkline?: number[]; // 30 daily activity values when `?sparkline=1` (or on detail)
23 +};
24 +type Metric = 'activity_score'|'hiring_momentum_7d'|'hiring_momentum_30d'|'hiring_momentum_90d'|'open_jobs'|'ai_adoption'|'product_velocity'|
25 + 'geo_expansion'|'developer_momentum'|'communication_activity'|'pricing_activity'|'leadership_activity'|'corporate_change_index'|'anomaly_score'|'historical_coverage';
26 +
27 +type Event = {
28 + id: string; company: { id: string; slug: string; display_name: string; canonical_domain: string; country: string|null; logo_url: string|null };
29 + event_type: string; event_subtype: string; importance: number; confidence: number; confidence_label: string;
30 + title: string; summary: string | null; old_value: string | null; new_value: string | null;
31 + payload: Record<string, unknown>; entities: Record<string, unknown>; tags: string[];
32 + detected_at: string; effective_at: string | null; published_at: string | null;
33 + source_url: string | null; surface: string | null; sensor_id: string | null; change_id: string | null; cluster_id: string | null;
34 + origin: 'deterministic'|'llm'|'hybrid'|'backfill'; model_name: string | null; prompt_version: string | null; status: 'active'|'retracted'|'duplicate'|'review';
35 + sources?: { source_url: string; surface: string | null; detected_at: string; kind: string; sensor_id: string | null }[]; // detail only
36 +};
37 +
38 +type Sensor = {
39 + id: string; company_id: string; surface: string; connector_id: string; url: string; canonical_url: string; domain: string;
40 + status: string; tier: 'A'|'B'|'C'|'D'|'E'; quality_score: number; discovery_confidence: number; discovery_method: string | null;
41 + current_interval_s: number; next_run_at: string; last_run_at: string | null; last_success_at: string | null; last_change_at: string | null;
42 + last_status: number | null; last_failure_class: string | null; consecutive_failures: number;
43 + observation_count: number; snapshot_count: number; change_count: number; meaningful_change_count: number; event_count: number; created_at: string;
44 +};
45 +
46 +type Snapshot = { id: string; sensor_id: string; version_no: number; fetched_at: string; title: string | null; language: string | null;
47 + text_length: number | null; block_count: number | null; extracted_summary: Record<string, number>; content_hash: string; previous_snapshot_id: string | null };
48 +
49 +type Change = { id: string; sensor_id: string; surface: string; company_id: string; detected_at: string; significance: number; kind: string;
50 + blocks_added: number; blocks_removed: number; blocks_modified: number; text_delta_ratio: number; similarity: number | null;
51 + snapshot_before: string | null; snapshot_after: string; diff?: DiffPayload; structured_delta?: Record<string, unknown> };
52 +
53 +type DiffPayload = { added: BlockDelta[]; removed: BlockDelta[]; modified: BlockDelta[]; moved: string[]; counts: Record<string, number>;
54 + text_delta_ratio: number; similarity: number; reasons: string[] };
55 +type BlockDelta = { key: string; kind: string; path: string; before: string | null; after: string | null; weight: number; similarity: number | null };
56 +
57 +type Job = { id: string; title: string; department: string | null; location_text: string | null; city: string | null; country: string | null;
58 + remote: boolean | null; employment_type: string | null; seniority: string | null; url: string | null; posted_at: string | null;
59 + first_seen_at: string; last_seen_at: string; removed_at: string | null; status: 'open'|'no_longer_listed'; is_ai: boolean };
60 +
61 +type Person = { id: string; name: string; title: string | null; role_category: string | null; is_executive: boolean; first_seen_at: string; last_seen_at: string; removed_at: string | null; status: string; source_url: string | null };
62 +type Product = { id: string; name: string; category: string | null; description: string | null; url: string | null; first_seen_at: string; last_seen_at: string; removed_at: string | null; status: string };
63 +type Plan = { id: string; plan_name: string; price: number | null; price_text: string | null; currency: string | null; billing_period: string | null; unit: string | null; features: string[]; contact_sales: boolean; version_no: number; valid_from: string; valid_to: string | null; status: string; source_url: string | null };
64 +type Location = { id: string; kind: string; name: string | null; city: string | null; region: string | null; country: string | null; lat: number | null; lon: number | null; first_seen_at: string; last_seen_at: string; removed_at: string | null; status: string; source_url: string | null };
65 +type NewsItem = { id: string; title: string; url: string; summary: string | null; category: string | null; published_at: string | null; first_seen_at: string; language: string | null };
66 +type MetricPoint = { day: string; value: number; confidence: number };
67 +type Signal = { id: string; company_id: string | null; scope: string; scope_key: string | null; kind: string; strength: number; confidence: number; title: string; explanation: string | null; evidence: Record<string, unknown>; window_days: number; detected_at: string; status: string };
68 +```
69 +
70 +## Endpoints
71 +
72 +### Platform
73 +| Method | Path | Notes |
74 +|---|---|---|
75 +| GET | `/health`, `/ready` | service health (also at `/api/v1/health`) |
76 +| GET | `/stats` | `{ companies, companies_active, sensors, sensors_active, observations, snapshots, changes, meaningful_changes, events, jobs_open, countries, industries, observations_today, changes_today, events_today, dataset_started_at, dataset_age_days, oldest_history_days, last_observation_at, archive: {objects, bytes} }` (cached 60 s) |
77 +| GET | `/stats/history?days=90` | `{ items: GlobalDaily[] }``{ day, companies_active, sensors_active, observations, changes, meaningful_changes, events, events_by_type, jobs_open, jobs_new, jobs_removed, activity_index }` |
78 +| GET | `/system` | public aggregate health only: `{ sensors_online, sensors_failing, observations_today, events_today, countries_covered, queue_lag_s, scheduler_last_tick_at, fetch_per_min, success_rate_24h }` |
79 +| GET | `/pulse` | homepage aggregate: `{ stats, live: Event[12], movers: CompanyCard[10], hiring: CompanyCard[8], launches: Event[8], pricing: Event[8], ai: CompanyCard[8], industries: IndustryRow[12], countries: CountryRow[12], trending: TrendRow[10], activity_index: {value, delta_7d, series: MetricPoint[30]}, map: MapBucket[] }` (cached 60 s) |
80 +| GET | `/live?limit=50&since=<iso>&event_type=&min_importance=` | latest active events (no cache); `since` returns only newer than that time |
81 +| GET | `/live/stream` | **SSE**: `event: event` with an `Event` JSON per message (poll-based, ~5 s), `event: heartbeat` every 20 s, `?since=` supported |
82 +
83 +### Companies
84 +| Method | Path | Notes |
85 +|---|---|---|
86 +| GET | `/companies` | filters: `q, country, industry, tier, public (bool), status, has_events (bool), sort=activity\|events\|hiring\|name\|importance\|recent, sparkline=1` → page of `CompanyCard` |
87 +| GET | `/companies/{slug_or_id}` | `CompanyCard & { aliases: string[], domains: {domain, kind}[], relationships: {kind, company: {slug, display_name} \| null, to_name, valid_from, valid_to, confidence}[], metrics_detail: {metric, value, confidence, computed_at, inputs}[], sensors_by_surface: Record<string, number>, coverage: {historical_coverage, first_observed_at, days_observed, sensor_uptime}, signals: Signal[], sparklines: {activity_30d: number[], hiring_90d: number[]} }` |
88 +| GET | `/companies/{slug}/events` | filters `event_type, event_subtype, since, until, min_importance, surface`; `sort=recent\|importance` → page of `Event` |
89 +| GET | `/companies/{slug}/timeline?filter=all\|products\|jobs\|pricing\|leadership\|locations\|legal\|news\|developer&limit=200` | `{ items: (Event & {day: string})[], days: {day, count}[] }` grouped for the timeline UI |
90 +| GET | `/companies/{slug}/metrics?metric=activity_score&days=90` | `{ current: {metric, value, confidence, computed_at, formula_version, inputs}[], series: Record<Metric, MetricPoint[]> }` |
91 +| GET | `/companies/{slug}/jobs?status=open\|removed\|all&q=&country=&ai=1` | page of `Job` + `{ summary: {open, new_7d, removed_7d, ai_open, by_country: {country,n}[], by_department: {department,n}[], remote_ratio} }` as `meta` |
92 +| GET | `/companies/{slug}/people` | `{ listed: Person[], no_longer_listed: Person[] }` |
93 +| GET | `/companies/{slug}/products` | `{ listed: Product[], removed: Product[] }` |
94 +| GET | `/companies/{slug}/pricing` | `{ current: Plan[], history: Plan[] }` |
95 +| GET | `/companies/{slug}/locations` | `{ items: Location[], countries: string[] }` |
96 +| GET | `/companies/{slug}/news?limit=50` | `{ items: NewsItem[] }` |
97 +| GET | `/companies/{slug}/sensors` | `{ items: Sensor[] }` |
98 +| GET | `/companies/{slug}/history` | `{ sensors: (Sensor & { versions: Snapshot[] })[] }` — historical page viewer index (max 20 versions per sensor) |
99 +| GET | `/companies/{slug}/similar?limit=8` | `{ items: CompanyCard[] }` (same industry/country, closest importance) |
100 +| GET | `/companies/compare?companies=stripe,adyen,block` | `{ companies: CompanyCard[], metrics: Record<Metric, Record<slug, number>>, series: Record<slug, MetricPoint[]>, events_30d: Record<slug, Record<event_type, number>>, jobs: Record<slug, {open, ai_open, new_30d}>, locations: Record<slug, number> }` (2–6 companies) |
101 +
102 +### Sensors, snapshots, changes (provenance)
103 +| Method | Path | Notes |
104 +|---|---|---|
105 +| GET | `/sensors/{id}` | `Sensor & { company: CompanyRef, latest_snapshot: Snapshot \| null }` |
106 +| GET | `/sensors/{id}/snapshots?limit=50` | `{ items: Snapshot[] }` |
107 +| GET | `/sensors/{id}/changes?limit=50` | `{ items: Change[] }` |
108 +| GET | `/snapshots/{id}` | `Snapshot & { text: string (≤ 200 kB), blocks: Block[], extracted: object }` |
109 +| GET | `/snapshots/{id}/diff/{other_id}` | `{ before: Snapshot, after: Snapshot, diff: DiffPayload }` (computed on demand) |
110 +| GET | `/changes/{id}` | `Change` with `diff` and `structured_delta`, plus `events: Event[]` |
111 +| GET | `/events/{id}` | `Event` detail with `sources`, `change` (Change summary), `company` |
112 +
113 +### Events
114 +| Method | Path | Notes |
115 +|---|---|---|
116 +| GET | `/events` | filters `event_type, event_subtype, country, industry, since, until, min_importance, min_confidence, q, surface, origin, company`; `sort=recent\|importance` → page of `Event` |
117 +| GET | `/events/types` | `{ types: {event_type, subtypes: {event_subtype, count_30d}[], count_30d}[] }` |
118 +| GET | `/events/summary?days=7&group=type\|industry\|country` | `{ items: {key, count, delta_pct}[] }` |
119 +
120 +### Rankings, industries, countries, signals, trends
121 +| Method | Path | Notes |
122 +|---|---|---|
123 +| GET | `/rankings?kind=most_active\|hiring_growth\|hiring_decline\|product_velocity\|ai_active\|geo_expansion\|developer_momentum\|pricing_changes\|unusual_activity&window=24h\|7d\|30d\|90d\|1y&country=&industry=&limit=50` | `{ kind, window, items: (CompanyCard & { rank: number; value: number; delta: number \| null })[] }` |
124 +| GET | `/industries` | `{ items: IndustryRow[] }``{ slug, name, parent_slug, companies, events_7d, events_30d, hiring_momentum_30d, activity_score, ai_adoption, top_event_types: string[] }` |
125 +| GET | `/industries/{slug}` | `IndustryRow & { description, companies: CompanyCard[24] (most active), events: Event[20], hiring: {open, new_30d, removed_30d, momentum_30d}, series: MetricPoint[90] (activity), countries: {country, companies}[], trending: TrendRow[] }` |
126 +| GET | `/countries` | `{ items: CountryRow[] }``{ code, name, region, companies, events_7d, events_30d, hiring_momentum_30d, activity_score, industry_mix: {industry, companies}[], lat, lon }` |
127 +| GET | `/countries/{code}` | `CountryRow & { companies: CompanyCard[24], events: Event[20], movers: CompanyCard[10], new_entrants: CompanyCard[10], series: MetricPoint[90], industries: IndustryRow[] }` |
128 +| GET | `/signals?kind=&scope=company\|industry\|country\|global&limit=50` | `{ items: Signal[] }` |
129 +| GET | `/trends?window=7d\|30d\|90d&limit=30` | `{ items: TrendRow[] }``{ term, mentions, companies, momentum, series: number[] }` |
130 +| GET | `/map?metric=events_30d\|companies\|hiring` | `{ buckets: MapBucket[] }``{ lat, lon, country, city: string \| null, companies: number, events_30d: number, jobs_open: number, top: {slug, display_name}[] }` (clustered by city/country, ≤ 600 buckets) |
131 +| GET | `/index` | Global Corporate Activity Index: `{ value, baseline: 100, delta_7d, delta_30d, series: MetricPoint[365], by_type: Record<string, number>, by_country: {key, value}[], by_industry: {key, value}[], formula_version }` |
132 +
133 +### Search
134 +| Method | Path | Notes |
135 +|---|---|---|
136 +| GET | `/search?q=&types=companies,events,industries,countries,people,products&limit=10` | `{ query, companies: CompanyCard[], events: Event[], industries: IndustryRow[], countries: CountryRow[], people: (Person & {company: CompanyRef})[], products: (Product & {company: CompanyRef})[], took_ms }` |
137 +| GET | `/search/suggest?q=` | `{ items: { kind: 'company'\|'industry'\|'country'\|'event_type', label, sublabel, href }[] }` (≤ 10, < 50 ms) |
138 +| GET | `/ask?q=` | natural-language routing (deterministic parser → structured query; LLM optional): `{ interpretation: {filters…}, answer: string, companies: CompanyCard[], events: Event[], sources: string[] }` |
139 +
140 +### Watchlists & alerts (owner token)
141 +| Method | Path | Notes |
142 +|---|---|---|
143 +| GET/POST | `/watchlist` | GET → `{ items: CompanyCard[], events: Event[30] (for the watched companies) }`; POST `{ company: slug }` adds; DELETE `/watchlist/{slug}` removes |
144 +| GET/POST | `/alerts` | POST `{ name, company?: slug, condition: {event_types?: string[], min_importance?: number, metrics?: {activity_score?: {gt: number}}}, channel: 'web'\|'webhook', target?: url }`; DELETE `/alerts/{id}` |
145 +| GET | `/alerts/deliveries?limit=50` | recent deliveries for this owner |
146 +
147 +### Exports & docs
148 +| Method | Path | Notes |
149 +|---|---|---|
150 +| GET | `/export/events.{json,ndjson,csv}?since=&event_type=&country=&limit=10000` | streamed export |
151 +| GET | `/export/companies.{json,ndjson,csv}?country=&industry=` | streamed export |
152 +| GET | `/export/jobs.ndjson?company=&since=` | |
153 +| GET | `/sitemap?kind=companies\|industries\|countries&page=` | `{ items: {slug, updated_at}[], pages }` — only companies with `indexed = true` |
154 +| GET | `/methodology` | `{ metrics: {metric, formula_version, description, inputs: string[]}[], significance_bands, event_types: string[], confidence_labels }` |
155 +
156 +### Admin (`X-CA-Admin-Token`)
157 +| Method | Path | Notes |
158 +|---|---|---|
159 +| GET | `/admin/overview` | `{ companies_by_status, sensors_by_status, sensors_by_tier, queue: {pending, running, dead, oldest_pending_s}, llm: {pending, done_today, failed_today, budget_left}, failures_24h_by_class, fetch_rate_1h, change_rate_1h, meaningful_rate_1h, storage: {objects, bytes}, workers: {name, last_seen_at, inflight}[], cost_today: {fetch, browser, llm} }` |
160 +| GET | `/admin/connectors` | `{ items: {id, name, version, category, enabled, sensors_active, sensors_failing, success_rate_24h, avg_latency_ms, change_rate_24h, errors_24h, last_run_at}[] }` |
161 +| GET | `/admin/sensors?status=&domain=&connector=&company=&filter=healthy\|failing\|stale\|blocked\|redirected\|low_quality\|high_activity&page=` | page of `Sensor & {company: CompanyRef}` |
162 +| POST | `/admin/sensors/{id}/{action}` | action ∈ `pause, resume, retry, rediscover, retire, run_now`; body `{ interval_s?, connector_id? }` for `set_interval`, `set_connector` |
163 +| GET | `/admin/companies?onboarding_status=&page=` · POST `/admin/companies` `{ website, display_name?, country?, industries? }` (create + queue discovery) · POST `/admin/companies/{slug}/rediscover` |
164 +| GET | `/admin/failures?class=&since=&page=` · GET `/admin/queue?kind=&status=` · POST `/admin/queue/requeue-dead` |
165 +| GET | `/admin/llm?status=&page=` · GET `/admin/reviews?kind=&status=open` · POST `/admin/reviews/{id}` `{ resolution: 'accepted'\|'rejected', note? }` |
166 +| POST | `/admin/events/{id}/retract` `{ reason }` · POST `/admin/events/{id}/restore` |
167 +| GET | `/admin/quality` | `{ coverage: {companies_active_pct, sensors_active_pct}, freshness: {sensors_checked_24h_pct, stale}, duplicate_rate, event_confidence_avg, unknown_surfaces, failed_sensors, calibration: {correct, duplicate, noise, misclassified} }` |
168 +| GET | `/admin/costs?days=30` | `{ items: {day, dimension, key, units, cost_estimate}[], per_1000_companies, per_million_observations, per_meaningful_event }` |
169 +| POST | `/admin/cache/clear` | |
170 +
171 +## Conventions for implementers
172 +- Every list endpoint is bounded (`per_page ≤ 200`, `limit ≤ 500`), uses indexed predicates, and returns `Cache-Control: public, max-age=60` for public aggregates (`/pulse`, `/stats`, `/rankings`, `/industries`, `/countries`) and `no-store` for `/live*`, owner and admin routes.
173 +- Company lookups accept slug **or** id. Unknown → 404 `{detail: "company not found"}`.
174 +- Text search: Postgres FTS (`companies.search`, `events.search`) + trigram fallback for short/partial queries; never LIKE on unindexed columns.
175 +- Numbers: metrics are 0–100 floats rounded to 1 decimal except `hiring_momentum_*` (percentage, may be negative) and `open_jobs` (int).
176 +- Never fabricate: when a metric has no inputs, omit it (or `null`) rather than returning 0 as if measured.
added docs/ARCHITECTURE.md +61 −0
@@ -0,0 +1,61 @@
1 +# Company Atlas — architecture & module ownership
2 +
3 +One Python package (`src/companyatlas`, CLI `catlas`) runs every logical service of the spec as processes/commands, plus a Next 16 web app
4 +(`apps/web`). Postgres 17 is the only stateful dependency (entities, history, queue via `SKIP LOCKED`, metrics); raw objects live in a
5 +content-addressed zstd store on disk (`CA_DATA_DIR/objects`). No Redis. Workers are stateless and idempotent: any node with
6 +`DATABASE_URL` + the data dir (or its own object store) can run `catlas schedule` / `catlas worker`.
7 +
8 +```
9 +registry/ (seed companies, industries, countries) ──catlas seed──▶ companies
10 + │ onboarding queue
11 +
12 + services/discovery.py (homepage → robots → sitemaps → nav → ATS/feeds → classify → sensors)
13 +
14 + scheduler (services/scheduler.py) claims due sensors ─────┤ adaptive intervals, domain budgets, priority
15 +
16 + services/pipeline.py run_sensor(): fetch (fetch.py) → observation → connector.extract → Extraction
17 + → normalize/fingerprint (sdk/normalize.py) → snapshot (+ objects)
18 + → sdk/diff.py block diff + significance → changes row (+ structured_delta)
19 + → entity reconciliation (jobs/people/products/plans/locations/news tables)
20 + │ changes.status = 'pending'
21 +
22 + services/events.py process_changes(): deterministic events from structured deltas + diff → events (+ clusters, dedupe)
23 + → LLM enrichment jobs when useful (services/llm/*, prompts/ versioned, budgeted)
24 +
25 + services/metrics.py hourly scores (activity, hiring momentum, product velocity, AI adoption, geo, developer, CCI),
26 + daily aggregates (company_daily, global_daily, coverage-normalised index), baselines → anomaly,
27 + signals, trends
28 +
29 + api/ (FastAPI, docs/API.md) ◀── apps/web (Next 16, SSR + SSE live feed) ◀── Caddy (MacLustr Tunnel) ◀── www.company-atlas.co
30 +```
31 +
32 +## Ownership map (who writes what)
33 +
34 +| Area | Modules | CLI (`companyatlas/commands/*.py`, `register(app)`) |
35 +|---|---|---|
36 +| Kernel (done) | `config.py`, `taxonomy.py`, `ids.py`, `urls.py`, `fetch.py`, `archive.py`, `db/`, `logging.py`, `sdk/models.py`, migration 0001, `api/main.py`, `api/common.py`, `cli.py` | `migrate`, `api`, `version` |
37 +| Crawl core | `sdk/normalize.py`, `sdk/diff.py`, `sdk/connector.py` (+ registry), `connectors/*`, `services/discovery.py`, `services/pipeline.py`, `services/scheduler.py`, `services/repair.py` | `crawl.py`: `onboard`, `discover`, `run-sensor`, `schedule`, `sensors`, `repair`, `connectors` |
38 +| Intelligence | `services/events.py`, `services/clustering.py`, `services/llm/{gateway,enrich,schemas}.py`, `prompts/*`, `services/metrics.py`, `services/signals.py`, `services/trends.py`, `services/alerts.py`, `services/digest.py` | `intel.py`: `process-changes`, `enrich`, `metrics`, `daily`, `signals`, `alerts` |
39 +| Seeds | `registry/` data files, `companyatlas/registry/seed.py`, `scripts/seed_wikidata.py`, `scripts/seed_edgar.py` | `seed.py`: `seed`, `import-companies` |
40 +| API | `api/routers/*.py` (auto-included; `ORDER` for precedence), `api/sse.py`, `api/ratelimit.py` | — |
41 +| Web | `apps/web` | — |
42 +| Ops | `deploy/*.mld.json`, `deploy/render-manifest.sh`, `deploy/first-run.sh`, `scripts/backup*.sh` | `ops.py`: `stats`, `status`, `backup`, `retention` |
43 +
44 +## Boundaries (contracts)
45 +- **Crawl → Intelligence**: `changes` rows with `status='pending'`, `diff` (bounded JSON, `BlockDiff.to_json()`), `structured_delta`
46 + (shape documented in `sdk/models.py`), `significance`, `kind`. Entity tables already reconciled (first_seen/last_seen/removed_at).
47 +- **Intelligence → API**: `events`, `event_clusters`, `event_sources`, `metrics_current`, `metric_series`, `company_daily`, `global_daily`,
48 + `baselines`, `signals`, `trends`, `alert_deliveries`.
49 +- **API → Web**: `docs/API.md`. The web never touches the database.
50 +- **Seeds → Crawl**: `companies` with `onboarding_status='pending'` + `queue_jobs(kind='discover')`.
51 +
52 +## Conventions
53 +- IDs: `ids.new_id(kind)` (prefixed ULIDs). Slugs from `ids.slugify`. Alias keys from `ids.normalize_alias`.
54 +- SQL: plain text via `db.fetch_all/fetch_one/execute` (SQLAlchemy Core, asyncpg). Cast ambiguous binds (`cast(:x as text)`),
55 + arrays as `any(cast(:ids as text[]))`, real `datetime`/`date` objects as params, `jsonb(value)` + `cast(:p as jsonb)`.
56 +- Timestamps UTC. Never delete history; use `status` columns. Migrations forward-only, additive, in `migrations/versions/000N_*.py`.
57 +- Politeness: only `fetch.Fetcher` talks to the network (SSRF guard, robots, per-domain governor, size caps). Never bypass challenges.
58 +- Language: "detected", "observed", "no longer listed", "appears", "signal", "inferred". Never "fired", "laid off", "shut down".
59 +- Config: tunables in `config.Settings` / `taxonomy.py`; formula versions bump when weights change.
60 +- Tests: fixtures in `fixtures/`, never live network in CI (`@pytest.mark.live` for opt-in checks).
61 +- Logging: `logging.getLogger(__name__)` with `extra={…}` (JSON in prod).
added docs/PRODUCT-SPEC.md +164 −0
@@ -0,0 +1,164 @@
1 +# Company Atlas — Product Specification (master brief, verbatim from the founder, 2026-09-12)
2 +
3 +## 0. PROJECT IDENTITY
4 +
5 +**Product name:** Company Atlas
6 +**Primary domain:** `https://www.company-atlas.com`
7 +**Canonical hostname:** `www.company-atlas.com`
8 +**Product category:** Global company intelligence / continuous company monitoring / historical corporate data platform
9 +**Core concept:** Build the continuously updated historical record of the world's companies.
10 +
11 +Company Atlas is NOT a static company directory.
12 +
13 +Company Atlas is a global intelligence platform composed of **thousands, and eventually millions, of persistent public-web connectors/sensors** attached to companies around the world.
14 +
15 +Each connector continuously observes a specific public surface of a company: corporate website, careers pages, job boards, newsroom, investor relations, pricing, products, services, documentation, APIs, changelogs, leadership pages, office/location pages, sustainability pages, partnerships, customer stories, legal pages, terms, privacy policies, status pages, support portals, blogs, research pages, GitHub/public development activity where appropriate, public structured feeds, sitemaps, public subdomains, other legally/publicly accessible corporate surfaces.
16 +
17 +The platform must continuously turn these observations into: 1. raw observations, 2. normalized snapshots, 3. structural fingerprints, 4. detected changes, 5. structured corporate events, 6. historical timelines, 7. proprietary metrics, 8. comparative intelligence, 9. search, 10. APIs, 11. alerts, 12. datasets.
18 +
19 +The core competitive advantage is **time**. Every day the platform operates, its historical dataset becomes harder to reproduce.
20 +
21 +> Anyone can crawl a website today. Very few can reconstruct exactly how 100,000 companies changed over the previous five years.
22 +
23 +Company Atlas should become that historical record.
24 +
25 +# 1. NORTH STAR
26 +
27 +> **Build the most comprehensive continuously updated machine-readable history of how companies evolve.**
28 +
29 +We are not merely collecting pages. We are measuring corporate change. Examples: Which companies are accelerating hiring? Cutting hiring? Changed pricing? Launched products? Quietly removed products? Entered new countries? Added AI-related roles? Changed executive leadership? Expanding developer ecosystems? Changing positioning? Moving toward enterprise customers? Changed their terms? Opened or closed offices? Increasing public communication activity? Which industries are changing fastest? Which geographies attract expansion? Which companies appear to be preparing launches? Which businesses suddenly have abnormal website activity?
30 +
31 +# 2. PRODUCT PRINCIPLES
32 +
33 +## 2.1 Historical-first
34 +Never overwrite history when a new version arrives. Changes create new versions. We care about state(t0), state(t1), state(t2)… not just current_state. Current state is simply the latest historical state.
35 +
36 +## 2.2 Everything important should be timestamped
37 +discovered_at, first_seen_at, last_seen_at, fetched_at, changed_at, published_at, effective_at, processed_at — depending on entity type.
38 +
39 +## 2.3 Raw data and interpreted data must remain separate
40 +Never destroy source observations after interpretation. RAW SOURCE → RAW SNAPSHOT → NORMALIZED SNAPSHOT → DIFF → EVENT → LLM ENRICHMENT → METRICS. This allows future reprocessing when extraction models improve.
41 +
42 +## 2.4 LLMs are enrichment engines, not the crawler
43 +Do NOT send every fetched webpage to an expensive model. fetch → normalize → hash → compare → structural diff → semantic-change candidate → LLM only if useful.
44 +
45 +## 2.5 Build reusable connector families
46 +Do NOT hard-code every company individually. The system combines generic connectors + site adapters + discovered endpoints + company-specific configuration and must be capable of creating thousands of connectors automatically.
47 +
48 +## 2.6 Every crawl should answer a question
49 +Did pricing change? Did the leadership team change? Did jobs increase? Was a product introduced? Were locations added? Did documentation change? Specialized sensors produce better data than storing full websites.
50 +
51 +# 3. SCALE TARGETS
52 +Phase 1: 5,000 companies / 25,000+ active sensors. Phase 2: 25,000 / 150,000+. Phase 3: 100,000 / 750,000+. Phase 4: 1,000,000 / 5M–15M. An important company could have 10–100 monitored surfaces; long-tail companies 2–10. Sensors are lightweight entities.
53 +
54 +# 4. TERMINOLOGY
55 +**Company** canonical organization (Stripe, Inc.). **Domain** canonical corporate domain (stripe.com). **Surface** logical information surface (careers, pricing, newsroom, investor_relations, products, leadership, locations). **Connector** a software strategy able to collect a type of source (generic_html_connector, sitemap_connector, greenhouse_connector, lever_connector, json_endpoint_connector, rss_connector). **Sensor** a deployed connector instance attached to a specific source (Company: Stripe · Surface: Careers · Connector: generic_careers · URL: https://stripe.com/jobs) — sensors execute repeatedly. **Observation** one fetch/read of a sensor. **Snapshot** normalized representation of an observation. **Change** difference between snapshots. **Event** a meaningful interpreted corporate change.
56 +
57 +# 5. HIGH-LEVEL ARCHITECTURE
58 +Company Registry / Graph → Discovery Engine + Scheduler → Connector Workers (HTTP, Browser, Feeds) → Raw Storage → Normalization → Fingerprint / Diff → Change Detector → (insignificant → archive | meaningful → Event Pipeline → LLM Enrichment → Event Store → Metrics Engine → API / Search / UI).
59 +
60 +# 6. COMPANY REGISTRY
61 +Canonical company entity: id, slug, legal_name, display_name, canonical_domain, website, description, industry[], country, headquarters, founded_year, company_type, public_company, ticker, status, created_at, updated_at. Keep factual provenance. Do not merge entities based only on similar names.
62 +
63 +# 7. COMPANY GRAPH
64 +Relationships: company → domain, brand, parent, subsidiary, executive, office, product, competitor, acquisition, investor, technology, job, industry, country, announcement, event. Every edge supports temporal validity where relevant (valid_from, valid_to, first_seen, last_seen, source).
65 +
66 +# 8. CONNECTOR ARCHITECTURE
67 +Thousands of connectors but not thousands of unrelated codebases. Base interface: discover / fetch / extract / normalize / fingerprint / compare / emit_events. Metadata: connector_id, name, version, category, fetch_mode, supports_discovery, supports_incremental.
68 +
69 +# 9. CONNECTOR FAMILIES
70 +9.1 Core website connectors: homepage, about, company, leadership, team, products, services, solutions, industries, customers, partners, locations, contact, pricing, documentation, developer, API, changelog, blog, news, press, research, careers, legal, terms, privacy, security, trust, sustainability, ESG, investor relations.
71 +9.2 Discovery connectors: robots.txt, sitemap.xml, sitemap index, RSS, Atom, navigation discovery, HTML link graph, JSON-LD, schema.org, alternate language URLs, subdomain discovery, known URL patterns.
72 +9.3 Job connectors: Greenhouse-like, Lever-like, Workday public career surfaces, SmartRecruiters-like, Ashby-like, custom career pages, JSON-backed boards, HTML listings. Avoid relying on a single vendor API; prefer public endpoints already used by public pages when lawful and appropriate.
73 +
74 +# 10–11. SENSOR CREATION & AUTOMATIC DISCOVERY
75 +canonical domain → homepage fetch → robots inspection → sitemap discovery → navigation analysis → subdomain discovery → URL classification → surface identification → sensor creation. Signals: anchor text, URL paths, navigation hierarchy, page titles, schema markup, sitemap metadata, headings, CMS conventions, subdomains. Classes: CAREERS, NEWSROOM, BLOG, PRODUCTS, PRICING, ABOUT, LEADERSHIP, LOCATIONS, INVESTOR_RELATIONS, DOCUMENTATION, CHANGELOG, LEGAL, OTHER — each with a confidence.
76 +
77 +# 12. SENSOR QUALITY SCORE
78 +source reliability × extraction confidence × historical stability × semantic importance × recency → quality_score 0–100. Low quality sensors may be automatically reviewed.
79 +
80 +# 13. FETCH MODES
81 +A — lightweight HTTP (default: HTML, JSON, XML, RSS, sitemaps). B — browser rendering only when needed, pooled. C — public frontend network endpoint extraction where a public page loads data from public endpoints; store provenance. Never bypass authentication, access controls, CAPTCHAs; never collect non-public information.
82 +
83 +# 14. POLITENESS / COMPLIANCE
84 +Domain-specific rate limiting, robots policy awareness, retry budgets, crawl-delay, backoff, concurrency caps, clear user agent, attribution, provenance, suppression mechanism, legal/compliance flags. Never collect content requiring unauthorized access; never circumvent authentication; no private customer data.
85 +
86 +# 15–16. SCHEDULER & ADAPTIVE CRAWLING
87 +Tiers: A 5–15 min · B 30–60 min · C 6 h · D 24 h · E 3–7 days. Frequency adapts: significant changes → temporary burst (1/15 min) then decay. next_interval = base × stability × importance × failure × activity.
88 +
89 +# 17–20. CHANGE DETECTION, NORMALIZATION, BLOCK DIFFING, SIGNIFICANCE
90 +Noise (dates, analytics IDs, rotating testimonials, randomized content, tracking params, ads, cookie banners, session ids) is normalized away. Hashes: raw_hash, normalized_hash, structural_hash, semantic_hash. Normalization: remove scripts/styles, normalize whitespace, remove dynamic attributes, normalize URLs, strip tracking/session params, canonicalize headings, extract primary content, preserve structured data → normalized DOM, plaintext, semantic blocks, structured fields. Block-level diffing over semantic blocks (header, hero, product card, pricing plan, job listing, executive bio, office location, news article, FAQ, table) with stable identities → added / removed / modified / moved. Significance: 0–0.20 noise · 0.20–0.40 minor · 0.40–0.65 meaningful · 0.65–0.85 major · 0.85–1.00 critical. Inputs: % text changed, semantic similarity, page importance, affected structured entities, novelty, cross-source confirmation, historical baseline.
91 +
92 +# 21–23. EVENT TAXONOMY, MODEL, DEDUPLICATION
93 +Types: PRODUCT, PRICING, HIRING, LEADERSHIP, LOCATION, FINANCING, M&A, PARTNERSHIP, STRATEGY, TECHNOLOGY, LEGAL, MARKETING, DEVELOPER, SECURITY, OPERATIONS, SUSTAINABILITY, INVESTOR_RELATIONS, COMMUNICATION, OTHER. Subtypes: PRODUCT_LAUNCH, PRODUCT_REMOVAL, PRODUCT_RENAME, PRICE_INCREASE, PRICE_DECREASE, NEW_PRICING_TIER, JOB_COUNT_INCREASE, JOB_COUNT_DECREASE, NEW_EXECUTIVE, EXECUTIVE_REMOVED, NEW_OFFICE, OFFICE_REMOVED, COUNTRY_EXPANSION, NEW_PARTNERSHIP, ACQUISITION, DIVESTITURE, API_LAUNCH, DOCUMENTATION_CHANGE, TERMS_CHANGE, BRAND_REPOSITIONING. Event model: id, company_id, event_type, event_subtype, importance, confidence, title, summary, old_value, new_value, detected_at, effective_at, source_url, sensor_id, snapshot_before, snapshot_after, model_version. The same corporate event appearing on homepage, press release, blog, pricing page, IR → clustered into one canonical event with sources; corroboration increases confidence.
94 +
95 +# 24–25. LLM ENRICHMENT & MODEL ABSTRACTION
96 +LLMs receive only relevant changed content (company metadata, source type, before blocks, after blocks, structured changes). Tasks: classification, summary, importance, entity extraction, structured event generation, industry tagging, sentiment where appropriate, strategy interpretation. Structured JSON validated against schemas. Never hard-code one vendor: LLMProvider / EmbeddingProvider / RerankerProvider; local models, OpenAI-compatible endpoints; work with local cluster inference wherever practical.
97 +
98 +# 26–28. STORAGE
99 +Every relevant version retained (sensor_id, url, fetched_at, status_code, content_hash, normalized_hash, storage_pointer, content_type, size_bytes); compressed object storage; dedupe by hash. Layers: PostgreSQL (entities/metadata), object storage (raw), columnar analytics, search engine, vector index — abstracted. Internal event bus topics: company.created, sensor.created, sensor.fetch.requested/completed, snapshot.created, change.detected, event.generated, metric.updated, alert.triggered; async idempotent workers.
100 +
101 +# 29–37. PROPRIETARY METRICS
102 +Company Activity Score (0–100: website changes, product changes, news frequency, job movement, leadership changes, documentation, pricing). Hiring Momentum (open jobs, new/removed, departments, locations, seniority, remote ratio, skills; 7/30/90-day, YoY). AI Adoption Score (observable public signals only: AI products, jobs, docs, marketing, partnerships, research, leadership roles — never claim internal use without evidence). Product Velocity. Geographic Expansion Score. Developer Momentum. Corporate Change Index = 0.25 hiring + 0.20 product + 0.15 geographic + 0.15 leadership + 0.10 developer + 0.10 communication + 0.05 pricing (weights to become empirical). Unusual Activity Detection from per-company baselines ("Companies behaving unusually today"). Cross-company signals (AI hiring acceleration by industry, SaaS pricing increases, US manufacturing expansion…).
103 +
104 +# 38–48. PRODUCT SURFACES
105 +Industry Atlas (living index per industry: activity, companies, events, hiring, expansion, trending). Country Atlas (/country/canada …: activity, hiring, expansion, industry mix, top movers, new entrants). Company profile `/company/stripe`: header with Activity Score, Hiring Momentum, Product Velocity, AI Adoption; sections Overview, Timeline, Signals, Jobs, Products, Locations, Leadership, Technology, Sources, Historical. Company Timeline (dated entries, filters all/products/jobs/pricing/leadership/locations/legal/news/developer). Historical Page Viewer (semantic diff between versions). Global Live Feed on the homepage (cards: company, change, "2 minutes ago", incremental updates). Homepage hero **The Live Atlas of Global Companies** with animated counters (companies, sensors, observations, changes, structured events) and sections: Hero, Live Activity Feed, Companies Moving Fastest, Global Activity Map, Hiring Momentum, Product Launches, Pricing Changes, AI Adoption, Industries, Countries, Trending Signals, Platform Statistics, API/Data CTA. Map with clustering (HQs, expansions, offices, event density, hiring). Search over companies, industries, events, products, people, locations, keywords, technologies ("companies hiring AI engineers in Canada"). Later: "Ask Company Atlas" natural-language search always linking back to events and sources.
106 +
107 +# 49–50. PROVENANCE & CONFIDENCE
108 +Every important fact traceable (Source, First seen, Last checked, Historical evidence, Confidence). Statuses: VERIFIED, HIGH CONFIDENCE, LIKELY, INFERRED, LOW CONFIDENCE. Never present inference as verified fact without labeling.
109 +
110 +# 51–54. API, EVENT API, STREAMING, EXPORTS
111 +API-first `/api/v1`: companies, companies/{id}, events, metrics, jobs, history, events (filters event_type, country, since…), industries, countries, search. Streaming later (WebSocket, SSE, webhooks). Exports JSON, CSV, Parquet, NDJSON.
112 +
113 +# 55–56. USERS & WATCHLISTS
114 +Public browsing without account. Accounts only for watchlists, alerts, API access, saved searches, dashboards, exports. Watchlists with alerts (product launch, pricing change, leadership change, hiring spike, location expansion, activity anomaly).
115 +
116 +# 57–66. INTERNAL ADMIN & OPERATIONS
117 +/admin modules: companies, domains, sensors, connectors, crawl queue, worker health, failures, snapshots, changes, events, duplicates, LLM jobs, metrics, sources, alerts, system statistics. Connector Control Center (version, active sensors, success rate, latency, change detection rate, errors, last deployment). Sensor Control Center (filters healthy/failing/stale/blocked/redirected/low quality/high activity; actions pause/resume/retry/rediscover/change frequency/change connector). Auto-repair (retry → re-fetch sitemap → rediscover navigation → replacement URL → compare content identity → migrate sensor; flag if uncertain). Connector versioning (generic-pricing-v1 → v2; version stored with observations). Failure classes DNS, TIMEOUT, HTTP_4XX, HTTP_5XX, BOT_CHALLENGE, PARSING, SCHEMA, REDIRECT, PAGE_REMOVED, RATE_LIMIT, UNKNOWN with distinct retry policies. Domain budgets (max_concurrency, requests_per_minute, daily_budget, browser_budget, retry_budget). Priority queue (importance, watchlists, recent activity, time since last crawl, sensor value, reliability, cost). Cost accounting per company/sensor/connector/fetch/browser/LLM/GB/event. Observability (fetch/sec, success rate, changes/sec, meaningful event rate, queue lag, browser utilization, storage growth, LLM jobs, false positive rate, connector failures).
118 +
119 +# 67–69. INFRASTRUCTURE
120 +Logical services: atlas-web, atlas-api, atlas-scheduler, atlas-discovery, atlas-fetch-http, atlas-fetch-browser, atlas-normalizer, atlas-diff, atlas-events, atlas-llm, atlas-metrics, atlas-search, atlas-admin, atlas-worker-manager. Stateless workers, horizontal scaling (add machine → register worker → joins queue), configurable node roles.
121 +
122 +# 70–82. DATA MODEL DETAILS
123 +Tables: companies, domains, company_aliases, company_relationships, surfaces, connectors, sensors, observations, snapshots, changes, events, event_sources, people, jobs, products, locations, metrics, metric_series, crawl_runs, failures, users, watchlists, alerts. Metrics are time series (never only latest). Jobs: title, department, location, remote, employment_type, seniority, skills, salary where public, posted_at, first_seen, last_seen, removed_at. Leadership: name, title, role category, first/last seen, source — disappearance is "No longer listed on monitored leadership page", never "Fired". Products: new/changed/renamed/removed from public catalog. Pricing: plan, currency, billing period, price, features, usage units, enterprise/contact sales — preserve every version. Locations: office/store/factory/warehouse/lab/headquarters with city/region/country — never invent exact addresses. Technology signals: observed / stated / inferred. Newsroom: title, URL, published_at, category, entities, summary (first-party = high provenance). Investor relations for public companies (not a substitute for regulated filings). Legal change tracking with semantic summaries ("Section 7 was materially updated"), source versions preserved. Duplicate company resolution by multiple signals; never auto-merge on name alone. Internationalization: store original language + normalized English summaries (source_language, normalized_language, translation_model).
124 +
125 +# 83–88. URL DESIGN, SEO, DESIGN LANGUAGE
126 +Clean URLs: /company/apple, /industry/artificial-intelligence, /country/canada, /events, /live, /rankings, /search, /api, /about. SEO: metadata, OpenGraph, canonical, schema markup, sitemaps; do not index thin profiles until sufficient data. Design: premium intelligence terminal blended with a modern data atlas — dense but readable, beautiful typography, live counters, micro visualizations, timelines, maps, tables, sparklines, confidence indicators, real-time status; avoid generic startup cards, excessive gradients, cartoon visuals, unused whitespace, clutter. Desktop: powerful tables, keyboard search, filters, multi-column, comparison, density. Mobile first-class: responsive typography, bottom navigation (Home, Live, Search, Rankings, Watchlist), touch controls, sticky search, compact metrics, collapsible filters, swipe-friendly timelines, fast loads. Live visual language: green live dot, "17 sec ago", new-event animation, counter increments, sparkline updates — never chaotic.
127 +
128 +# 89–93. RANKINGS, COMPARISON, GLOBAL INDEX, SIGNALS, PREDICTIONS
129 +Rankings: Most Active, Fastest Hiring Growth/Decline, Highest Product Velocity, Most AI-Active, Fastest Geographic Expansion, Highest Developer Momentum, Most Pricing Changes, Most Unusual Activity — windows 24h/7d/30d/90d/1y. Comparison `/company/compare?companies=stripe,adyen,block` (activity, hiring, product velocity, AI, locations, events). Global Corporate Activity Index (baseline 100 = normalized historical activity; by industry, country, size, event type). Signals (hiring surge, hiring freeze, launch buildup, international expansion, pricing migration, developer push, enterprise repositioning, AI acceleration) labeled as signals, not facts. Predictions only as explicitly probabilistic ("Possible launch preparation signal — Confidence 63%").
130 +
131 +# 94–98. FLYWHEEL, MOAT, RETENTION, CAS, BACKFILL
132 +More companies → sensors → observations → baselines → anomalies → events → metrics → users → watchlists → prioritization → dataset value. Moat = historical snapshots, normalized entities, change events, sensor reliability history, historical metrics, comparisons, graph. Long-term retention; identical snapshots share content objects; keep observation metadata. Content-addressable storage sha256 `objects/ab/cd/hash`. Backfills marked collection_method = backfill, never blurred with live.
133 +
134 +# 99–106. SEED, FIRST TARGET, CONNECTOR FACTORY, ONBOARDING, REGISTRY, FIXTURES, ADAPTERS, SDK
135 +Seed diversified companies (technology, AI, finance, banking, insurance, retail, energy, manufacturing, healthcare, biotech, pharma, transportation, aerospace, telecom, media, real estate, construction, logistics, automotive, consumer) across US, Canada, Europe, UK, Japan, South Korea, India, Australia, Latin America, Middle East, Africa, Southeast Asia. First target 5,000+ companies / 25,000+ functioning sensors. Bootstrap connector factory: domain → surfaces[{type, url, connector, confidence, frequency}]. Mass onboarding pipeline fully parallelizable (import → canonicalization → domain validation → discovery → surface mapping → sensor generation → initial fetch → quality validation → scheduler activation). Machine-readable connector registry (manifest, schema, implementation, fixtures, tests, version). Fixtures for every major connector; never depend entirely on live sites in CI. Site-specific adapters inherit generic logic. Connector SDK decorator style.
136 +
137 +# 107–112. EXTRACTION & BANDWIDTH
138 +Semantic extraction (DOM, ARIA, headings, lists, tables, JSON-LD, microdata, embedded JSON) over brittle selectors. Structured data first. Crawl fingerprinting (etag, last-modified, content-length, status, hash, DOM fingerprint) with conditional requests. Compression, connection pooling, streaming, size limits, selective HEAD. Do not download images/videos/fonts/binaries/tracking scripts by default. Screenshots only for high-impact changes/human review.
139 +
140 +# 113–131. IMPORTANCE, SECURITY, CANONICALIZATION, LOOPS, PAGE VALUE, QUALITY, REVIEW, AUDIT, VERSIONING, BACKPRESSURE, IDEMPOTENCY, LOCKS, HEALTH, PUBLIC STATS
141 +Event importance ≠ confidence; company importance affects crawl priority only. Security: strict URL validation, SSRF protection, DNS rebinding protection, private-IP blocking, download/redirect limits, content-type checks, sandboxed parsing, secret separation; crawler must NEVER access localhost, RFC1918, metadata endpoints, cluster internal domains, Tailscale/private hosts, file://, unix sockets. URL canonicalization (scheme, host casing, ports, tracking params, fragments, duplicate slashes, session params) with the original preserved. Loop prevention (calendars, facets, random params, endless pagination, session IDs) via budgets. Page value classifier for discovered URLs. Data quality dashboard (coverage, freshness, accuracy, duplicate rate, event confidence, stale/failed sensors, unknown surfaces). Human review queues (company merge, major event, low-confidence extraction, sensor migration, legal-sensitive inference, unexpected activity) feeding evaluation data. Auditability: what changed, where, when, evidence, extraction version, model. Model + prompt versioning (prompts in code, e.g. prompts/event-classifier/v3.md). Reprocessing without recollecting. Backpressure prioritizes important/high-value/fresh/active work without losing queued work. Job idempotency (sensor_id + scheduled_window). Distributed locks. /health /ready /metrics per service. Public /system page with aggregate health only. Public statistics counters.
142 +
143 +# 132–148. VISUALIZATIONS, THEMES, PERFORMANCE, CACHING, RATE LIMITS, AUTH, PRIVACY, ALERTS, DIGESTS, TRENDS, COVERAGE NORMALIZATION, BASELINES, CALIBRATION, REGRESSION
144 +Sparklines, time series, heatmaps, maps, histograms, rankings, timelines, network graphs (few pie charts). Dark/light both intentional (default follows system). Fast SSR, progressive hydration, cached public pages, optimized queries. Cache layers (request, query, entity, search, homepage aggregate) — not live feeds. API rate-limit tiers anonymous/authenticated/paid/internal. Auth (if added): passkeys, OAuth, magic links. Privacy: no consumer profiling; executive data limited to legitimate public professional context. Alerts (activity > 80, new executive, price change, job count −30 %, new country, product launch) via email/web/webhook. Weekly company digest and market digests. Trend engine with rolling windows and momentum (7/30/90 d) normalized by coverage growth. **Coverage normalization is critical**: long-term indices adjust for sensors, company population, crawl frequency, industry/country coverage. Baseline engine per sensor/company (changes/week, jobs, announcements, volatility). Quality calibration (correct / duplicate / noise / misclassified). Regression tests against historical fixtures before changing normalizers, diff engine, extractors, prompts.
145 +
146 +# 149–155. STACK, REPO, ENV, DOMAIN, DEPLOYMENT, BACKUPS
147 +Next.js/TypeScript/React front; Python and/or TypeScript services; PostgreSQL; Redis-backed queue initially or durable broker; S3-compatible object storage; columnar analytics as scale requires; full-text search; Dockerized services; no vendor coupling. Monorepo apps/ services/ packages/ connectors/ prompts/ fixtures/ scripts/ infra/ docs/. `.env.example`, never commit secrets (DATABASE_URL, REDIS_URL, OBJECT_STORAGE_*, LLM_ENDPOINT, LLM_API_KEY, SEARCH_URL). Production hostname www.company-atlas.com with apex redirect, HTTPS, no hard-coded IPs. Rolling updates, health checks, restart policies, persistent storage separation, logs, metrics, service discovery, environment separation. Back up PostgreSQL, configuration, indexes, connector metadata; object storage redundancy; test restores; object storage is not a backup — a second independent copy is required.
148 +
149 +# 156–161. MVP & PHASES
150 +MVP: 5,000 companies, 25,000 sensors, continuous monitoring, company profiles, live feed, historical timeline, change detection, event extraction, search, rankings, activity score, admin monitoring. MVP connector categories: homepage, careers, news, products, pricing, leadership, locations, blog, documentation, investor relations, sitemap. MVP events: NEW_JOB, JOB_REMOVED, JOB_COUNT_CHANGE, NEW_PRODUCT, PRODUCT_REMOVED, PRICING_CHANGE, LEADERSHIP_CHANGE, NEW_LOCATION, NEWS_RELEASE, DOC_CHANGE. Phase 2: company graph, comparisons, industry/country indices, watchlists, alerts, API, historical viewer, AI adoption, developer momentum. Phase 3: 100k+ companies, advanced graph, anomaly engine, NL search, cross-company trends, datasets, streaming API, historical analytics. Phase 4: 1M+ companies, 5M+ sensors.
151 +
152 +# 162–168. COPY, POSITIONING, BRAND, ERROR UX, TRANSPARENCY, ETHICS, LANGUAGE
153 +Headline **The Live Atlas of Global Companies**. Supporting: "Company Atlas continuously observes the public web to track how companies evolve — products, hiring, pricing, leadership, locations, technology, strategy and more." Alt: "Thousands of companies. Millions of observations. One continuously growing historical record." Positioning: not a directory, not a news aggregator, not a web archive, not a financial terminal — **a continuously updated corporate observation network**. Brand: intelligent, global, precise, technical, credible, alive, data-rich, premium; not sci-fi. Error UX: "No monitored evidence available yet." / "Last successfully checked 3 days ago." — never fabricate. Every user-facing event links to its public source; if the source disappears, say so. Ethical inference: "72 monitored listings are no longer visible", never "Company laid off 72 employees". Language: detected, observed, publicly listed, no longer listed, appears, signal, inferred, confirmed by.
154 +
155 +# 169–181. STATES, PROVENANCE GRAPH, SOURCE UI, TIME ZONES, IDS, SOFT DELETES, CORRECTIONS, SCHEMA EVOLUTION, INDEX REPRODUCIBILITY, NO MAGIC NUMBERS, FEATURE FLAGS, EXPERIMENTATION, DOCS
156 +Company states ACTIVE, POSSIBLY_INACTIVE, WEBSITE_UNAVAILABLE, ACQUIRED, DISSOLVED, UNKNOWN (require corroboration). Provenance graph entity field → extraction → snapshot → observation → sensor → URL. Event source UI lists each source with detection times. UTC storage, local rendering, preserve source timezone. Immutable internal ids; slugs may change; never name as key. Soft deletes via statuses. Corrections: status = retracted with audit history. Versioned schemas and careful migrations. Reproducible indices (formula version, inputs, normalization window, computed_at). No magic numbers (typed config). Feature flags for experimental systems. A/B evaluation of extractors/normalizers/classifiers/schedules (unstable metrics labeled). Docs: architecture, connector-sdk, event-taxonomy, data-model, deployment, operations, scoring.
157 +
158 +# 182–183. CLAUDE CODE OPERATING RULES & STYLE
159 +1. Inspect existing architecture first. 2. Preserve working functionality. 3. Avoid unnecessary rewrites. 4. Prefer reusable abstractions. 5. Add tests with new connector behavior. 6. Keep schemas explicit. 7. Never introduce fabricated data. 8. Never hard-code secrets. 9. Do not break mobile. 10. Do not break historical reproducibility. 11. Do not delete historical data during migrations without explicit reason. 12. Run lint/typecheck/tests after meaningful changes. Favor small composable services, typed contracts, schemas, idempotent workers, deterministic processing, clear observability; avoid giant functions, silent error handling, unbounded crawling, LLMs for deterministic tasks, vendor lock-in.
160 +
161 +# 184–200. PHILOSOPHY & FINAL DEFINITION
162 +Every company on Earth should feel like it has virtual sensors attached (APPLE 63 sensors, NVIDIA 48, STRIPE 41, LOCAL BUSINESS 5) living continuously. "Thousands of connectors" is not marketing: 25,000 deployed sensors initially, then 100,000, 1,000,000, 10,000,000+ — configuration-driven and auto-discoverable. Track dataset age and oldest continuous history prominently. Historical Completeness Score per company (sensor uptime, continuity, source coverage, failed periods). Show data density (active sensors, observations, historical changes, structured events). Discovery feedback loop (event → URL discovery → new sensor). Sensor retirement keeps history and seeks successors. Domain migration continuity. Acquisition continuity (ACQUIRED_BY relationship, no blind merges). Entity resolution with stored confidence. Published API docs (/api) with examples, schemas, pagination, rate limits, filters, webhooks. Monetization readiness (free public, pro research, enterprise API, bulk datasets, alerts, custom monitoring) without crippling the free product. Data licensing metadata; redistribute derived data/changes/facts/metadata rather than raw content where required. Long-term: 1M+ companies, 10M+ sensors, billions of observations, years of continuous history.
163 +
164 +> **Company Atlas is a distributed global sensor network for companies.** Each company receives persistent monitoring sensors. Each sensor produces observations. Observations become historical snapshots. Snapshots produce changes. Changes become structured events. Events become metrics. Metrics become intelligence. And every day the platform runs, the dataset becomes more valuable. Optimize for what this dataset becomes after 1, 3, 5, 10 years. That accumulated history is the product.
added migrations/env.py +45 −0
@@ -0,0 +1,45 @@
1 +"""Alembic environment (async engine). Migrations are plain SQL executed through `op.execute`."""
2 +from __future__ import annotations
3 +
4 +import asyncio
5 +from logging.config import fileConfig
6 +
7 +from alembic import context
8 +from sqlalchemy.ext.asyncio import create_async_engine
9 +
10 +from companyatlas.config import settings
11 +
12 +config = context.config
13 +if config.config_file_name is not None:
14 + fileConfig(config.config_file_name)
15 +
16 +target_metadata = None
17 +
18 +
19 +def run_migrations_offline() -> None:
20 + context.configure(url=settings.sync_database_url, literal_binds=True, dialect_opts={"paramstyle": "named"})
21 + with context.begin_transaction():
22 + context.run_migrations()
23 +
24 +
25 +def do_run_migrations(connection) -> None: # type: ignore[no-untyped-def]
26 + context.configure(connection=connection, target_metadata=target_metadata, transaction_per_migration=True)
27 + with context.begin_transaction():
28 + context.run_migrations()
29 +
30 +
31 +async def run_async_migrations() -> None:
32 + engine = create_async_engine(settings.database_url, poolclass=None)
33 + async with engine.connect() as connection:
34 + await connection.run_sync(do_run_migrations)
35 + await engine.dispose()
36 +
37 +
38 +def run_migrations_online() -> None:
39 + asyncio.run(run_async_migrations())
40 +
41 +
42 +if context.is_offline_mode():
43 + run_migrations_offline()
44 +else:
45 + run_migrations_online()
added migrations/script.py.mako +21 −0
@@ -0,0 +1,21 @@
1 +"""${message}
2 +
3 +Revision ID: ${up_revision}
4 +Revises: ${down_revision | comma,n}
5 +"""
6 +from __future__ import annotations
7 +
8 +from alembic import op
9 +
10 +revision = ${repr(up_revision)}
11 +down_revision = ${repr(down_revision)}
12 +branch_labels = ${repr(branch_labels)}
13 +depends_on = ${repr(depends_on)}
14 +
15 +
16 +def upgrade() -> None:
17 + ${upgrades if upgrades else "pass"}
18 +
19 +
20 +def downgrade() -> None:
21 + raise RuntimeError("forward-only")
added migrations/versions/0001_initial.py +791 −0
@@ -0,0 +1,791 @@
1 +"""Company Atlas — initial schema (spec §6–7, §22, §26, §70–76).
2 +
3 +Historical-first: nothing here is ever overwritten by the pipeline — observations, snapshots, changes and events are append-only;
4 +entities (jobs, people, products, plans, locations) carry first_seen / last_seen / removed_at and a status instead of being deleted.
5 +
6 +Revision ID: 0001
7 +Revises:
8 +"""
9 +from __future__ import annotations
10 +
11 +from alembic import op
12 +
13 +revision = "0001"
14 +down_revision = None
15 +branch_labels = None
16 +depends_on = None
17 +
18 +SQL = r"""
19 +create extension if not exists pg_trgm;
20 +create extension if not exists "uuid-ossp";
21 +
22 +-- ============================================================================================================ reference
23 +create table if not exists industries (
24 + slug text primary key,
25 + name text not null,
26 + parent_slug text references industries(slug),
27 + description text,
28 + keywords text[] not null default '{}',
29 + sort_order int not null default 100
30 +);
31 +
32 +create table if not exists countries (
33 + code char(2) primary key,
34 + name text not null,
35 + region text,
36 + subregion text,
37 + lat double precision,
38 + lon double precision
39 +);
40 +
41 +-- ============================================================================================================ companies
42 +create table if not exists companies (
43 + id text primary key,
44 + slug text not null unique,
45 + legal_name text,
46 + display_name text not null,
47 + canonical_domain text not null unique,
48 + website text not null,
49 + description text,
50 + industries text[] not null default '{}',
51 + industry_primary text references industries(slug),
52 + country char(2) references countries(code),
53 + hq_city text,
54 + hq_region text,
55 + founded_year int,
56 + company_type text,
57 + public_company boolean not null default false,
58 + ticker text,
59 + exchange text,
60 + employees_band text,
61 + employees int,
62 + wikidata_id text unique,
63 + lei text,
64 + sec_cik text,
65 + logo_url text,
66 + status text not null default 'ACTIVE',
67 + onboarding_status text not null default 'pending',
68 + onboarding_error text,
69 + importance real not null default 0.2,
70 + tier smallint not null default 4,
71 + indexed boolean not null default false,
72 + source_meta jsonb not null default '{}'::jsonb,
73 + stats jsonb not null default '{}'::jsonb,
74 + discovered_at timestamptz not null default now(),
75 + first_observed_at timestamptz,
76 + last_observed_at timestamptz,
77 + last_change_at timestamptz,
78 + last_event_at timestamptz,
79 + created_at timestamptz not null default now(),
80 + updated_at timestamptz not null default now(),
81 + search tsvector generated always as (
82 + setweight(to_tsvector('simple', coalesce(display_name, '')), 'A') ||
83 + setweight(to_tsvector('simple', coalesce(legal_name, '')), 'B') ||
84 + setweight(to_tsvector('simple', coalesce(canonical_domain, '')), 'B') ||
85 + setweight(to_tsvector('english', coalesce(description, '')), 'C')) stored
86 +);
87 +create index if not exists companies_search_idx on companies using gin (search);
88 +create index if not exists companies_name_trgm_idx on companies using gin (display_name gin_trgm_ops);
89 +create index if not exists companies_domain_trgm_idx on companies using gin (canonical_domain gin_trgm_ops);
90 +create index if not exists companies_country_idx on companies (country);
91 +create index if not exists companies_industry_idx on companies using gin (industries);
92 +create index if not exists companies_importance_idx on companies (importance desc);
93 +create index if not exists companies_onboarding_idx on companies (onboarding_status) where onboarding_status <> 'active';
94 +create index if not exists companies_last_event_idx on companies (last_event_at desc nulls last);
95 +
96 +create table if not exists company_aliases (
97 + company_id text not null references companies(id) on delete cascade,
98 + alias text not null,
99 + alias_norm text not null,
100 + kind text not null default 'alias', -- alias | legal | brand | former | ticker | native
101 + source text,
102 + primary key (company_id, alias_norm)
103 +);
104 +create index if not exists company_aliases_norm_idx on company_aliases (alias_norm);
105 +
106 +create table if not exists domains (
107 + id text primary key,
108 + company_id text not null references companies(id) on delete cascade,
109 + domain text not null,
110 + kind text not null default 'primary', -- primary | alias | redirect | subdomain | former
111 + first_seen_at timestamptz not null default now(),
112 + last_seen_at timestamptz not null default now(),
113 + status text not null default 'active',
114 + unique (domain, company_id)
115 +);
116 +create index if not exists domains_domain_idx on domains (domain);
117 +
118 +create table if not exists company_relationships (
119 + id text primary key,
120 + from_company_id text not null references companies(id) on delete cascade,
121 + to_company_id text references companies(id) on delete set null,
122 + to_name text,
123 + kind text not null, -- PARENT_OF | SUBSIDIARY_OF | ACQUIRED_BY | ACQUIRED | PARTNER_OF | COMPETITOR_OF | INVESTOR_IN | BRAND_OF
124 + valid_from date,
125 + valid_to date,
126 + first_seen_at timestamptz not null default now(),
127 + last_seen_at timestamptz not null default now(),
128 + source_url text,
129 + confidence real not null default 0.5,
130 + provenance jsonb not null default '{}'::jsonb
131 +);
132 +create index if not exists company_relationships_from_idx on company_relationships (from_company_id, kind);
133 +create index if not exists company_relationships_to_idx on company_relationships (to_company_id, kind);
134 +
135 +-- ============================================================================================================ connectors / sensors
136 +create table if not exists connectors (
137 + id text primary key, -- generic-html-v1, greenhouse-v1 …
138 + name text not null,
139 + version text not null,
140 + category text not null, -- surface
141 + fetch_mode text not null default 'http',
142 + supports_discovery boolean not null default false,
143 + supports_incremental boolean not null default true,
144 + default_interval_s int not null default 86400,
145 + enabled boolean not null default true,
146 + created_at timestamptz not null default now(),
147 + updated_at timestamptz not null default now(),
148 + stats jsonb not null default '{}'::jsonb
149 +);
150 +
151 +create table if not exists sensors (
152 + id text primary key,
153 + company_id text not null references companies(id) on delete cascade,
154 + surface text not null,
155 + connector_id text not null references connectors(id),
156 + url text not null,
157 + canonical_url text not null,
158 + domain text not null,
159 + discovery_confidence real not null default 0.5,
160 + discovery_method text, -- nav | sitemap | robots | pattern | ats | feed | manual | event
161 + quality_score real not null default 50,
162 + status text not null default 'pending',
163 + tier char(1) not null default 'D',
164 + base_interval_s int not null default 86400,
165 + current_interval_s int not null default 86400,
166 + next_run_at timestamptz not null default now(),
167 + last_run_at timestamptz,
168 + last_success_at timestamptz,
169 + last_change_at timestamptz,
170 + last_meaningful_change_at timestamptz,
171 + last_status int,
172 + last_failure_class text,
173 + last_error text,
174 + consecutive_failures int not null default 0,
175 + consecutive_unchanged int not null default 0,
176 + etag text,
177 + last_modified text,
178 + last_content_hash text,
179 + last_normalized_hash text,
180 + last_structural_hash text,
181 + last_snapshot_id text,
182 + snapshot_count int not null default 0,
183 + observation_count int not null default 0,
184 + change_count int not null default 0,
185 + meaningful_change_count int not null default 0,
186 + event_count int not null default 0,
187 + config jsonb not null default '{}'::jsonb,
188 + priority real not null default 0.5,
189 + claimed_by text,
190 + claimed_at timestamptz,
191 + created_at timestamptz not null default now(),
192 + updated_at timestamptz not null default now(),
193 + retired_at timestamptz,
194 + unique (company_id, canonical_url)
195 +);
196 +create index if not exists sensors_due_idx on sensors (next_run_at) where status in ('active', 'failing', 'pending');
197 +create index if not exists sensors_company_idx on sensors (company_id, surface);
198 +create index if not exists sensors_domain_idx on sensors (domain);
199 +create index if not exists sensors_status_idx on sensors (status);
200 +create index if not exists sensors_connector_idx on sensors (connector_id);
201 +create index if not exists sensors_claimed_idx on sensors (claimed_at) where claimed_by is not null;
202 +
203 +create table if not exists domain_budgets (
204 + domain text primary key,
205 + max_concurrency int not null default 2,
206 + requests_per_minute int not null default 20,
207 + daily_budget int not null default 600,
208 + browser_budget int not null default 20,
209 + used_today int not null default 0,
210 + browser_used_today int not null default 0,
211 + budget_day date not null default current_date,
212 + crawl_delay_s real,
213 + blocked_until timestamptz,
214 + block_reason text,
215 + notes text,
216 + updated_at timestamptz not null default now()
217 +);
218 +
219 +-- ============================================================================================================ observations / snapshots
220 +create table if not exists observations (
221 + id text primary key,
222 + sensor_id text not null references sensors(id) on delete cascade,
223 + company_id text not null references companies(id) on delete cascade,
224 + fetched_at timestamptz not null default now(),
225 + status_code int,
226 + duration_ms int,
227 + transport text not null default 'http',
228 + final_url text,
229 + redirects int not null default 0,
230 + not_modified boolean not null default false,
231 + changed boolean not null default false,
232 + failure_class text,
233 + error text,
234 + content_hash text,
235 + normalized_hash text,
236 + structural_hash text,
237 + object_key text,
238 + size_bytes int,
239 + content_type text,
240 + connector_version text,
241 + collection_method text not null default 'live',
242 + worker text
243 +);
244 +create index if not exists observations_sensor_idx on observations (sensor_id, fetched_at desc);
245 +create index if not exists observations_company_idx on observations (company_id, fetched_at desc);
246 +create index if not exists observations_time_idx on observations (fetched_at desc);
247 +create index if not exists observations_failure_idx on observations (failure_class, fetched_at desc) where failure_class is not null;
248 +
249 +create table if not exists snapshots (
250 + id text primary key,
251 + sensor_id text not null references sensors(id) on delete cascade,
252 + company_id text not null references companies(id) on delete cascade,
253 + observation_id text references observations(id) on delete set null,
254 + previous_snapshot_id text,
255 + version_no int not null default 1,
256 + fetched_at timestamptz not null default now(),
257 + content_hash text not null,
258 + normalized_hash text not null,
259 + structural_hash text not null,
260 + object_key text, -- raw bytes
261 + text_key text, -- normalized text
262 + blocks_key text, -- JSON blocks
263 + extracted jsonb not null default '{}'::jsonb, -- structured fields (jobs, prices, people, locations, news, products, meta)
264 + extracted_summary jsonb not null default '{}'::jsonb, -- small counters for listings (job_count, plan_count …)
265 + title text,
266 + language text,
267 + size_bytes int,
268 + text_length int,
269 + block_count int,
270 + content_type text,
271 + connector_version text,
272 + collection_method text not null default 'live'
273 +);
274 +create index if not exists snapshots_sensor_idx on snapshots (sensor_id, fetched_at desc);
275 +create index if not exists snapshots_company_idx on snapshots (company_id, fetched_at desc);
276 +
277 +create table if not exists changes (
278 + id text primary key,
279 + sensor_id text not null references sensors(id) on delete cascade,
280 + company_id text not null references companies(id) on delete cascade,
281 + surface text not null,
282 + snapshot_before text references snapshots(id) on delete set null,
283 + snapshot_after text not null references snapshots(id) on delete cascade,
284 + detected_at timestamptz not null default now(),
285 + significance real not null,
286 + kind text not null, -- noise | minor | meaningful | major | critical
287 + blocks_added int not null default 0,
288 + blocks_removed int not null default 0,
289 + blocks_modified int not null default 0,
290 + blocks_moved int not null default 0,
291 + text_delta_ratio real not null default 0,
292 + similarity real,
293 + diff jsonb not null default '{}'::jsonb, -- block-level diff (bounded)
294 + structured_delta jsonb not null default '{}'::jsonb, -- typed deltas (jobs added/removed, prices, people …)
295 + status text not null default 'pending', -- pending | processed | enriched | archived
296 + processed_at timestamptz,
297 + diff_version text not null default 'diff-v1'
298 +);
299 +create index if not exists changes_company_idx on changes (company_id, detected_at desc);
300 +create index if not exists changes_sensor_idx on changes (sensor_id, detected_at desc);
301 +create index if not exists changes_kind_idx on changes (kind, detected_at desc);
302 +create index if not exists changes_pending_idx on changes (status) where status = 'pending';
303 +
304 +-- ============================================================================================================ events
305 +create table if not exists event_clusters (
306 + id text primary key,
307 + company_id text not null references companies(id) on delete cascade,
308 + cluster_key text not null unique,
309 + event_type text not null,
310 + event_subtype text,
311 + title text,
312 + first_detected_at timestamptz not null default now(),
313 + last_detected_at timestamptz not null default now(),
314 + source_count int not null default 1,
315 + surfaces text[] not null default '{}',
316 + confidence real not null default 0.5,
317 + canonical_event_id text
318 +);
319 +
320 +create table if not exists events (
321 + id text primary key,
322 + company_id text not null references companies(id) on delete cascade,
323 + sensor_id text references sensors(id) on delete set null,
324 + change_id text references changes(id) on delete set null,
325 + cluster_id text references event_clusters(id) on delete set null,
326 + surface text,
327 + event_type text not null,
328 + event_subtype text not null,
329 + importance real not null default 0.5,
330 + confidence real not null default 0.7,
331 + confidence_label text not null default 'LIKELY',
332 + title text not null,
333 + summary text,
334 + old_value text,
335 + new_value text,
336 + payload jsonb not null default '{}'::jsonb,
337 + entities jsonb not null default '{}'::jsonb, -- {jobs:[…], people:[…], products:[…], locations:[…], amounts:[…]}
338 + tags text[] not null default '{}',
339 + detected_at timestamptz not null default now(),
340 + effective_at timestamptz,
341 + published_at timestamptz,
342 + source_url text,
343 + snapshot_before text,
344 + snapshot_after text,
345 + language text,
346 + origin text not null default 'deterministic', -- deterministic | llm | hybrid | backfill
347 + model_provider text,
348 + model_name text,
349 + model_version text,
350 + prompt_version text,
351 + schema_version text not null default 'event-v1',
352 + status text not null default 'active',
353 + retracted_reason text,
354 + dedupe_key text unique,
355 + created_at timestamptz not null default now(),
356 + search tsvector generated always as (
357 + setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
358 + setweight(to_tsvector('english', coalesce(summary, '')), 'B')) stored
359 +);
360 +create index if not exists events_company_idx on events (company_id, detected_at desc);
361 +create index if not exists events_time_idx on events (detected_at desc) where status = 'active';
362 +create index if not exists events_type_idx on events (event_type, detected_at desc);
363 +create index if not exists events_subtype_idx on events (event_subtype, detected_at desc);
364 +create index if not exists events_importance_idx on events (importance desc, detected_at desc);
365 +create index if not exists events_search_idx on events using gin (search);
366 +create index if not exists events_cluster_idx on events (cluster_id);
367 +create index if not exists events_tags_idx on events using gin (tags);
368 +
369 +create table if not exists event_sources (
370 + event_id text not null references events(id) on delete cascade,
371 + sensor_id text references sensors(id) on delete set null,
372 + source_url text not null,
373 + snapshot_id text,
374 + surface text,
375 + detected_at timestamptz not null default now(),
376 + kind text not null default 'primary',
377 + primary key (event_id, source_url)
378 +);
379 +
380 +-- ============================================================================================================ extracted entities
381 +create table if not exists jobs (
382 + id text primary key,
383 + company_id text not null references companies(id) on delete cascade,
384 + sensor_id text references sensors(id) on delete set null,
385 + external_id text,
386 + fingerprint text not null,
387 + title text not null,
388 + department text,
389 + team text,
390 + location_text text,
391 + city text,
392 + region text,
393 + country char(2),
394 + remote boolean,
395 + employment_type text,
396 + seniority text,
397 + skills text[] not null default '{}',
398 + salary_min numeric,
399 + salary_max numeric,
400 + salary_currency text,
401 + salary_period text,
402 + url text,
403 + description_hash text,
404 + posted_at timestamptz,
405 + first_seen_at timestamptz not null default now(),
406 + last_seen_at timestamptz not null default now(),
407 + removed_at timestamptz,
408 + status text not null default 'open', -- open | no_longer_listed
409 + is_ai boolean not null default false,
410 + is_engineering boolean not null default false,
411 + raw jsonb not null default '{}'::jsonb,
412 + unique (company_id, fingerprint)
413 +);
414 +create index if not exists jobs_company_idx on jobs (company_id, status);
415 +create index if not exists jobs_first_seen_idx on jobs (first_seen_at desc);
416 +create index if not exists jobs_country_idx on jobs (country) where status = 'open';
417 +create index if not exists jobs_ai_idx on jobs (company_id) where is_ai and status = 'open';
418 +create index if not exists jobs_title_trgm_idx on jobs using gin (title gin_trgm_ops);
419 +
420 +create table if not exists people (
421 + id text primary key,
422 + company_id text not null references companies(id) on delete cascade,
423 + sensor_id text references sensors(id) on delete set null,
424 + name text not null,
425 + name_norm text not null,
426 + title text,
427 + role_category text, -- ceo | cfo | cto | coo | founder | president | chair | board | vp | head | other
428 + is_executive boolean not null default false,
429 + first_seen_at timestamptz not null default now(),
430 + last_seen_at timestamptz not null default now(),
431 + removed_at timestamptz,
432 + status text not null default 'listed', -- listed | no_longer_listed
433 + source_url text,
434 + unique (company_id, name_norm)
435 +);
436 +create index if not exists people_company_idx on people (company_id, status);
437 +
438 +create table if not exists products (
439 + id text primary key,
440 + company_id text not null references companies(id) on delete cascade,
441 + sensor_id text references sensors(id) on delete set null,
442 + name text not null,
443 + name_norm text not null,
444 + category text,
445 + description text,
446 + url text,
447 + first_seen_at timestamptz not null default now(),
448 + last_seen_at timestamptz not null default now(),
449 + removed_at timestamptz,
450 + status text not null default 'listed',
451 + unique (company_id, name_norm)
452 +);
453 +create index if not exists products_company_idx on products (company_id, status);
454 +
455 +create table if not exists pricing_plans (
456 + id text primary key,
457 + company_id text not null references companies(id) on delete cascade,
458 + sensor_id text references sensors(id) on delete set null,
459 + plan_name text not null,
460 + plan_norm text not null,
461 + currency text,
462 + billing_period text, -- month | year | one_time | usage | contact
463 + price numeric,
464 + price_text text,
465 + unit text,
466 + features jsonb not null default '[]'::jsonb,
467 + contact_sales boolean not null default false,
468 + version_no int not null default 1,
469 + valid_from timestamptz not null default now(),
470 + valid_to timestamptz,
471 + first_seen_at timestamptz not null default now(),
472 + last_seen_at timestamptz not null default now(),
473 + status text not null default 'current', -- current | superseded | removed
474 + source_url text
475 +);
476 +create index if not exists pricing_plans_company_idx on pricing_plans (company_id, status);
477 +create index if not exists pricing_plans_current_idx on pricing_plans (company_id, plan_norm) where status = 'current';
478 +
479 +create table if not exists locations (
480 + id text primary key,
481 + company_id text not null references companies(id) on delete cascade,
482 + sensor_id text references sensors(id) on delete set null,
483 + kind text not null default 'office', -- headquarters | office | store | factory | warehouse | lab | data_center | other
484 + name text,
485 + name_norm text not null,
486 + city text,
487 + region text,
488 + country char(2),
489 + lat double precision,
490 + lon double precision,
491 + first_seen_at timestamptz not null default now(),
492 + last_seen_at timestamptz not null default now(),
493 + removed_at timestamptz,
494 + status text not null default 'listed',
495 + source_url text,
496 + unique (company_id, name_norm)
497 +);
498 +create index if not exists locations_company_idx on locations (company_id, status);
499 +create index if not exists locations_country_idx on locations (country) where status = 'listed';
500 +
501 +create table if not exists news_items (
502 + id text primary key,
503 + company_id text not null references companies(id) on delete cascade,
504 + sensor_id text references sensors(id) on delete set null,
505 + url text not null,
506 + canonical_url text not null,
507 + title text not null,
508 + summary text,
509 + category text, -- press | blog | changelog | research | ir | other
510 + published_at timestamptz,
511 + first_seen_at timestamptz not null default now(),
512 + language text,
513 + entities jsonb not null default '{}'::jsonb,
514 + unique (company_id, canonical_url)
515 +);
516 +create index if not exists news_items_company_idx on news_items (company_id, coalesce(published_at, first_seen_at) desc);
517 +create index if not exists news_items_time_idx on news_items (first_seen_at desc);
518 +
519 +-- ============================================================================================================ metrics
520 +create table if not exists metrics_current (
521 + company_id text not null references companies(id) on delete cascade,
522 + metric text not null,
523 + value double precision not null,
524 + confidence real not null default 0.5,
525 + inputs jsonb not null default '{}'::jsonb,
526 + formula_version text not null,
527 + computed_at timestamptz not null default now(),
528 + primary key (company_id, metric)
529 +);
530 +create index if not exists metrics_current_metric_idx on metrics_current (metric, value desc);
531 +
532 +create table if not exists metric_series (
533 + company_id text not null references companies(id) on delete cascade,
534 + metric text not null,
535 + day date not null,
536 + value double precision not null,
537 + confidence real not null default 0.5,
538 + formula_version text not null,
539 + primary key (company_id, metric, day)
540 +);
541 +create index if not exists metric_series_metric_day_idx on metric_series (metric, day desc);
542 +
543 +create table if not exists company_daily (
544 + company_id text not null references companies(id) on delete cascade,
545 + day date not null,
546 + observations int not null default 0,
547 + changes int not null default 0,
548 + meaningful_changes int not null default 0,
549 + events int not null default 0,
550 + events_by_type jsonb not null default '{}'::jsonb,
551 + jobs_open int,
552 + jobs_new int not null default 0,
553 + jobs_removed int not null default 0,
554 + jobs_ai_open int,
555 + news_items int not null default 0,
556 + sensors_active int,
557 + primary key (company_id, day)
558 +);
559 +create index if not exists company_daily_day_idx on company_daily (day desc);
560 +
561 +create table if not exists baselines (
562 + company_id text not null references companies(id) on delete cascade,
563 + metric text not null,
564 + mean double precision not null,
565 + stddev double precision not null,
566 + samples int not null,
567 + window_days int not null,
568 + computed_at timestamptz not null default now(),
569 + primary key (company_id, metric)
570 +);
571 +
572 +create table if not exists global_daily (
573 + day date primary key,
574 + companies_active int not null default 0,
575 + sensors_active int not null default 0,
576 + observations int not null default 0,
577 + changes int not null default 0,
578 + meaningful_changes int not null default 0,
579 + events int not null default 0,
580 + events_by_type jsonb not null default '{}'::jsonb,
581 + jobs_open int,
582 + jobs_new int not null default 0,
583 + jobs_removed int not null default 0,
584 + activity_index double precision, -- coverage-normalised Global Corporate Activity Index (100 = baseline)
585 + by_country jsonb not null default '{}'::jsonb,
586 + by_industry jsonb not null default '{}'::jsonb,
587 + computed_at timestamptz not null default now()
588 +);
589 +
590 +create table if not exists signals (
591 + id text primary key,
592 + company_id text references companies(id) on delete cascade,
593 + scope text not null default 'company', -- company | industry | country | global
594 + scope_key text,
595 + kind text not null, -- hiring_surge | hiring_freeze | launch_buildup | expansion | pricing_migration | developer_push | enterprise_repositioning | ai_acceleration | abnormal_activity
596 + strength real not null,
597 + confidence real not null,
598 + title text not null,
599 + explanation text,
600 + evidence jsonb not null default '{}'::jsonb,
601 + window_days int not null default 30,
602 + detected_at timestamptz not null default now(),
603 + expires_at timestamptz,
604 + status text not null default 'active'
605 +);
606 +create index if not exists signals_company_idx on signals (company_id, detected_at desc);
607 +create index if not exists signals_scope_idx on signals (scope, scope_key, detected_at desc);
608 +
609 +create table if not exists trends (
610 + term text not null,
611 + day date not null,
612 + mentions int not null default 0,
613 + companies int not null default 0,
614 + primary key (term, day)
615 +);
616 +
617 +-- ============================================================================================================ operations
618 +create table if not exists queue_jobs (
619 + id text primary key,
620 + kind text not null, -- discover | run_sensor | enrich_change | recompute_metrics | digest | repair_sensor
621 + key text not null unique, -- idempotency (sensor_id + window …)
622 + payload jsonb not null default '{}'::jsonb,
623 + priority real not null default 0.5,
624 + run_at timestamptz not null default now(),
625 + locked_at timestamptz,
626 + locked_by text,
627 + attempts int not null default 0,
628 + max_attempts int not null default 3,
629 + status text not null default 'pending', -- pending | running | done | failed | dead
630 + last_error text,
631 + created_at timestamptz not null default now(),
632 + finished_at timestamptz
633 +);
634 +create index if not exists queue_jobs_due_idx on queue_jobs (kind, priority desc, run_at) where status = 'pending';
635 +create index if not exists queue_jobs_status_idx on queue_jobs (status, kind);
636 +
637 +create table if not exists llm_jobs (
638 + id text primary key,
639 + kind text not null, -- classify_change | summarize_event | extract_entities | classify_industry
640 + ref_id text not null,
641 + company_id text references companies(id) on delete cascade,
642 + model text,
643 + prompt_version text,
644 + status text not null default 'pending',
645 + attempts int not null default 0,
646 + request_tokens int,
647 + response_tokens int,
648 + latency_ms int,
649 + result jsonb,
650 + error text,
651 + created_at timestamptz not null default now(),
652 + started_at timestamptz,
653 + finished_at timestamptz
654 +);
655 +create index if not exists llm_jobs_status_idx on llm_jobs (status, created_at);
656 +create index if not exists llm_jobs_ref_idx on llm_jobs (ref_id);
657 +
658 +create table if not exists failures (
659 + id text primary key,
660 + sensor_id text references sensors(id) on delete cascade,
661 + company_id text references companies(id) on delete cascade,
662 + at timestamptz not null default now(),
663 + failure_class text not null,
664 + status_code int,
665 + message text,
666 + url text
667 +);
668 +create index if not exists failures_time_idx on failures (at desc);
669 +create index if not exists failures_sensor_idx on failures (sensor_id, at desc);
670 +
671 +create table if not exists crawl_runs (
672 + id text primary key,
673 + kind text not null, -- scheduler_tick | onboarding | metrics | daily | backup | repair
674 + worker text,
675 + started_at timestamptz not null default now(),
676 + finished_at timestamptz,
677 + stats jsonb not null default '{}'::jsonb,
678 + error text
679 +);
680 +create index if not exists crawl_runs_kind_idx on crawl_runs (kind, started_at desc);
681 +
682 +create table if not exists review_queue (
683 + id text primary key,
684 + kind text not null, -- company_merge | major_event | low_confidence | sensor_migration | legal_sensitive | unexpected_activity | blocked_source
685 + ref_id text,
686 + company_id text references companies(id) on delete cascade,
687 + payload jsonb not null default '{}'::jsonb,
688 + status text not null default 'open', -- open | accepted | rejected | resolved
689 + resolution text,
690 + created_at timestamptz not null default now(),
691 + resolved_at timestamptz
692 +);
693 +create index if not exists review_queue_open_idx on review_queue (kind, created_at) where status = 'open';
694 +
695 +create table if not exists cost_ledger (
696 + day date not null,
697 + dimension text not null, -- fetch | browser | llm | storage_gb | event
698 + key text not null default '',
699 + units double precision not null default 0,
700 + cost_estimate double precision not null default 0,
701 + primary key (day, dimension, key)
702 +);
703 +
704 +-- ============================================================================================================ users-light (no accounts required)
705 +create table if not exists owners (
706 + token_hash text primary key,
707 + created_at timestamptz not null default now(),
708 + last_seen_at timestamptz not null default now(),
709 + email text,
710 + plan text not null default 'free'
711 +);
712 +
713 +create table if not exists watchlists (
714 + id text primary key,
715 + owner_hash text not null references owners(token_hash) on delete cascade,
716 + name text not null default 'My Watchlist',
717 + created_at timestamptz not null default now()
718 +);
719 +create table if not exists watchlist_items (
720 + watchlist_id text not null references watchlists(id) on delete cascade,
721 + company_id text not null references companies(id) on delete cascade,
722 + added_at timestamptz not null default now(),
723 + primary key (watchlist_id, company_id)
724 +);
725 +
726 +create table if not exists alerts (
727 + id text primary key,
728 + owner_hash text not null references owners(token_hash) on delete cascade,
729 + company_id text references companies(id) on delete cascade,
730 + name text not null,
731 + condition jsonb not null, -- {event_types:[…], min_importance, metrics:{activity_score:{gt:80}}, industries, countries}
732 + channel text not null default 'web', -- web | email | webhook
733 + target text,
734 + enabled boolean not null default true,
735 + created_at timestamptz not null default now(),
736 + last_fired_at timestamptz
737 +);
738 +create table if not exists alert_deliveries (
739 + id text primary key,
740 + alert_id text not null references alerts(id) on delete cascade,
741 + event_id text references events(id) on delete cascade,
742 + delivered_at timestamptz not null default now(),
743 + channel text not null,
744 + status text not null default 'queued',
745 + detail text
746 +);
747 +create index if not exists alert_deliveries_alert_idx on alert_deliveries (alert_id, delivered_at desc);
748 +
749 +create table if not exists api_keys (
750 + id text primary key,
751 + key_hash text not null unique,
752 + prefix text not null,
753 + name text not null,
754 + tier text not null default 'authenticated', -- anonymous | authenticated | paid | internal
755 + owner_hash text references owners(token_hash) on delete set null,
756 + created_at timestamptz not null default now(),
757 + last_used_at timestamptz,
758 + request_count bigint not null default 0,
759 + revoked_at timestamptz
760 +);
761 +
762 +create table if not exists settings_kv (
763 + key text primary key,
764 + value jsonb not null,
765 + updated_at timestamptz not null default now()
766 +);
767 +insert into settings_kv (key, value) values ('dataset_started_at', to_jsonb(now())) on conflict (key) do nothing;
768 +"""
769 +
770 +
771 +def upgrade() -> None:
772 + for stmt in _split(SQL):
773 + op.execute(stmt)
774 +
775 +
776 +def downgrade() -> None:
777 + raise RuntimeError("forward-only migrations: historical data is never dropped")
778 +
779 +
780 +def _split(sql: str) -> list[str]:
781 + out: list[str] = []
782 + buf: list[str] = []
783 + for line in sql.splitlines():
784 + buf.append(line)
785 + if line.rstrip().endswith(";"):
786 + stmt = "\n".join(buf).strip()
787 + body = "\n".join(x for x in stmt.splitlines() if not x.strip().startswith("--")).strip()
788 + if body and body != ";":
789 + out.append(stmt)
790 + buf = []
791 + return out
added package.json +14 −0
@@ -0,0 +1,14 @@
1 +{
2 + "name": "company-atlas",
3 + "version": "0.1.0",
4 + "private": true,
5 + "description": "Company Atlas — the live atlas of global companies. Web workspace (backend is Python, see pyproject.toml).",
6 + "packageManager": "pnpm@11.1.2",
7 + "engines": { "node": ">=22" },
8 + "scripts": {
9 + "dev:web": "pnpm --filter @company-atlas/web run dev",
10 + "build": "pnpm --filter @company-atlas/web run build",
11 + "start:web": "pnpm --filter @company-atlas/web run start",
12 + "typecheck": "pnpm -r run typecheck"
13 + }
14 +}
added pnpm-workspace.yaml +2 −0
@@ -0,0 +1,2 @@
1 +packages:
2 + - apps/*
added pyproject.toml +53 −0
@@ -0,0 +1,53 @@
1 +[project]
2 +name = "companyatlas"
3 +version = "0.1.0"
4 +description = "Company Atlas — the live atlas of global companies: a distributed public-web sensor network that turns corporate change into a continuously updated historical record"
5 +requires-python = ">=3.12"
6 +dependencies = [
7 + "fastapi>=0.115",
8 + "uvicorn[standard]>=0.30",
9 + "pydantic>=2.8",
10 + "pydantic-settings>=2.4",
11 + "sqlalchemy[asyncio]>=2.0.35",
12 + "asyncpg>=0.30",
13 + "alembic>=1.13",
14 + "httpx[http2]>=0.27",
15 + "orjson>=3.10",
16 + "typer>=0.12",
17 + "rich>=13",
18 + "apscheduler>=3.10,<4",
19 + "python-ulid>=2.7",
20 + "pyyaml>=6",
21 + "python-dateutil>=2.9",
22 + "tenacity>=9",
23 + "python-slugify>=8",
24 + "selectolax>=0.3.21",
25 + "feedparser>=6.0.11",
26 + "rapidfuzz>=3.9",
27 + "zstandard>=0.23",
28 + "tldextract>=5.1",
29 + "sse-starlette>=2.1",
30 +]
31 +
32 +[project.optional-dependencies]
33 +dev = ["pytest>=8", "pytest-asyncio>=0.24", "ruff>=0.6", "respx>=0.21"]
34 +browser = ["playwright>=1.47"]
35 +
36 +[project.scripts]
37 +catlas = "companyatlas.cli:app"
38 +
39 +[build-system]
40 +requires = ["hatchling"]
41 +build-backend = "hatchling.build"
42 +
43 +[tool.hatch.build.targets.wheel]
44 +packages = ["src/companyatlas"]
45 +
46 +[tool.ruff]
47 +line-length = 130
48 +target-version = "py312"
49 +
50 +[tool.pytest.ini_options]
51 +testpaths = ["tests"]
52 +asyncio_mode = "auto"
53 +markers = ["live: hits real external endpoints (skipped unless -m live)"]
added src/companyatlas/__init__.py +2 −0
@@ -0,0 +1,2 @@
1 +"""Company Atlas — a distributed global sensor network for companies."""
2 +__version__ = "0.1.0"
added src/companyatlas/api/common.py +118 −0
@@ -0,0 +1,118 @@
1 +"""Shared API helpers: JSON response class (orjson), pagination, admin/owner auth dependencies, tiny in-process TTL cache."""
2 +from __future__ import annotations
3 +
4 +import hashlib
5 +import hmac
6 +import time
7 +from collections import OrderedDict
8 +from typing import Any
9 +
10 +import orjson
11 +from fastapi import Header, HTTPException, Query, Request
12 +from fastapi.responses import JSONResponse
13 +
14 +from companyatlas.config import settings
15 +
16 +
17 +class AtlasJSONResponse(JSONResponse):
18 + media_type = "application/json"
19 +
20 + def render(self, content: Any) -> bytes:
21 + return orjson.dumps(content, option=orjson.OPT_NON_STR_KEYS | orjson.OPT_UTC_Z | orjson.OPT_SERIALIZE_NUMPY, default=_default)
22 +
23 +
24 +def _default(obj: Any) -> Any:
25 + if hasattr(obj, "isoformat"):
26 + return obj.isoformat()
27 + if hasattr(obj, "__float__"):
28 + return float(obj)
29 + if isinstance(obj, set | frozenset):
30 + return sorted(obj)
31 + raise TypeError(f"not serialisable: {type(obj)!r}")
32 +
33 +
34 +# ------------------------------------------------------------------------------------------------ pagination
35 +
36 +
37 +class PageParams:
38 + def __init__(self, page: int = Query(1, ge=1), per_page: int = Query(25, ge=1, le=200)):
39 + self.page = page
40 + self.per_page = per_page
41 +
42 + @property
43 + def offset(self) -> int:
44 + return (self.page - 1) * self.per_page
45 +
46 +
47 +def page_payload(items: list[Any], total: int, p: PageParams) -> dict[str, Any]:
48 + return {"items": items, "page": p.page, "per_page": p.per_page, "total": total, "pages": max(1, -(-total // p.per_page))}
49 +
50 +
51 +# ------------------------------------------------------------------------------------------------ auth
52 +
53 +
54 +def require_admin(x_ca_admin_token: str | None = Header(None, alias="X-CA-Admin-Token")) -> None:
55 + if not settings.admin_token or not x_ca_admin_token or not hmac.compare_digest(x_ca_admin_token, settings.admin_token):
56 + raise HTTPException(status_code=401, detail="admin token required")
57 +
58 +
59 +def owner_hash(x_ca_owner_token: str | None = Header(None, alias="X-CA-Owner-Token")) -> str:
60 + """Anonymous owner identity for watchlists/alerts: a client-generated random token (≥ 24 chars), stored hashed."""
61 + if not x_ca_owner_token or len(x_ca_owner_token) < 24 or len(x_ca_owner_token) > 200:
62 + raise HTTPException(status_code=401, detail="owner token required (X-CA-Owner-Token, ≥ 24 characters)")
63 + return hashlib.sha256(x_ca_owner_token.encode()).hexdigest()
64 +
65 +
66 +def client_ip(request: Request) -> str:
67 + fwd = request.headers.get("x-forwarded-for")
68 + if fwd:
69 + return fwd.split(",")[0].strip()
70 + return request.client.host if request.client else "0.0.0.0"
71 +
72 +
73 +# ------------------------------------------------------------------------------------------------ cache (per process)
74 +
75 +
76 +class TTLCache:
77 + def __init__(self, max_items: int = 4096):
78 + self._data: OrderedDict[str, tuple[float, Any]] = OrderedDict()
79 + self._max = max_items
80 +
81 + def get(self, key: str) -> Any | None:
82 + item = self._data.get(key)
83 + if item is None:
84 + return None
85 + exp, value = item
86 + if exp < time.monotonic():
87 + self._data.pop(key, None)
88 + return None
89 + self._data.move_to_end(key)
90 + return value
91 +
92 + def set(self, key: str, value: Any, ttl_s: float) -> None:
93 + self._data[key] = (time.monotonic() + ttl_s, value)
94 + self._data.move_to_end(key)
95 + while len(self._data) > self._max:
96 + self._data.popitem(last=False)
97 +
98 + def clear(self, prefix: str | None = None) -> None:
99 + if prefix is None:
100 + self._data.clear()
101 + else:
102 + for k in [k for k in self._data if k.startswith(prefix)]:
103 + self._data.pop(k, None)
104 +
105 +
106 +cache = TTLCache()
107 +
108 +
109 +async def cached(key: str, ttl_s: float, producer): # type: ignore[no-untyped-def]
110 + hit = cache.get(key)
111 + if hit is not None:
112 + return hit
113 + value = await producer()
114 + cache.set(key, value, ttl_s)
115 + return value
116 +
117 +
118 +__all__ = ["AtlasJSONResponse", "PageParams", "TTLCache", "cache", "cached", "client_ip", "owner_hash", "page_payload", "require_admin"]
added src/companyatlas/api/main.py +131 −0
@@ -0,0 +1,131 @@
1 +"""FastAPI application — `/api/v1`. Loopback-only in production (Next.js proxies `/api/v1/*`); docs at `/api/v1/docs`.
2 +
3 +Routers live in `companyatlas.api.routers.<name>` and expose `router`; they are auto-included in alphabetical order, except that
4 +modules listing `ORDER = n` are sorted by that first (literal paths must be registered before `/{slug}` catch-alls).
5 +"""
6 +from __future__ import annotations
7 +
8 +import asyncio
9 +import importlib
10 +import logging
11 +import pkgutil
12 +import time
13 +from contextlib import asynccontextmanager
14 +from datetime import UTC, datetime
15 +from typing import Any
16 +
17 +from fastapi import FastAPI, Request
18 +from fastapi.exceptions import RequestValidationError
19 +from fastapi.middleware.cors import CORSMiddleware
20 +from fastapi.middleware.gzip import GZipMiddleware
21 +from starlette.exceptions import HTTPException as StarletteHTTPException
22 +
23 +import companyatlas
24 +from companyatlas.api.common import AtlasJSONResponse
25 +from companyatlas.config import settings
26 +from companyatlas.db import connection, dispose, fetch_val
27 +from companyatlas.logging import setup_logging
28 +
29 +log = logging.getLogger("companyatlas.api")
30 +API_VERSION = "1.0"
31 +_STARTED = time.time()
32 +
33 +
34 +@asynccontextmanager
35 +async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
36 + setup_logging(service="ca-api")
37 + settings.ensure_dirs()
38 + log.info("api started", extra={"version": companyatlas.__version__, "api": API_VERSION, "env": settings.app_env, "port": settings.api_port})
39 + yield
40 + await dispose()
41 +
42 +
43 +app = FastAPI(title="Company Atlas API", version=companyatlas.__version__, lifespan=lifespan, default_response_class=AtlasJSONResponse,
44 + docs_url="/api/v1/docs", redoc_url=None, openapi_url="/api/v1/openapi.json",
45 + description="The live atlas of global companies: companies, sensors, observations, changes, structured events, metrics, rankings, "
46 + "industries and countries — with provenance and history on every fact. Contract: docs/API.md.")
47 +
48 +app.add_middleware(GZipMiddleware, minimum_size=1024)
49 +_origins = {settings.site_url, "https://www.company-atlas.co", "https://company-atlas.co", "https://www.company-atlas.com", "https://company-atlas.com",
50 + "http://localhost:8370", "http://127.0.0.1:8370", "http://localhost:8360", "http://127.0.0.1:8360"}
51 +app.add_middleware(CORSMiddleware, allow_origins=sorted(o for o in _origins if o), allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
52 + allow_headers=["*"], expose_headers=["etag", "cache-control", "x-api-version", "x-ratelimit-remaining"], max_age=600)
53 +
54 +
55 +@app.middleware("http")
56 +async def security_headers(request: Request, call_next): # type: ignore[no-untyped-def]
57 + try:
58 + response = await call_next(request)
59 + except Exception:
60 + log.exception("unhandled error", extra={"route": request.url.path})
61 + return AtlasJSONResponse({"detail": "internal server error"}, status_code=500)
62 + response.headers["x-content-type-options"] = "nosniff"
63 + response.headers["referrer-policy"] = "strict-origin-when-cross-origin"
64 + response.headers["x-api-version"] = API_VERSION
65 + return response
66 +
67 +
68 +@app.exception_handler(StarletteHTTPException)
69 +async def http_error(request: Request, exc: StarletteHTTPException): # type: ignore[no-untyped-def]
70 + detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
71 + return AtlasJSONResponse({"detail": detail}, status_code=exc.status_code, headers=dict(exc.headers or {}))
72 +
73 +
74 +@app.exception_handler(RequestValidationError)
75 +async def validation_error(request: Request, exc: RequestValidationError): # type: ignore[no-untyped-def]
76 + errs = exc.errors()[:5]
77 + msg = "; ".join(f"{'.'.join(str(x) for x in e.get('loc', []) if x not in ('query', 'body', 'path'))}: {e.get('msg')}" for e in errs) or "invalid request"
78 + return AtlasJSONResponse({"detail": msg, "errors": [{"loc": e.get("loc"), "msg": e.get("msg"), "type": e.get("type")} for e in errs]}, status_code=422)
79 +
80 +
81 +@app.exception_handler(Exception)
82 +async def unhandled(request: Request, exc: Exception): # type: ignore[no-untyped-def]
83 + log.exception("unhandled error", extra={"route": request.url.path})
84 + return AtlasJSONResponse({"detail": "internal server error"}, status_code=500)
85 +
86 +
87 +async def _health() -> dict[str, Any]:
88 + db_ok = False
89 + try:
90 + async with connection() as conn:
91 + db_ok = (await asyncio.wait_for(fetch_val(conn, "select 1"), timeout=3)) == 1
92 + except Exception: # noqa: BLE001
93 + db_ok = False
94 + return {"status": "ok" if db_ok else "degraded", "version": companyatlas.__version__, "api_version": API_VERSION, "db": db_ok,
95 + "llm": {"configured": settings.llm_configured}, "uptime_s": int(time.time() - _STARTED), "time": datetime.now(UTC)}
96 +
97 +
98 +@app.get("/health", tags=["health"])
99 +async def health_root() -> dict[str, Any]:
100 + return await _health()
101 +
102 +
103 +@app.get("/api/v1/health", tags=["health"])
104 +async def health_v1() -> dict[str, Any]:
105 + return await _health()
106 +
107 +
108 +@app.get("/ready", tags=["health"])
109 +async def ready() -> dict[str, Any]:
110 + h = await _health()
111 + return {"ready": h["db"]}
112 +
113 +
114 +def _include_routers() -> None:
115 + import companyatlas.api.routers as pkg
116 +
117 + mods = []
118 + for m in pkgutil.iter_modules(pkg.__path__):
119 + if m.name.startswith("_"):
120 + continue
121 + module = importlib.import_module(f"companyatlas.api.routers.{m.name}")
122 + router = getattr(module, "router", None)
123 + if router is not None:
124 + mods.append((getattr(module, "ORDER", 50), m.name, router))
125 + for _order, _name, router in sorted(mods, key=lambda x: (x[0], x[1])):
126 + app.include_router(router)
127 +
128 +
129 +_include_routers()
130 +
131 +__all__ = ["API_VERSION", "app"]
added src/companyatlas/api/routers/__init__.py +1 −0
@@ -0,0 +1 @@
1 +"""Package."""
added src/companyatlas/archive.py +77 −0
@@ -0,0 +1,77 @@
1 +"""Content-addressable object store for raw observations, normalized text and block structures (spec §26, §96, §97).
2 +
3 + objects/<sha256[0:2]>/<sha256[2:4]>/<sha256>.zst
4 +
5 +Objects are written once (dedupe by hash) and never mutated; many observations may point at the same key. zstd level 9 gives
6 +~8× on HTML. `key` = sha256 hex of the *uncompressed* bytes; the relative path is derived, so the store can move as a whole.
7 +"""
8 +from __future__ import annotations
9 +
10 +import hashlib
11 +import os
12 +from pathlib import Path
13 +
14 +import zstandard as zstd
15 +
16 +from companyatlas.config import settings
17 +
18 +_cctx = zstd.ZstdCompressor(level=9)
19 +_dctx = zstd.ZstdDecompressor()
20 +
21 +
22 +def object_path(key: str, root: Path | None = None) -> Path:
23 + root = root or settings.objects_dir
24 + return root / key[:2] / key[2:4] / f"{key}.zst"
25 +
26 +
27 +def sha256_hex(data: bytes) -> str:
28 + return hashlib.sha256(data).hexdigest()
29 +
30 +
31 +def put_bytes(data: bytes, *, key: str | None = None) -> tuple[str, int, bool]:
32 + """Store bytes; return (key, stored_size_bytes, created). Idempotent."""
33 + key = key or sha256_hex(data)
34 + path = object_path(key)
35 + if path.exists():
36 + return key, path.stat().st_size, False
37 + path.parent.mkdir(parents=True, exist_ok=True)
38 + tmp = path.with_suffix(f".zst.tmp{os.getpid()}")
39 + with open(tmp, "wb") as fh:
40 + fh.write(_cctx.compress(data))
41 + tmp.replace(path)
42 + return key, path.stat().st_size, True
43 +
44 +
45 +def put_text(text: str) -> tuple[str, int, bool]:
46 + return put_bytes(text.encode("utf-8"))
47 +
48 +
49 +def get_bytes(key: str) -> bytes:
50 + with open(object_path(key), "rb") as fh:
51 + return _dctx.decompress(fh.read(), max_output_size=64 * 1024 * 1024)
52 +
53 +
54 +def get_text(key: str) -> str:
55 + return get_bytes(key).decode("utf-8", errors="replace")
56 +
57 +
58 +def exists(key: str) -> bool:
59 + return object_path(key).exists()
60 +
61 +
62 +def store_stats(root: Path | None = None) -> dict[str, int]:
63 + root = root or settings.objects_dir
64 + total = count = 0
65 + if root.exists():
66 + for dirpath, _dirs, files in os.walk(root):
67 + for f in files:
68 + if f.endswith(".zst"):
69 + try:
70 + total += os.stat(os.path.join(dirpath, f)).st_size
71 + count += 1
72 + except OSError:
73 + pass
74 + return {"objects": count, "bytes": total}
75 +
76 +
77 +__all__ = ["exists", "get_bytes", "get_text", "object_path", "put_bytes", "put_text", "sha256_hex", "store_stats"]
added src/companyatlas/cli.py +90 −0
@@ -0,0 +1,90 @@
1 +"""`catlas` — Company Atlas operations CLI.
2 +
3 +Command groups live in `companyatlas.commands.<module>`; each module exposes `register(app: typer.Typer) -> None` and is
4 +auto-discovered here, so crawl, intelligence, API and seed commands can evolve independently.
5 +"""
6 +from __future__ import annotations
7 +
8 +import asyncio
9 +import importlib
10 +import logging
11 +import pkgutil
12 +from pathlib import Path
13 +from typing import Annotated
14 +
15 +import typer
16 +from rich.console import Console
17 +
18 +from companyatlas.config import settings
19 +from companyatlas.logging import setup_logging
20 +
21 +app = typer.Typer(name="catlas", help="Company Atlas — the live atlas of global companies.", no_args_is_help=True, add_completion=False)
22 +console = Console(stderr=True)
23 +out = Console()
24 +
25 +
26 +@app.callback()
27 +def _main(verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False) -> None:
28 + setup_logging(level=logging.DEBUG if verbose else logging.INFO, service="catlas")
29 + settings.ensure_dirs()
30 +
31 +
32 +def run_async(coro): # type: ignore[no-untyped-def]
33 + """Run a coroutine and dispose the shared DB engine afterwards (for CLI commands)."""
34 + from companyatlas.db import dispose
35 +
36 + async def wrapper(): # type: ignore[no-untyped-def]
37 + try:
38 + return await coro
39 + finally:
40 + await dispose()
41 +
42 + return asyncio.run(wrapper())
43 +
44 +
45 +@app.command()
46 +def migrate(revision: str = "head") -> None:
47 + """Apply database migrations (forward-only)."""
48 + from alembic import command
49 + from alembic.config import Config
50 +
51 + root = Path(__file__).resolve().parents[2]
52 + cfg = Config(str(root / "alembic.ini"))
53 + cfg.set_main_option("script_location", str(root / "migrations"))
54 + command.upgrade(cfg, revision)
55 + out.print("[green]migrations applied[/]")
56 +
57 +
58 +@app.command()
59 +def api(host: str | None = None, port: int | None = None, workers: int = 1, reload: bool = False) -> None:
60 + """Serve the FastAPI application (development)."""
61 + import uvicorn
62 +
63 + uvicorn.run("companyatlas.api.main:app", host=host or settings.api_host, port=port or settings.api_port, workers=workers, reload=reload,
64 + proxy_headers=True, access_log=False)
65 +
66 +
67 +@app.command()
68 +def version() -> None:
69 + import companyatlas
70 +
71 + out.print(companyatlas.__version__)
72 +
73 +
74 +def _discover_commands() -> None:
75 + try:
76 + import companyatlas.commands as pkg
77 + except ImportError:
78 + return
79 + for mod in pkgutil.iter_modules(pkg.__path__):
80 + if mod.name.startswith("_"):
81 + continue
82 + module = importlib.import_module(f"companyatlas.commands.{mod.name}")
83 + register = getattr(module, "register", None)
84 + if callable(register):
85 + register(app)
86 +
87 +
88 +_discover_commands()
89 +
90 +__all__ = ["app", "console", "out", "run_async"]
added src/companyatlas/commands/__init__.py +1 −0
@@ -0,0 +1 @@
1 +"""Package."""
added src/companyatlas/config.py +130 −0
@@ -0,0 +1,130 @@
1 +"""Runtime settings (environment variables, `CA_` prefix). Never log `settings.model_dump()` — it contains secrets.
2 +
3 +Every tunable that the product spec calls a "magic number" (crawl intervals, thresholds, weights, retry counts) lives here or in
4 +`companyatlas.taxonomy` as typed configuration — never scattered as literals through the pipeline.
5 +"""
6 +from __future__ import annotations
7 +
8 +from functools import lru_cache
9 +from pathlib import Path
10 +
11 +from pydantic import Field
12 +from pydantic_settings import BaseSettings, SettingsConfigDict
13 +
14 +
15 +class Settings(BaseSettings):
16 + model_config = SettingsConfigDict(env_file=(".env",), env_file_encoding="utf-8", extra="ignore")
17 +
18 + app_env: str = Field("development", alias="APP_ENV")
19 + site_url: str = Field("https://www.company-atlas.co", alias="CA_SITE_URL")
20 + canonical_hosts: str = Field("www.company-atlas.co,www.company-atlas.com", alias="CA_CANONICAL_HOSTS")
21 +
22 + database_url: str = Field("postgresql+asyncpg://companyatlas:companyatlas@127.0.0.1:5432/companyatlas", alias="DATABASE_URL")
23 + db_pool_size: int = Field(8, alias="CA_DB_POOL_SIZE")
24 + db_max_overflow: int = Field(8, alias="CA_DB_MAX_OVERFLOW")
25 +
26 + data_dir: Path = Field(Path("./data"), alias="CA_DATA_DIR")
27 + api_host: str = Field("127.0.0.1", alias="CA_API_HOST")
28 + api_port: int = Field(8371, alias="CA_API_PORT")
29 + admin_token: str = Field("", alias="CA_ADMIN_TOKEN")
30 + log_json: bool = Field(True, alias="CA_LOG_JSON")
31 + tz: str = Field("America/Toronto", alias="CA_TZ")
32 +
33 + # ---------------------------------------------------------------- crawler / politeness
34 + user_agent: str = Field("CompanyAtlasBot/0.1 (+https://www.company-atlas.co/bot; contact@spboucher.ai)", alias="CA_USER_AGENT")
35 + http_timeout_s: float = Field(30.0, alias="CA_HTTP_TIMEOUT")
36 + max_body_bytes: int = Field(8 * 1024 * 1024, alias="CA_MAX_BODY_BYTES")
37 + default_rate_per_min: int = Field(20, alias="CA_DEFAULT_RATE_PER_MIN")
38 + domain_max_concurrency: int = Field(2, alias="CA_DOMAIN_MAX_CONCURRENCY")
39 + domain_daily_budget: int = Field(600, alias="CA_DOMAIN_DAILY_BUDGET")
40 + respect_robots: bool = Field(True, alias="CA_RESPECT_ROBOTS")
41 + fetch_concurrency: int = Field(16, alias="CA_FETCH_CONCURRENCY")
42 + browser_enabled: bool = Field(False, alias="CA_BROWSER_ENABLED")
43 + browser_concurrency: int = Field(2, alias="CA_BROWSER_CONCURRENCY")
44 + max_redirects: int = Field(5, alias="CA_MAX_REDIRECTS")
45 + fetch_retries: int = Field(2, alias="CA_FETCH_RETRIES")
46 +
47 + # ---------------------------------------------------------------- discovery
48 + discovery_max_pages: int = Field(12, alias="CA_DISCOVERY_MAX_PAGES")
49 + discovery_max_sitemap_urls: int = Field(5000, alias="CA_DISCOVERY_MAX_SITEMAP_URLS")
50 + discovery_min_confidence: float = Field(0.55, alias="CA_DISCOVERY_MIN_CONFIDENCE")
51 + discovery_max_sensors_per_company: int = Field(40, alias="CA_DISCOVERY_MAX_SENSORS")
52 + onboarding_concurrency: int = Field(12, alias="CA_ONBOARDING_CONCURRENCY")
53 +
54 + # ---------------------------------------------------------------- scheduling (seconds)
55 + scheduler_tick_s: int = Field(15, alias="CA_SCHEDULER_TICK_S")
56 + scheduler_claim_batch: int = Field(200, alias="CA_SCHEDULER_CLAIM_BATCH")
57 + min_interval_s: int = Field(15 * 60, alias="CA_MIN_INTERVAL_S")
58 + max_interval_s: int = Field(7 * 86400, alias="CA_MAX_INTERVAL_S")
59 + burst_interval_s: int = Field(15 * 60, alias="CA_BURST_INTERVAL_S")
60 + burst_decay: float = Field(1.6, alias="CA_BURST_DECAY")
61 + stability_growth: float = Field(1.25, alias="CA_STABILITY_GROWTH")
62 + failure_growth: float = Field(2.0, alias="CA_FAILURE_GROWTH")
63 + stale_after_failures: int = Field(6, alias="CA_STALE_AFTER_FAILURES")
64 + retire_after_failures: int = Field(30, alias="CA_RETIRE_AFTER_FAILURES")
65 +
66 + # ---------------------------------------------------------------- change detection
67 + noise_threshold: float = Field(0.20, alias="CA_NOISE_THRESHOLD")
68 + meaningful_threshold: float = Field(0.40, alias="CA_MEANINGFUL_THRESHOLD")
69 + major_threshold: float = Field(0.65, alias="CA_MAJOR_THRESHOLD")
70 + critical_threshold: float = Field(0.85, alias="CA_CRITICAL_THRESHOLD")
71 + keep_noise_snapshots: bool = Field(False, alias="CA_KEEP_NOISE_SNAPSHOTS")
72 +
73 + # ---------------------------------------------------------------- LLM enrichment (optional)
74 + llm_base_url: str = Field("", alias="CA_LLM_BASE_URL")
75 + llm_api_key: str = Field("", alias="CA_LLM_API_KEY")
76 + llm_small_model: str = Field("qwen3-4b-instruct-2507-4bit", alias="CA_LLM_SMALL_MODEL")
77 + llm_medium_model: str = Field("qwen3.6-35b-a3b-4bit", alias="CA_LLM_MEDIUM_MODEL")
78 + llm_large_model: str = Field("qwen3.8-27b-4bit", alias="CA_LLM_LARGE_MODEL")
79 + llm_timeout_s: float = Field(300.0, alias="CA_LLM_TIMEOUT")
80 + llm_enabled: bool = Field(True, alias="CA_LLM_ENABLED")
81 + llm_daily_budget: int = Field(1500, alias="CA_LLM_DAILY_BUDGET")
82 + llm_min_significance: float = Field(0.40, alias="CA_LLM_MIN_SIGNIFICANCE")
83 + worker_concurrency: int = Field(1, alias="CA_WORKER_CONCURRENCY")
84 +
85 + # ---------------------------------------------------------------- metrics / retention
86 + metrics_cron: str = Field("7 * * * *", alias="CA_METRICS_CRON")
87 + daily_cron: str = Field("20 0 * * *", alias="CA_DAILY_CRON")
88 + backup_cron: str = Field("35 4 * * *", alias="CA_BACKUP_CRON")
89 + baseline_window_days: int = Field(56, alias="CA_BASELINE_WINDOW_DAYS")
90 + anomaly_z: float = Field(2.5, alias="CA_ANOMALY_Z")
91 + seo_min_events: int = Field(1, alias="CA_SEO_MIN_EVENTS")
92 + seo_min_sensors: int = Field(3, alias="CA_SEO_MIN_SENSORS")
93 +
94 + @property
95 + def objects_dir(self) -> Path:
96 + return self.data_dir / "objects"
97 +
98 + @property
99 + def logs_dir(self) -> Path:
100 + return self.data_dir / "logs"
101 +
102 + @property
103 + def backups_dir(self) -> Path:
104 + return self.data_dir / "backups"
105 +
106 + @property
107 + def cache_dir(self) -> Path:
108 + return self.data_dir / "cache"
109 +
110 + @property
111 + def sync_database_url(self) -> str:
112 + return self.database_url.replace("+asyncpg", "")
113 +
114 + @property
115 + def llm_configured(self) -> bool:
116 + return bool(self.llm_enabled and self.llm_base_url)
117 +
118 + def ensure_dirs(self) -> None:
119 + for d in (self.objects_dir, self.logs_dir, self.backups_dir, self.cache_dir):
120 + d.mkdir(parents=True, exist_ok=True)
121 +
122 +
123 +@lru_cache
124 +def get_settings() -> Settings:
125 + return Settings()
126 +
127 +
128 +settings = get_settings()
129 +
130 +__all__ = ["Settings", "get_settings", "settings"]
added src/companyatlas/connectors/__init__.py +1 −0
@@ -0,0 +1 @@
1 +"""Package."""
added src/companyatlas/db/__init__.py +75 −0
@@ -0,0 +1,75 @@
1 +"""Database access: SQLAlchemy Core (async, asyncpg) with plain SQL. One engine per process."""
2 +from __future__ import annotations
3 +
4 +import json
5 +from collections.abc import AsyncIterator, Mapping, Sequence
6 +from contextlib import asynccontextmanager
7 +from typing import Any
8 +
9 +from sqlalchemy import text
10 +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
11 +
12 +from companyatlas.config import settings
13 +
14 +_engine: AsyncEngine | None = None
15 +
16 +
17 +def engine() -> AsyncEngine:
18 + global _engine
19 + if _engine is None:
20 + _engine = create_async_engine(settings.database_url, pool_size=8, max_overflow=8, pool_pre_ping=True, pool_recycle=1800,
21 + connect_args={"server_settings": {"application_name": "companyatlas", "jit": "off"}})
22 + return _engine
23 +
24 +
25 +async def dispose() -> None:
26 + global _engine
27 + if _engine is not None:
28 + await _engine.dispose()
29 + _engine = None
30 +
31 +
32 +@asynccontextmanager
33 +async def connection() -> AsyncIterator[AsyncConnection]:
34 + async with engine().connect() as conn:
35 + yield conn
36 +
37 +
38 +@asynccontextmanager
39 +async def transaction() -> AsyncIterator[AsyncConnection]:
40 + async with engine().begin() as conn:
41 + yield conn
42 +
43 +
44 +def jsonb(value: Any) -> str:
45 + """Serialise a Python value for a `cast(:x as jsonb)` parameter."""
46 + return json.dumps(value, default=str, ensure_ascii=False)
47 +
48 +
49 +async def execute(conn: AsyncConnection, sql: str, /, **params: Any) -> None:
50 + await conn.execute(text(sql), params)
51 +
52 +
53 +async def fetch_all(conn: AsyncConnection, sql: str, /, **params: Any) -> list[dict[str, Any]]:
54 + result = await conn.execute(text(sql), params)
55 + return [dict(r._mapping) for r in result]
56 +
57 +
58 +async def fetch_one(conn: AsyncConnection, sql: str, /, **params: Any) -> dict[str, Any] | None:
59 + result = await conn.execute(text(sql), params)
60 + row = result.first()
61 + return dict(row._mapping) if row is not None else None
62 +
63 +
64 +async def fetch_val(conn: AsyncConnection, sql: str, /, **params: Any) -> Any:
65 + result = await conn.execute(text(sql), params)
66 + row = result.first()
67 + return row[0] if row is not None else None
68 +
69 +
70 +async def execute_many(conn: AsyncConnection, sql: str, rows: Sequence[Mapping[str, Any]]) -> None:
71 + if rows:
72 + await conn.execute(text(sql), list(rows))
73 +
74 +
75 +__all__ = ["connection", "dispose", "engine", "execute", "execute_many", "fetch_all", "fetch_one", "fetch_val", "jsonb", "transaction"]
added src/companyatlas/fetch.py +472 −0
@@ -0,0 +1,472 @@
1 +"""Fetch transport (spec §13–14, §109–118): httpx direct mode with conditional requests, per-domain rate limiting and concurrency
2 +caps, robots.txt awareness, bounded redirects (each hop SSRF-checked), size limits, failure classification and an *optional*
3 +headless-browser mode that is only used when a sensor's connector asks for it.
4 +
5 +Crawler inputs are untrusted: every destination and every redirect hop is validated before a socket is opened.
6 +"""
7 +from __future__ import annotations
8 +
9 +import asyncio
10 +import hashlib
11 +import ipaddress
12 +import logging
13 +import socket
14 +import time
15 +from dataclasses import dataclass, field
16 +from datetime import UTC, datetime
17 +from urllib.parse import urljoin, urlparse
18 +from urllib.robotparser import RobotFileParser
19 +
20 +import httpx
21 +
22 +from companyatlas.config import settings
23 +from companyatlas.taxonomy import FailureClass
24 +from companyatlas.urls import registrable_domain
25 +
26 +log = logging.getLogger(__name__)
27 +
28 +TRANSIENT_STATUS = {408, 425, 429, 500, 502, 503, 504}
29 +BLOCK_STATUS = {401, 403, 451, 999}
30 +REDIRECT_STATUS = {301, 302, 303, 307, 308}
31 +
32 +# ---------------------------------------------------------------------------------------------- SSRF guard (spec §115–116)
33 +_BLOCKED_SUFFIXES = (".local", ".internal", ".localhost", ".localdomain", ".lan", ".home", ".corp", ".intranet", ".maclustr.io", ".ts.net")
34 +_BLOCKED_HOSTS = {"localhost", "metadata.google.internal", "metadata", "instance-data", "kubernetes.default", "kubernetes.default.svc"}
35 +_BLOCKED_NETWORKS = [ipaddress.ip_network(n) for n in (
36 + "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", "172.16.0.0/12", "192.0.0.0/24", "192.168.0.0/16",
37 + "198.18.0.0/15", "240.0.0.0/4", "::/128", "::1/128", "fc00::/7", "fe80::/10", "::ffff:0:0/96", "64:ff9b::/96",
38 +)]
39 +
40 +
41 +class BlockedDestination(ValueError):
42 + """The URL points at a private, local or otherwise non-public destination."""
43 +
44 +
45 +def _ip_blocked(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
46 + if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
47 + ip = ip.ipv4_mapped
48 + if isinstance(ip, ipaddress.IPv6Address) and ip in ipaddress.ip_network("64:ff9b::/96"):
49 + ip = ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF) # NAT64: judge the embedded IPv4
50 + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_unspecified or ip.is_reserved or ip.is_multicast:
51 + return True
52 + return any(ip in net for net in _BLOCKED_NETWORKS)
53 +
54 +
55 +def _parse_ip(host: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
56 + try:
57 + return ipaddress.ip_address(host.strip("[]").split("%")[0])
58 + except ValueError:
59 + return None
60 +
61 +
62 +def _resolve(host: str, port: int) -> list[str]:
63 + try:
64 + infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
65 + except socket.gaierror:
66 + return []
67 + return sorted({info[4][0] for info in infos})
68 +
69 +
70 +def validate_destination(url: str, *, resolved_ips: list[str] | None = None) -> None:
71 + p = urlparse(url.strip())
72 + if p.scheme.lower() not in ("http", "https"):
73 + raise BlockedDestination(f"scheme {p.scheme!r}")
74 + host = (p.hostname or "").strip().lower().rstrip(".")
75 + if not host:
76 + raise BlockedDestination("no host")
77 + if p.username or p.password:
78 + raise BlockedDestination("credentials in URL")
79 + literal = _parse_ip(host)
80 + if literal is None and (host in _BLOCKED_HOSTS or host.endswith(_BLOCKED_SUFFIXES) or "." not in host):
81 + raise BlockedDestination(f"local host {host!r}")
82 + if literal is not None:
83 + if _ip_blocked(literal):
84 + raise BlockedDestination(f"non-public address {host}")
85 + return
86 + ips = resolved_ips if resolved_ips is not None else _resolve(host, p.port or (443 if p.scheme.lower() == "https" else 80))
87 + for raw in ips:
88 + ip = _parse_ip(raw)
89 + if ip is not None and _ip_blocked(ip):
90 + raise BlockedDestination(f"{host} resolves to non-public address {raw}")
91 +
92 +
93 +async def validate_destination_async(url: str) -> None:
94 + p = urlparse(url.strip())
95 + host = (p.hostname or "").strip().lower()
96 + validate_destination(url, resolved_ips=[])
97 + if host and _parse_ip(host) is None:
98 + ips = await asyncio.to_thread(_resolve, host, p.port or (443 if p.scheme.lower() == "https" else 80))
99 + validate_destination(url, resolved_ips=ips)
100 +
101 +
102 +# ---------------------------------------------------------------------------------------------- results / errors
103 +
104 +
105 +class FetchError(Exception):
106 + def __init__(self, message: str, *, status: int | None = None, url: str = "", failure: FailureClass = FailureClass.UNKNOWN):
107 + super().__init__(message)
108 + self.status = status
109 + self.url = url
110 + self.failure = failure
111 +
112 +
113 +class BlockedError(FetchError):
114 + """Access denied by the origin or by robots — never bypassed."""
115 +
116 +
117 +class NotModified(Exception):
118 + """HTTP 304 — content unchanged since our stored validators."""
119 +
120 + def __init__(self, duration_ms: int = 0):
121 + super().__init__("not modified")
122 + self.duration_ms = duration_ms
123 +
124 +
125 +@dataclass
126 +class FetchResult:
127 + url: str
128 + final_url: str
129 + status: int
130 + headers: dict[str, str]
131 + content: bytes
132 + content_type: str
133 + fetched_at: datetime
134 + duration_ms: int
135 + transport: str = "http"
136 + redirects: int = 0
137 + sha256: str = field(init=False)
138 +
139 + def __post_init__(self) -> None:
140 + self.sha256 = hashlib.sha256(self.content).hexdigest()
141 +
142 + @property
143 + def etag(self) -> str | None:
144 + return self.headers.get("etag")
145 +
146 + @property
147 + def last_modified(self) -> str | None:
148 + return self.headers.get("last-modified")
149 +
150 + @property
151 + def text(self) -> str:
152 + enc = "utf-8"
153 + ct = self.content_type.lower()
154 + if "charset=" in ct:
155 + enc = ct.split("charset=", 1)[1].split(";")[0].strip().strip('"') or "utf-8"
156 + try:
157 + return self.content.decode(enc, errors="replace")
158 + except LookupError:
159 + return self.content.decode("utf-8", errors="replace")
160 +
161 + @property
162 + def is_html(self) -> bool:
163 + head = self.content[:512].lstrip().lower()
164 + return "html" in self.content_type or head.startswith((b"<!doctype html", b"<html")) or b"<html" in head
165 +
166 + @property
167 + def is_json(self) -> bool:
168 + return "json" in self.content_type or self.content[:1] in (b"{", b"[")
169 +
170 + @property
171 + def is_xml(self) -> bool:
172 + return "xml" in self.content_type or self.content[:5] == b"<?xml" or self.content[:200].lstrip().startswith((b"<rss", b"<feed", b"<urlset", b"<sitemapindex"))
173 +
174 + def json(self): # type: ignore[no-untyped-def]
175 + import orjson
176 +
177 + return orjson.loads(self.content)
178 +
179 +
180 +def classify_exception(exc: BaseException) -> FailureClass:
181 + if isinstance(exc, FetchError):
182 + return exc.failure
183 + if isinstance(exc, httpx.ConnectTimeout | httpx.ReadTimeout | httpx.WriteTimeout | httpx.PoolTimeout | asyncio.TimeoutError | TimeoutError):
184 + return FailureClass.TIMEOUT
185 + if isinstance(exc, httpx.ConnectError):
186 + msg = str(exc).lower()
187 + if "nodename" in msg or "name or service" in msg or "getaddrinfo" in msg or "temporary failure in name" in msg or "no address" in msg:
188 + return FailureClass.DNS
189 + return FailureClass.TIMEOUT
190 + return FailureClass.UNKNOWN
191 +
192 +
193 +def _status_failure(status: int) -> FailureClass:
194 + if status == 429:
195 + return FailureClass.RATE_LIMIT
196 + if status in (404, 410):
197 + return FailureClass.PAGE_REMOVED
198 + if status in BLOCK_STATUS:
199 + return FailureClass.BOT_CHALLENGE if status in (403, 999) else FailureClass.HTTP_4XX
200 + if 400 <= status < 500:
201 + return FailureClass.HTTP_4XX
202 + return FailureClass.HTTP_5XX
203 +
204 +
205 +# ---------------------------------------------------------------------------------------------- politeness primitives
206 +
207 +
208 +class DomainGovernor:
209 + """Per-domain minimum spacing + concurrency cap (in-process). Cluster-wide fairness comes from Postgres domain budgets."""
210 +
211 + def __init__(self) -> None:
212 + self._next: dict[str, float] = {}
213 + self._sem: dict[str, asyncio.Semaphore] = {}
214 + self._delay: dict[str, float] = {}
215 + self._lock = asyncio.Lock()
216 +
217 + def set_crawl_delay(self, domain: str, seconds: float) -> None:
218 + self._delay[domain] = min(120.0, max(0.0, seconds))
219 +
220 + async def acquire(self, domain: str, per_min: int) -> asyncio.Semaphore:
221 + sem = self._sem.get(domain)
222 + if sem is None:
223 + sem = self._sem[domain] = asyncio.Semaphore(settings.domain_max_concurrency)
224 + await sem.acquire()
225 + async with self._lock:
226 + spacing = max(60.0 / max(1, per_min), self._delay.get(domain, 0.0))
227 + now = time.monotonic()
228 + ready = self._next.get(domain, 0.0)
229 + wait = max(0.0, ready - now)
230 + self._next[domain] = max(now, ready) + spacing
231 + if wait > 0:
232 + await asyncio.sleep(wait)
233 + return sem
234 +
235 +
236 +class RobotsCache:
237 + def __init__(self) -> None:
238 + self._cache: dict[str, tuple[float, RobotFileParser | None, float | None]] = {}
239 +
240 + async def policy(self, client: httpx.AsyncClient, url: str) -> tuple[bool, float | None]:
241 + """(allowed, crawl_delay_seconds)."""
242 + if not settings.respect_robots:
243 + return True, None
244 + p = urlparse(url)
245 + key = f"{p.scheme}://{p.netloc}"
246 + cached = self._cache.get(key)
247 + if cached is None or cached[0] < time.monotonic():
248 + rp: RobotFileParser | None = RobotFileParser()
249 + delay: float | None = None
250 + try:
251 + r = await client.get(f"{key}/robots.txt", timeout=15, follow_redirects=False)
252 + if r.status_code == 200 and len(r.content) < 512 * 1024 and b"<html" not in r.content[:512].lower():
253 + rp.parse(r.text.splitlines()) # type: ignore[union-attr]
254 + agent = settings.user_agent.split("/")[0]
255 + try:
256 + d = rp.crawl_delay(agent) or rp.crawl_delay("*") # type: ignore[union-attr]
257 + delay = float(d) if d else None
258 + except Exception: # noqa: BLE001
259 + delay = None
260 + else:
261 + rp = None
262 + except Exception: # noqa: BLE001
263 + rp = None
264 + self._cache[key] = (time.monotonic() + 12 * 3600, rp, delay)
265 + cached = self._cache[key]
266 + rp, delay = cached[1], cached[2]
267 + if rp is None:
268 + return True, None
269 + agent = settings.user_agent.split("/")[0]
270 + try:
271 + return (rp.can_fetch(agent, url) or rp.can_fetch("*", url)), delay
272 + except Exception: # noqa: BLE001
273 + return True, delay
274 +
275 +
276 +governor = DomainGovernor()
277 +robots = RobotsCache()
278 +
279 +
280 +# ---------------------------------------------------------------------------------------------- fetcher
281 +
282 +
283 +class Fetcher:
284 + """Shared httpx client (one per worker process). `get()` raises NotModified / FetchError / BlockedError."""
285 +
286 + def __init__(self, *, timeout_s: float | None = None, headers: dict[str, str] | None = None, http2: bool = True,
287 + max_connections: int | None = None):
288 + self.timeout_s = timeout_s or settings.http_timeout_s
289 + self.headers = {"User-Agent": settings.user_agent,
290 + "Accept": "text/html,application/xhtml+xml,application/xml,application/json,application/rss+xml,text/*;q=0.9,*/*;q=0.7",
291 + "Accept-Language": "en-US,en;q=0.9,fr;q=0.6,de;q=0.4,*;q=0.2", "Accept-Encoding": "gzip, deflate, br",
292 + **(headers or {})}
293 + self._client: httpx.AsyncClient | None = None
294 + self._http2 = http2
295 + self._max_connections = max_connections or max(16, settings.fetch_concurrency * 2)
296 +
297 + async def __aenter__(self) -> Fetcher:
298 + await self.open()
299 + return self
300 +
301 + async def __aexit__(self, *exc: object) -> None:
302 + await self.close()
303 +
304 + async def open(self) -> None:
305 + if self._client is None:
306 + self._client = httpx.AsyncClient(headers=self.headers, timeout=httpx.Timeout(self.timeout_s, connect=15), follow_redirects=False,
307 + http2=self._http2, limits=httpx.Limits(max_connections=self._max_connections,
308 + max_keepalive_connections=self._max_connections // 2))
309 +
310 + async def close(self) -> None:
311 + if self._client is not None:
312 + await self._client.aclose()
313 + self._client = None
314 +
315 + @property
316 + def client(self) -> httpx.AsyncClient:
317 + if self._client is None:
318 + raise RuntimeError("Fetcher not opened — use `async with Fetcher() as f` or `await f.open()`")
319 + return self._client
320 +
321 + async def get(self, url: str, *, etag: str | None = None, last_modified: str | None = None, min_bytes: int = 1,
322 + retries: int | None = None, accept: str | None = None, rate_per_min: int | None = None, respect_robots: bool = True,
323 + max_bytes: int | None = None) -> FetchResult:
324 + client = self.client
325 + retries = settings.fetch_retries if retries is None else retries
326 + try:
327 + await validate_destination_async(url)
328 + except BlockedDestination as exc:
329 + raise FetchError(f"blocked destination: {exc}", url=url, failure=FailureClass.BLOCKED_DESTINATION) from exc
330 + domain = registrable_domain(url)
331 + if respect_robots:
332 + allowed, delay = await robots.policy(client, url)
333 + if delay:
334 + governor.set_crawl_delay(domain, delay)
335 + if not allowed:
336 + raise BlockedError(f"robots.txt disallows {url}", url=url, failure=FailureClass.ROBOTS)
337 + headers: dict[str, str] = {}
338 + if etag:
339 + headers["If-None-Match"] = etag
340 + if last_modified:
341 + headers["If-Modified-Since"] = last_modified
342 + if accept:
343 + headers["Accept"] = accept
344 + current = url
345 + hops = 0
346 + attempt = 0
347 + while True:
348 + sem = await governor.acquire(registrable_domain(current), rate_per_min or settings.default_rate_per_min)
349 + t0 = time.perf_counter()
350 + try:
351 + async with client.stream("GET", current, headers=headers) as r:
352 + if r.status_code == 304:
353 + raise NotModified(int((time.perf_counter() - t0) * 1000))
354 + if r.status_code in REDIRECT_STATUS:
355 + location = r.headers.get("location")
356 + if not location:
357 + raise FetchError(f"http {r.status_code} without Location", status=r.status_code, url=url, failure=FailureClass.REDIRECT)
358 + hops += 1
359 + if hops > settings.max_redirects:
360 + raise FetchError(f"too many redirects (> {settings.max_redirects})", status=r.status_code, url=url, failure=FailureClass.REDIRECT)
361 + nxt = urljoin(current, location)
362 + try:
363 + await validate_destination_async(nxt)
364 + except BlockedDestination as exc:
365 + raise FetchError(f"blocked redirect {current}{nxt}: {exc}", status=r.status_code, url=url,
366 + failure=FailureClass.BLOCKED_DESTINATION) from exc
367 + if registrable_domain(nxt) != registrable_domain(current):
368 + headers.pop("If-None-Match", None)
369 + headers.pop("If-Modified-Since", None)
370 + current = nxt
371 + continue
372 + if r.status_code in TRANSIENT_STATUS and attempt < retries:
373 + retry_after = r.headers.get("retry-after")
374 + delay = min(60.0, float(retry_after)) if retry_after and retry_after.isdigit() else 2.0 * (2 ** attempt)
375 + attempt += 1
376 + await asyncio.sleep(delay)
377 + continue
378 + if r.status_code in BLOCK_STATUS:
379 + raise BlockedError(f"http {r.status_code} for {current}", status=r.status_code, url=url, failure=_status_failure(r.status_code))
380 + if r.status_code >= 400:
381 + raise FetchError(f"http {r.status_code} for {current}", status=r.status_code, url=url, failure=_status_failure(r.status_code))
382 + return await self._read(r, url=url, final_url=current, t0=t0, min_bytes=min_bytes, hops=hops, max_bytes=max_bytes)
383 + except (NotModified, FetchError):
384 + raise
385 + except (httpx.TimeoutException, httpx.TransportError) as exc:
386 + if attempt < retries:
387 + attempt += 1
388 + await asyncio.sleep(1.5 * (2 ** attempt))
389 + continue
390 + raise FetchError(f"{exc.__class__.__name__}: {exc}", url=url, failure=classify_exception(exc)) from exc
391 + finally:
392 + sem.release()
393 +
394 + async def _read(self, r: httpx.Response, *, url: str, final_url: str, t0: float, min_bytes: int, hops: int, max_bytes: int | None) -> FetchResult:
395 + limit = max_bytes or settings.max_body_bytes
396 + declared = r.headers.get("content-length")
397 + if declared and declared.isdigit() and int(declared) > limit:
398 + raise FetchError(f"declared size {declared} exceeds {limit}", status=r.status_code, url=url, failure=FailureClass.TOO_LARGE)
399 + chunks: list[bytes] = []
400 + size = 0
401 + async for chunk in r.aiter_bytes():
402 + size += len(chunk)
403 + if size > limit:
404 + raise FetchError(f"body exceeds {limit} bytes", status=r.status_code, url=url, failure=FailureClass.TOO_LARGE)
405 + chunks.append(chunk)
406 + content = b"".join(chunks)
407 + if len(content) < min_bytes:
408 + raise FetchError(f"short body ({len(content)} bytes)", status=r.status_code, url=url, failure=FailureClass.PARSING)
409 + res = FetchResult(url=url, final_url=final_url, status=r.status_code, headers={k.lower(): v for k, v in r.headers.items()},
410 + content=content, content_type=r.headers.get("content-type", "").lower(), fetched_at=datetime.now(UTC),
411 + duration_ms=int((time.perf_counter() - t0) * 1000), transport="http", redirects=hops)
412 + if res.is_html and looks_like_challenge(content):
413 + raise BlockedError(f"anti-bot challenge page at {final_url}", status=r.status_code, url=url, failure=FailureClass.BOT_CHALLENGE)
414 + return res
415 +
416 + # ------------------------------------------------------------------------------------------ optional browser mode (spec §13 B)
417 + async def get_rendered(self, url: str, *, wait_ms: int = 1500) -> FetchResult:
418 + """Headless Chromium render — pooled by `settings.browser_concurrency`; only when a connector declares fetch_mode=browser."""
419 + if not settings.browser_enabled:
420 + raise FetchError("browser mode disabled", url=url, failure=FailureClass.UNKNOWN)
421 + await validate_destination_async(url)
422 + async with _browser_slots():
423 + from playwright.async_api import async_playwright # optional dependency
424 +
425 + t0 = time.perf_counter()
426 + async with async_playwright() as p:
427 + browser = await p.chromium.launch(headless=True)
428 + try:
429 + ctx = await browser.new_context(user_agent=settings.user_agent, java_script_enabled=True)
430 + await ctx.route("**/*", lambda route: route.abort() if route.request.resource_type in ("image", "media", "font") else route.continue_())
431 + page = await ctx.new_page()
432 + resp = await page.goto(url, wait_until="domcontentloaded", timeout=int(self.timeout_s * 1000))
433 + await page.wait_for_timeout(wait_ms)
434 + html = await page.content()
435 + status = resp.status if resp else 200
436 + final = page.url
437 + finally:
438 + await browser.close()
439 + return FetchResult(url=url, final_url=final, status=status, headers={}, content=html.encode(), content_type="text/html; charset=utf-8",
440 + fetched_at=datetime.now(UTC), duration_ms=int((time.perf_counter() - t0) * 1000), transport="browser")
441 +
442 +
443 +_browser_sem: asyncio.Semaphore | None = None
444 +
445 +
446 +def _browser_slots() -> asyncio.Semaphore:
447 + global _browser_sem
448 + if _browser_sem is None:
449 + _browser_sem = asyncio.Semaphore(settings.browser_concurrency)
450 + return _browser_sem
451 +
452 +
453 +def looks_like_challenge(content: bytes) -> bool:
454 + if len(content) > 80_000:
455 + return False
456 + head = content[:20000].lower()
457 + markers = (b"just a moment", b"cf-chl-", b"challenge-platform", b"attention required", b"verify you are human", b"access denied",
458 + b"captcha", b"perimeterx", b"_pxappid", b"datadome", b"enable javascript and cookies to continue", b"request unsuccessful. incapsula",
459 + b"bot detection", b"are you a robot")
460 + return sum(m in head for m in markers) >= 2
461 +
462 +
463 +def file_result(path: str, *, url: str, content_type: str = "text/html") -> FetchResult:
464 + """Wrap a fixture file as a FetchResult (tests, `catlas run-sensor --file`)."""
465 + with open(path, "rb") as fh:
466 + content = fh.read()
467 + return FetchResult(url=url, final_url=url, status=200, headers={}, content=content, content_type=content_type,
468 + fetched_at=datetime.now(UTC), duration_ms=0, transport="file")
469 +
470 +
471 +__all__ = ["BlockedDestination", "BlockedError", "FetchError", "FetchResult", "Fetcher", "NotModified", "classify_exception", "file_result",
472 + "governor", "looks_like_challenge", "robots", "validate_destination", "validate_destination_async"]
added src/companyatlas/ids.py +81 −0
@@ -0,0 +1,81 @@
1 +"""Stable internal identifiers: prefixed ULIDs (`co_01J…`). Slugs, names and URLs change; ids never do (spec §173)."""
2 +from __future__ import annotations
3 +
4 +import hashlib
5 +import re
6 +
7 +from slugify import slugify as _slugify
8 +from ulid import ULID
9 +
10 +PREFIXES: dict[str, str] = {
11 + "company": "co",
12 + "domain": "dom",
13 + "sensor": "sen",
14 + "observation": "obs",
15 + "snapshot": "snap",
16 + "change": "chg",
17 + "event": "evt",
18 + "cluster": "cl",
19 + "job": "job",
20 + "person": "person",
21 + "product": "prod",
22 + "plan": "plan",
23 + "location": "loc",
24 + "news": "news",
25 + "relationship": "rel",
26 + "queue_job": "qj",
27 + "llm_job": "llm",
28 + "review": "rev",
29 + "failure": "fail",
30 + "crawl_run": "run",
31 + "watchlist": "wl",
32 + "alert": "alert",
33 + "api_key": "key",
34 + "signal": "sig",
35 +}
36 +
37 +
38 +def new_id(kind: str) -> str:
39 + prefix = PREFIXES.get(kind)
40 + if prefix is None:
41 + raise ValueError(f"unknown id kind {kind!r}")
42 + return f"{prefix}_{ULID()}"
43 +
44 +
45 +def kind_of(entity_id: str) -> str | None:
46 + prefix = entity_id.split("_", 1)[0]
47 + for kind, p in PREFIXES.items():
48 + if p == prefix:
49 + return kind
50 + return None
51 +
52 +
53 +_slug_clean = re.compile(r"[^a-z0-9.-]+")
54 +
55 +
56 +def slugify(text: str, *, max_length: int = 80) -> str:
57 + """URL slug (`stripe`, `jpmorgan-chase`, `l-oreal`)."""
58 + s = _slugify(text, lowercase=True, regex_pattern=r"[^a-z0-9-]+", replacements=[("&", " and "), ("/", "-"), ("_", "-"), ("@", "-at-")])
59 + s = _slug_clean.sub("-", s).strip("-.")
60 + return s[:max_length].rstrip("-.") or "company"
61 +
62 +
63 +def normalize_alias(text: str) -> str:
64 + """Deterministic alias key: lowercase ASCII, punctuation collapsed, legal suffixes dropped. Resolution only — never display."""
65 + s = _slugify(text, lowercase=True, regex_pattern=r"[^a-z0-9]+")
66 + s = s.replace("-", "")
67 + for suffix in ("incorporated", "corporation", "limited", "company", "holdings", "group", "inc", "corp", "ltd", "plc", "llc",
68 + "gmbh", "ag", "sa", "nv", "bv", "se", "ab", "oyj", "asa", "spa", "srl", "kk", "co"):
69 + if s.endswith(suffix) and len(s) > len(suffix) + 2:
70 + s = s[: -len(suffix)]
71 + break
72 + return s
73 +
74 +
75 +def stable_hash(*parts: str, length: int = 24) -> str:
76 + """Deterministic key for idempotent rows (queue jobs `sensor_id + window`, job fingerprints, cluster keys)."""
77 + h = hashlib.sha256("\x1f".join(parts).encode("utf-8")).hexdigest()
78 + return h[:length]
79 +
80 +
81 +__all__ = ["PREFIXES", "kind_of", "new_id", "normalize_alias", "slugify", "stable_hash"]
added src/companyatlas/logging.py +56 −0
@@ -0,0 +1,56 @@
1 +"""Structured logging (JSON in production, human-readable in development)."""
2 +from __future__ import annotations
3 +
4 +import json
5 +import logging
6 +import sys
7 +from datetime import UTC, datetime
8 +
9 +from companyatlas.config import settings
10 +
11 +_RESERVED = {"name", "msg", "args", "levelname", "levelno", "pathname", "filename", "module", "exc_info", "exc_text", "stack_info",
12 + "lineno", "funcName", "created", "msecs", "relativeCreated", "thread", "threadName", "processName", "process",
13 + "message", "taskName"}
14 +
15 +
16 +class JsonFormatter(logging.Formatter):
17 + def __init__(self, service: str):
18 + super().__init__()
19 + self.service = service
20 +
21 + def format(self, record: logging.LogRecord) -> str:
22 + payload = {
23 + "ts": datetime.now(UTC).isoformat(timespec="milliseconds"),
24 + "level": record.levelname,
25 + "logger": record.name,
26 + "service": self.service,
27 + "msg": record.getMessage(),
28 + }
29 + for key, value in record.__dict__.items():
30 + if key not in _RESERVED and not key.startswith("_"):
31 + payload[key] = value
32 + if record.exc_info:
33 + payload["exc"] = self.formatException(record.exc_info)
34 + return json.dumps(payload, default=str)
35 +
36 +
37 +class PlainFormatter(logging.Formatter):
38 + def format(self, record: logging.LogRecord) -> str:
39 + extras = {k: v for k, v in record.__dict__.items() if k not in _RESERVED and not k.startswith("_")}
40 + base = f"{datetime.now().strftime('%H:%M:%S')} {record.levelname:<7} {record.name}: {record.getMessage()}"
41 + if extras:
42 + base += " " + " ".join(f"{k}={v}" for k, v in extras.items())
43 + if record.exc_info:
44 + base += "\n" + self.formatException(record.exc_info)
45 + return base
46 +
47 +
48 +def setup_logging(level: int = logging.INFO, service: str = "companyatlas") -> None:
49 + root = logging.getLogger()
50 + root.handlers.clear()
51 + handler = logging.StreamHandler(sys.stderr)
52 + handler.setFormatter(JsonFormatter(service) if settings.log_json else PlainFormatter())
53 + root.addHandler(handler)
54 + root.setLevel(level)
55 + for noisy in ("httpx", "httpcore", "apscheduler", "asyncio", "urllib3"):
56 + logging.getLogger(noisy).setLevel(logging.WARNING)
added src/companyatlas/registry/__init__.py +1 −0
@@ -0,0 +1 @@
1 +"""Package."""
added src/companyatlas/schemas/__init__.py +1 −0
@@ -0,0 +1 @@
1 +"""Package."""
added src/companyatlas/sdk/__init__.py +1 −0
@@ -0,0 +1 @@
1 +"""Package."""
added src/companyatlas/sdk/models.py +208 −0
@@ -0,0 +1,208 @@
1 +"""Typed contracts shared by connectors, the pipeline and the intelligence layer.
2 +
3 + FetchResult (fetch.py) → Extraction (this module) → snapshot row + blocks object → BlockDiff / StructuredDelta → changes row
4 +
5 + events service reads changes.structured_delta
6 +
7 +Everything is plain dataclasses / pydantic so it serialises to JSON for the `snapshots.extracted` and `changes.structured_delta`
8 +columns. Never put raw HTML in these — raw bytes live in the object store, referenced by hash.
9 +"""
10 +from __future__ import annotations
11 +
12 +from dataclasses import asdict, dataclass, field
13 +from datetime import datetime
14 +from typing import Any, Literal
15 +
16 +from companyatlas.taxonomy import Surface
17 +
18 +BlockKind = Literal["header", "hero", "nav", "section", "heading", "paragraph", "list", "table", "product_card", "pricing_plan", "job_listing",
19 + "person", "location", "news_item", "faq", "footer", "code", "quote", "other"]
20 +
21 +
22 +@dataclass(slots=True)
23 +class Block:
24 + """A semantic block of a page (spec §19). `key` is the stable identity used for added/removed/modified/moved diffing:
25 + it is derived from the block kind + heading path + a fuzzy content fingerprint, never from DOM position alone."""
26 + key: str
27 + kind: str
28 + text: str
29 + path: str = "" # heading breadcrumb, e.g. "Pricing > Pro"
30 + hash: str = "" # sha256 of normalized text (exact)
31 + simhash: int = 0 # 64-bit near-duplicate fingerprint
32 + weight: float = 1.0 # importance weight (hero/pricing/job > footer/nav)
33 + order: int = 0
34 + attrs: dict[str, Any] = field(default_factory=dict)
35 +
36 + def to_json(self) -> dict[str, Any]:
37 + return asdict(self)
38 +
39 +
40 +@dataclass(slots=True)
41 +class ExtractedJob:
42 + title: str
43 + url: str | None = None
44 + external_id: str | None = None
45 + department: str | None = None
46 + team: str | None = None
47 + location_text: str | None = None
48 + city: str | None = None
49 + region: str | None = None
50 + country: str | None = None # ISO-2 when confidently derivable, else None (never guess)
51 + remote: bool | None = None
52 + employment_type: str | None = None
53 + seniority: str | None = None
54 + skills: list[str] = field(default_factory=list)
55 + salary_min: float | None = None
56 + salary_max: float | None = None
57 + salary_currency: str | None = None
58 + salary_period: str | None = None
59 + posted_at: datetime | None = None
60 + description_hash: str | None = None
61 + raw: dict[str, Any] = field(default_factory=dict)
62 +
63 +
64 +@dataclass(slots=True)
65 +class ExtractedPerson:
66 + name: str
67 + title: str | None = None
68 + role_category: str | None = None # ceo | cfo | cto | coo | founder | president | chair | board | vp | head | other
69 + is_executive: bool = False
70 + url: str | None = None
71 +
72 +
73 +@dataclass(slots=True)
74 +class ExtractedProduct:
75 + name: str
76 + url: str | None = None
77 + category: str | None = None
78 + description: str | None = None
79 +
80 +
81 +@dataclass(slots=True)
82 +class ExtractedPlan:
83 + plan_name: str
84 + price: float | None = None
85 + price_text: str | None = None
86 + currency: str | None = None
87 + billing_period: str | None = None # month | year | one_time | usage | contact
88 + unit: str | None = None
89 + features: list[str] = field(default_factory=list)
90 + contact_sales: bool = False
91 +
92 +
93 +@dataclass(slots=True)
94 +class ExtractedLocation:
95 + name: str
96 + kind: str = "office" # headquarters | office | store | factory | warehouse | lab | data_center | other
97 + city: str | None = None
98 + region: str | None = None
99 + country: str | None = None
100 + address_text: str | None = None # only if the page states it; never invented
101 +
102 +
103 +@dataclass(slots=True)
104 +class ExtractedNewsItem:
105 + title: str
106 + url: str
107 + published_at: datetime | None = None
108 + summary: str | None = None
109 + category: str | None = None # press | blog | changelog | research | ir | other
110 + language: str | None = None
111 +
112 +
113 +@dataclass(slots=True)
114 +class DiscoveredUrl:
115 + url: str
116 + surface: Surface
117 + confidence: float
118 + anchor: str | None = None
119 + method: str = "nav" # nav | sitemap | robots | pattern | ats | feed | link | jsonld
120 +
121 +
122 +@dataclass(slots=True)
123 +class Extraction:
124 + """Output of `Connector.extract`. `text` is the normalized main-content text (what gets diffed at the text level),
125 + `blocks` the semantic blocks (block-level diff), the typed lists feed entity tables and structured deltas."""
126 + text: str
127 + blocks: list[Block]
128 + title: str | None = None
129 + language: str | None = None
130 + meta: dict[str, Any] = field(default_factory=dict) # description, og tags, canonical, generator, jsonld types…
131 + jobs: list[ExtractedJob] = field(default_factory=list)
132 + people: list[ExtractedPerson] = field(default_factory=list)
133 + products: list[ExtractedProduct] = field(default_factory=list)
134 + plans: list[ExtractedPlan] = field(default_factory=list)
135 + locations: list[ExtractedLocation] = field(default_factory=list)
136 + news: list[ExtractedNewsItem] = field(default_factory=list)
137 + discovered: list[DiscoveredUrl] = field(default_factory=list)
138 + structured_hash: str = "" # hash of the structured payload (typed lists) — set by the pipeline if empty
139 + normalized_hash: str = "" # hash of `text` — set by the pipeline if empty
140 +
141 + def summary(self) -> dict[str, Any]:
142 + return {"job_count": len(self.jobs), "people_count": len(self.people), "product_count": len(self.products), "plan_count": len(self.plans),
143 + "location_count": len(self.locations), "news_count": len(self.news), "block_count": len(self.blocks), "text_length": len(self.text),
144 + "discovered_count": len(self.discovered)}
145 +
146 + def structured_payload(self) -> dict[str, Any]:
147 + return {"jobs": [asdict(j) for j in self.jobs], "people": [asdict(p) for p in self.people], "products": [asdict(p) for p in self.products],
148 + "plans": [asdict(p) for p in self.plans], "locations": [asdict(loc) for loc in self.locations], "news": [asdict(n) for n in self.news],
149 + "meta": self.meta}
150 +
151 +
152 +@dataclass(slots=True)
153 +class BlockDelta:
154 + key: str
155 + kind: str
156 + path: str
157 + before: str | None
158 + after: str | None
159 + weight: float
160 + similarity: float | None = None # for modified blocks
161 +
162 +
163 +@dataclass(slots=True)
164 +class BlockDiff:
165 + """Result of comparing two block lists (spec §19–20). `significance` ∈ [0,1] is computed by the diff engine from the weighted
166 + share of changed content, page importance and the typed deltas; the pipeline maps it to a ChangeKind."""
167 + added: list[BlockDelta] = field(default_factory=list)
168 + removed: list[BlockDelta] = field(default_factory=list)
169 + modified: list[BlockDelta] = field(default_factory=list)
170 + moved: list[str] = field(default_factory=list)
171 + text_delta_ratio: float = 0.0
172 + similarity: float = 1.0
173 + significance: float = 0.0
174 + reasons: list[str] = field(default_factory=list)
175 +
176 + @property
177 + def is_empty(self) -> bool:
178 + return not (self.added or self.removed or self.modified)
179 +
180 + def to_json(self, *, limit: int = 40) -> dict[str, Any]:
181 + def _cut(items: list[BlockDelta]) -> list[dict[str, Any]]:
182 + out = []
183 + for d in items[:limit]:
184 + out.append({"key": d.key, "kind": d.kind, "path": d.path, "before": (d.before or "")[:600] or None, "after": (d.after or "")[:600] or None,
185 + "weight": d.weight, "similarity": d.similarity})
186 + return out
187 + return {"added": _cut(self.added), "removed": _cut(self.removed), "modified": _cut(self.modified), "moved": self.moved[:limit],
188 + "counts": {"added": len(self.added), "removed": len(self.removed), "modified": len(self.modified), "moved": len(self.moved)},
189 + "text_delta_ratio": round(self.text_delta_ratio, 4), "similarity": round(self.similarity, 4), "reasons": self.reasons}
190 +
191 +
192 +# StructuredDelta — JSON stored in `changes.structured_delta`, produced by the pipeline when it reconciles typed extractions
193 +# with the entity tables. Shape (all keys optional, lists bounded to 200 items):
194 +# {
195 +# "jobs": {"added": [ {title, url, location_text, country, remote, department, is_ai} ], "removed": [...], "open_before": n, "open_after": n},
196 +# "people": {"added": [ {name, title, role_category} ], "removed": [...], "title_changed": [ {name, before, after} ]},
197 +# "products": {"added": [ {name, url} ], "removed": [...]},
198 +# "plans": {"added": [ {plan_name, price, currency, billing_period} ], "removed": [...],
199 +# "price_changed": [ {plan_name, before, after, currency, billing_period, pct} ]},
200 +# "locations": {"added": [ {name, city, country, kind} ], "removed": [...], "new_countries": ["JP"]},
201 +# "news": {"added": [ {title, url, published_at, category} ]},
202 +# "meta": {"title_changed": {before, after}, "description_changed": bool, "language": "en"}
203 +# }
204 +StructuredDelta = dict[str, Any]
205 +
206 +
207 +__all__ = ["Block", "BlockDelta", "BlockDiff", "BlockKind", "DiscoveredUrl", "ExtractedJob", "ExtractedLocation", "ExtractedNewsItem", "ExtractedPerson",
208 + "ExtractedPlan", "ExtractedProduct", "Extraction", "StructuredDelta"]
added src/companyatlas/services/__init__.py +1 −0
@@ -0,0 +1 @@
1 +"""Package."""
added src/companyatlas/services/periodic.py +114 −0
@@ -0,0 +1,114 @@
1 +"""Periodic task registry shared by the scheduler process.
2 +
3 +The crawl scheduler (`catlas schedule`, services/scheduler.py) is the single long-running production process besides the API. Any
4 +area (intelligence, alerts, metrics, retention, backups…) registers background work here instead of shipping its own daemon:
5 +
6 + from companyatlas.services.periodic import periodic
7 +
8 + @periodic("process-changes", every_s=30)
9 + async def process_changes_task(): ...
10 +
11 + @periodic("metrics-hourly", cron="7 * * * *")
12 + async def metrics_task(): ...
13 +
14 +`every_s` tasks run on a fixed cadence (first run `initial_delay_s` after start); `cron` tasks use APScheduler cron syntax in
15 +`settings.tz`. Tasks must be idempotent and bounded (do a batch, return). Exceptions are logged, never fatal. The scheduler
16 +imports `companyatlas.services.registry_loader` (below) which imports every module that registers tasks — add yours there.
17 +"""
18 +from __future__ import annotations
19 +
20 +import asyncio
21 +import importlib
22 +import logging
23 +import time
24 +from collections.abc import Awaitable, Callable
25 +from dataclasses import dataclass, field
26 +
27 +log = logging.getLogger(__name__)
28 +
29 +TaskFn = Callable[[], Awaitable[object]]
30 +
31 +
32 +@dataclass
33 +class PeriodicTask:
34 + name: str
35 + fn: TaskFn
36 + every_s: float | None = None
37 + cron: str | None = None
38 + initial_delay_s: float = 5.0
39 + exclusive: bool = True # never overlap with itself
40 + last_started_at: float | None = None
41 + last_finished_at: float | None = None
42 + last_error: str | None = None
43 + runs: int = 0
44 + failures: int = 0
45 + _lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False)
46 +
47 + async def run(self) -> None:
48 + if self.exclusive and self._lock.locked():
49 + return
50 + async with self._lock:
51 + self.last_started_at = time.time()
52 + try:
53 + await self.fn()
54 + self.runs += 1
55 + self.last_error = None
56 + except Exception as exc: # noqa: BLE001
57 + self.failures += 1
58 + self.last_error = f"{exc.__class__.__name__}: {exc}"[:500]
59 + log.exception("periodic task failed", extra={"task": self.name})
60 + finally:
61 + self.last_finished_at = time.time()
62 +
63 +
64 +_TASKS: dict[str, PeriodicTask] = {}
65 +
66 +# Modules that register periodic tasks at import time. Areas append their module path here (one line each).
67 +TASK_MODULES: list[str] = [
68 + "companyatlas.services.events",
69 + "companyatlas.services.metrics",
70 + "companyatlas.services.alerts",
71 + "companyatlas.services.retention",
72 + "companyatlas.services.llm.enrich",
73 + "companyatlas.services.signals",
74 + "companyatlas.services.trends",
75 +]
76 +
77 +
78 +def periodic(name: str, *, every_s: float | None = None, cron: str | None = None, initial_delay_s: float = 5.0, exclusive: bool = True): # type: ignore[no-untyped-def]
79 + if not every_s and not cron:
80 + raise ValueError("periodic task needs every_s or cron")
81 +
82 + def deco(fn: TaskFn) -> TaskFn:
83 + _TASKS[name] = PeriodicTask(name=name, fn=fn, every_s=every_s, cron=cron, initial_delay_s=initial_delay_s, exclusive=exclusive)
84 + return fn
85 +
86 + return deco
87 +
88 +
89 +def load_task_modules() -> list[str]:
90 + """Import every registered task module (missing modules are skipped so areas can land independently)."""
91 + loaded: list[str] = []
92 + for mod in TASK_MODULES:
93 + try:
94 + importlib.import_module(mod)
95 + loaded.append(mod)
96 + except ModuleNotFoundError as exc:
97 + if exc.name and (mod == exc.name or mod.startswith(exc.name + ".")):
98 + continue
99 + log.exception("task module failed to import", extra={"module": mod})
100 + except Exception: # noqa: BLE001
101 + log.exception("task module failed to import", extra={"module": mod})
102 + return loaded
103 +
104 +
105 +def tasks() -> dict[str, PeriodicTask]:
106 + return _TASKS
107 +
108 +
109 +def snapshot() -> list[dict[str, object]]:
110 + return [{"name": t.name, "every_s": t.every_s, "cron": t.cron, "runs": t.runs, "failures": t.failures, "last_started_at": t.last_started_at,
111 + "last_finished_at": t.last_finished_at, "last_error": t.last_error} for t in _TASKS.values()]
112 +
113 +
114 +__all__ = ["TASK_MODULES", "PeriodicTask", "load_task_modules", "periodic", "snapshot", "tasks"]
added src/companyatlas/taxonomy.py +369 −0
@@ -0,0 +1,369 @@
1 +"""Shared vocabulary and typed configuration: surfaces, connector categories, schedule tiers, failure classes, event taxonomy,
2 +significance bands, metric names and index weights. Everything here is versioned with the code (spec §21, §35, §61, §178)."""
3 +from __future__ import annotations
4 +
5 +from enum import StrEnum
6 +
7 +# ------------------------------------------------------------------------------------------------------------ surfaces
8 +
9 +
10 +class Surface(StrEnum):
11 + HOMEPAGE = "homepage"
12 + ABOUT = "about"
13 + LEADERSHIP = "leadership"
14 + PRODUCTS = "products"
15 + SERVICES = "services"
16 + SOLUTIONS = "solutions"
17 + INDUSTRIES = "industries"
18 + PRICING = "pricing"
19 + CAREERS = "careers"
20 + JOBS_BOARD = "jobs_board" # structured ATS board (Greenhouse, Lever, Ashby, SmartRecruiters, Workday, JSON)
21 + NEWSROOM = "newsroom"
22 + BLOG = "blog"
23 + FEED = "feed" # RSS / Atom
24 + DOCS = "docs"
25 + DEVELOPER = "developer"
26 + API = "api"
27 + CHANGELOG = "changelog"
28 + INVESTOR_RELATIONS = "investor_relations"
29 + LOCATIONS = "locations"
30 + CONTACT = "contact"
31 + CUSTOMERS = "customers"
32 + PARTNERS = "partners"
33 + LEGAL_TERMS = "legal_terms"
34 + LEGAL_PRIVACY = "legal_privacy"
35 + SECURITY = "security"
36 + SUSTAINABILITY = "sustainability"
37 + RESEARCH = "research"
38 + STATUS = "status"
39 + SUPPORT = "support"
40 + SITEMAP = "sitemap"
41 + OTHER = "other"
42 +
43 +
44 +# Default base interval per surface (seconds) — the scheduler adapts around it (spec §15–16). Tier letters are derived.
45 +SURFACE_BASE_INTERVAL_S: dict[str, int] = {
46 + Surface.HOMEPAGE: 6 * 3600,
47 + Surface.NEWSROOM: 3 * 3600,
48 + Surface.FEED: 2 * 3600,
49 + Surface.BLOG: 6 * 3600,
50 + Surface.CAREERS: 12 * 3600,
51 + Surface.JOBS_BOARD: 6 * 3600,
52 + Surface.PRICING: 12 * 3600,
53 + Surface.PRODUCTS: 24 * 3600,
54 + Surface.SERVICES: 48 * 3600,
55 + Surface.SOLUTIONS: 48 * 3600,
56 + Surface.INDUSTRIES: 72 * 3600,
57 + Surface.LEADERSHIP: 24 * 3600,
58 + Surface.ABOUT: 72 * 3600,
59 + Surface.LOCATIONS: 48 * 3600,
60 + Surface.CONTACT: 7 * 86400,
61 + Surface.DOCS: 24 * 3600,
62 + Surface.DEVELOPER: 24 * 3600,
63 + Surface.API: 24 * 3600,
64 + Surface.CHANGELOG: 6 * 3600,
65 + Surface.INVESTOR_RELATIONS: 12 * 3600,
66 + Surface.CUSTOMERS: 72 * 3600,
67 + Surface.PARTNERS: 72 * 3600,
68 + Surface.LEGAL_TERMS: 3 * 86400,
69 + Surface.LEGAL_PRIVACY: 3 * 86400,
70 + Surface.SECURITY: 3 * 86400,
71 + Surface.SUSTAINABILITY: 7 * 86400,
72 + Surface.RESEARCH: 48 * 3600,
73 + Surface.STATUS: 6 * 3600,
74 + Surface.SUPPORT: 7 * 86400,
75 + Surface.SITEMAP: 24 * 3600,
76 + Surface.OTHER: 3 * 86400,
77 +}
78 +
79 +# Semantic importance of a surface (0–1): weights change significance and sensor quality (spec §12, §20).
80 +SURFACE_IMPORTANCE: dict[str, float] = {
81 + Surface.PRICING: 1.0, Surface.JOBS_BOARD: 0.95, Surface.CAREERS: 0.85, Surface.LEADERSHIP: 0.9, Surface.NEWSROOM: 0.9,
82 + Surface.PRODUCTS: 0.85, Surface.INVESTOR_RELATIONS: 0.85, Surface.CHANGELOG: 0.8, Surface.LOCATIONS: 0.8, Surface.FEED: 0.75,
83 + Surface.HOMEPAGE: 0.7, Surface.DOCS: 0.65, Surface.API: 0.65, Surface.DEVELOPER: 0.65, Surface.LEGAL_TERMS: 0.7,
84 + Surface.LEGAL_PRIVACY: 0.6, Surface.SECURITY: 0.6, Surface.PARTNERS: 0.6, Surface.CUSTOMERS: 0.55, Surface.BLOG: 0.55,
85 + Surface.RESEARCH: 0.55, Surface.SERVICES: 0.55, Surface.SOLUTIONS: 0.5, Surface.INDUSTRIES: 0.45, Surface.ABOUT: 0.5,
86 + Surface.SUSTAINABILITY: 0.45, Surface.STATUS: 0.5, Surface.CONTACT: 0.3, Surface.SUPPORT: 0.3, Surface.SITEMAP: 0.4, Surface.OTHER: 0.3,
87 +}
88 +
89 +
90 +def tier_for_interval(seconds: int) -> str:
91 + """Schedule tier letter (spec §15): A ≤ 15 min · B ≤ 1 h · C ≤ 6 h · D ≤ 24 h · E > 24 h."""
92 + if seconds <= 15 * 60:
93 + return "A"
94 + if seconds <= 3600:
95 + return "B"
96 + if seconds <= 6 * 3600:
97 + return "C"
98 + if seconds <= 86400:
99 + return "D"
100 + return "E"
101 +
102 +
103 +# ------------------------------------------------------------------------------------------------------------ fetch / failures
104 +
105 +
106 +class FetchMode(StrEnum):
107 + HTTP = "http"
108 + BROWSER = "browser"
109 + FEED = "feed"
110 + JSON = "json"
111 + SITEMAP = "sitemap"
112 +
113 +
114 +class FailureClass(StrEnum):
115 + DNS = "DNS"
116 + TIMEOUT = "TIMEOUT"
117 + HTTP_4XX = "HTTP_4XX"
118 + HTTP_5XX = "HTTP_5XX"
119 + BOT_CHALLENGE = "BOT_CHALLENGE"
120 + PARSING = "PARSING"
121 + SCHEMA = "SCHEMA"
122 + REDIRECT = "REDIRECT"
123 + PAGE_REMOVED = "PAGE_REMOVED"
124 + RATE_LIMIT = "RATE_LIMIT"
125 + ROBOTS = "ROBOTS"
126 + BLOCKED_DESTINATION = "BLOCKED_DESTINATION"
127 + TOO_LARGE = "TOO_LARGE"
128 + UNKNOWN = "UNKNOWN"
129 +
130 +
131 +# Retry policy per failure class: (backoff multiplier on the sensor interval, failures before the sensor is marked failing)
132 +FAILURE_POLICY: dict[str, tuple[float, int]] = {
133 + FailureClass.DNS: (3.0, 3),
134 + FailureClass.TIMEOUT: (1.5, 4),
135 + FailureClass.HTTP_4XX: (2.0, 3),
136 + FailureClass.HTTP_5XX: (1.5, 5),
137 + FailureClass.BOT_CHALLENGE: (4.0, 2),
138 + FailureClass.PARSING: (2.0, 3),
139 + FailureClass.SCHEMA: (2.0, 3),
140 + FailureClass.REDIRECT: (2.0, 2),
141 + FailureClass.PAGE_REMOVED: (4.0, 2),
142 + FailureClass.RATE_LIMIT: (3.0, 4),
143 + FailureClass.ROBOTS: (8.0, 1),
144 + FailureClass.BLOCKED_DESTINATION: (8.0, 1),
145 + FailureClass.TOO_LARGE: (4.0, 2),
146 + FailureClass.UNKNOWN: (2.0, 3),
147 +}
148 +
149 +
150 +class SensorStatus(StrEnum):
151 + PENDING = "pending"
152 + ACTIVE = "active"
153 + PAUSED = "paused"
154 + FAILING = "failing"
155 + STALE = "stale"
156 + BLOCKED = "blocked"
157 + REDIRECTED = "redirected"
158 + RETIRED = "retired"
159 +
160 +
161 +class CompanyStatus(StrEnum):
162 + ACTIVE = "ACTIVE"
163 + POSSIBLY_INACTIVE = "POSSIBLY_INACTIVE"
164 + WEBSITE_UNAVAILABLE = "WEBSITE_UNAVAILABLE"
165 + ACQUIRED = "ACQUIRED"
166 + DISSOLVED = "DISSOLVED"
167 + UNKNOWN = "UNKNOWN"
168 +
169 +
170 +class OnboardingStatus(StrEnum):
171 + PENDING = "pending"
172 + DISCOVERING = "discovering"
173 + ACTIVE = "active"
174 + FAILED = "failed"
175 + NO_WEBSITE = "no_website"
176 +
177 +
178 +# ------------------------------------------------------------------------------------------------------------ changes / events
179 +
180 +
181 +class ChangeKind(StrEnum):
182 + NOISE = "noise"
183 + MINOR = "minor"
184 + MEANINGFUL = "meaningful"
185 + MAJOR = "major"
186 + CRITICAL = "critical"
187 +
188 +
189 +def change_kind(significance: float, *, noise: float = 0.20, meaningful: float = 0.40, major: float = 0.65, critical: float = 0.85) -> ChangeKind:
190 + if significance < noise:
191 + return ChangeKind.NOISE
192 + if significance < meaningful:
193 + return ChangeKind.MINOR
194 + if significance < major:
195 + return ChangeKind.MEANINGFUL
196 + if significance < critical:
197 + return ChangeKind.MAJOR
198 + return ChangeKind.CRITICAL
199 +
200 +
201 +class EventType(StrEnum):
202 + PRODUCT = "PRODUCT"
203 + PRICING = "PRICING"
204 + HIRING = "HIRING"
205 + LEADERSHIP = "LEADERSHIP"
206 + LOCATION = "LOCATION"
207 + FINANCING = "FINANCING"
208 + MA = "M&A"
209 + PARTNERSHIP = "PARTNERSHIP"
210 + STRATEGY = "STRATEGY"
211 + TECHNOLOGY = "TECHNOLOGY"
212 + LEGAL = "LEGAL"
213 + MARKETING = "MARKETING"
214 + DEVELOPER = "DEVELOPER"
215 + SECURITY = "SECURITY"
216 + OPERATIONS = "OPERATIONS"
217 + SUSTAINABILITY = "SUSTAINABILITY"
218 + INVESTOR_RELATIONS = "INVESTOR_RELATIONS"
219 + COMMUNICATION = "COMMUNICATION"
220 + OTHER = "OTHER"
221 +
222 +
223 +# subtype → (type, default importance). Deterministic extractors emit these; the LLM classifier may only pick from this list.
224 +EVENT_SUBTYPES: dict[str, tuple[EventType, float]] = {
225 + "PRODUCT_LAUNCH": (EventType.PRODUCT, 0.75),
226 + "NEW_PRODUCT": (EventType.PRODUCT, 0.7),
227 + "PRODUCT_REMOVED": (EventType.PRODUCT, 0.6),
228 + "PRODUCT_RENAME": (EventType.PRODUCT, 0.5),
229 + "PRODUCT_UPDATE": (EventType.PRODUCT, 0.45),
230 + "FEATURE_LAUNCH": (EventType.PRODUCT, 0.5),
231 + "PRICE_INCREASE": (EventType.PRICING, 0.8),
232 + "PRICE_DECREASE": (EventType.PRICING, 0.75),
233 + "NEW_PRICING_TIER": (EventType.PRICING, 0.7),
234 + "PRICING_TIER_REMOVED": (EventType.PRICING, 0.65),
235 + "PRICING_CHANGE": (EventType.PRICING, 0.65),
236 + "NEW_JOB": (EventType.HIRING, 0.3),
237 + "JOB_REMOVED": (EventType.HIRING, 0.25),
238 + "JOB_COUNT_INCREASE": (EventType.HIRING, 0.55),
239 + "JOB_COUNT_DECREASE": (EventType.HIRING, 0.55),
240 + "HIRING_SURGE": (EventType.HIRING, 0.75),
241 + "HIRING_FREEZE_SIGNAL": (EventType.HIRING, 0.7),
242 + "AI_HIRING": (EventType.HIRING, 0.5),
243 + "NEW_EXECUTIVE": (EventType.LEADERSHIP, 0.8),
244 + "EXECUTIVE_NO_LONGER_LISTED": (EventType.LEADERSHIP, 0.75),
245 + "EXECUTIVE_TITLE_CHANGE": (EventType.LEADERSHIP, 0.6),
246 + "LEADERSHIP_CHANGE": (EventType.LEADERSHIP, 0.7),
247 + "NEW_OFFICE": (EventType.LOCATION, 0.65),
248 + "NEW_LOCATION": (EventType.LOCATION, 0.6),
249 + "OFFICE_REMOVED": (EventType.LOCATION, 0.6),
250 + "COUNTRY_EXPANSION": (EventType.LOCATION, 0.8),
251 + "FUNDING_ROUND": (EventType.FINANCING, 0.85),
252 + "IPO": (EventType.FINANCING, 0.95),
253 + "ACQUISITION": (EventType.MA, 0.9),
254 + "DIVESTITURE": (EventType.MA, 0.8),
255 + "MERGER": (EventType.MA, 0.9),
256 + "NEW_PARTNERSHIP": (EventType.PARTNERSHIP, 0.6),
257 + "PARTNERSHIP_ENDED": (EventType.PARTNERSHIP, 0.5),
258 + "BRAND_REPOSITIONING": (EventType.STRATEGY, 0.6),
259 + "STRATEGY_UPDATE": (EventType.STRATEGY, 0.5),
260 + "ENTERPRISE_REPOSITIONING": (EventType.STRATEGY, 0.55),
261 + "TECHNOLOGY_ADOPTION": (EventType.TECHNOLOGY, 0.5),
262 + "AI_LAUNCH": (EventType.TECHNOLOGY, 0.7),
263 + "TERMS_CHANGE": (EventType.LEGAL, 0.6),
264 + "PRIVACY_POLICY_CHANGE": (EventType.LEGAL, 0.55),
265 + "LEGAL_UPDATE": (EventType.LEGAL, 0.5),
266 + "REGULATORY": (EventType.LEGAL, 0.6),
267 + "CAMPAIGN_LAUNCH": (EventType.MARKETING, 0.35),
268 + "MESSAGING_CHANGE": (EventType.MARKETING, 0.4),
269 + "API_LAUNCH": (EventType.DEVELOPER, 0.7),
270 + "API_CHANGE": (EventType.DEVELOPER, 0.5),
271 + "SDK_RELEASE": (EventType.DEVELOPER, 0.5),
272 + "DOCUMENTATION_CHANGE": (EventType.DEVELOPER, 0.35),
273 + "CHANGELOG_ENTRY": (EventType.DEVELOPER, 0.4),
274 + "SECURITY_INCIDENT": (EventType.SECURITY, 0.85),
275 + "SECURITY_UPDATE": (EventType.SECURITY, 0.5),
276 + "OUTAGE": (EventType.OPERATIONS, 0.6),
277 + "OPERATIONS_UPDATE": (EventType.OPERATIONS, 0.4),
278 + "SUSTAINABILITY_UPDATE": (EventType.SUSTAINABILITY, 0.4),
279 + "EARNINGS_RELEASE": (EventType.INVESTOR_RELATIONS, 0.7),
280 + "INVESTOR_UPDATE": (EventType.INVESTOR_RELATIONS, 0.55),
281 + "NEWS_RELEASE": (EventType.COMMUNICATION, 0.45),
282 + "BLOG_POST": (EventType.COMMUNICATION, 0.3),
283 + "WEBSITE_CHANGE": (EventType.COMMUNICATION, 0.3),
284 + "HOMEPAGE_REDESIGN": (EventType.MARKETING, 0.45),
285 + "DOC_CHANGE": (EventType.DEVELOPER, 0.35),
286 + "OTHER": (EventType.OTHER, 0.3),
287 +}
288 +
289 +# MVP high-confidence events emitted deterministically (spec §158).
290 +MVP_EVENT_SUBTYPES = ("NEW_JOB", "JOB_REMOVED", "JOB_COUNT_INCREASE", "JOB_COUNT_DECREASE", "NEW_PRODUCT", "PRODUCT_REMOVED",
291 + "PRICING_CHANGE", "PRICE_INCREASE", "PRICE_DECREASE", "NEW_PRICING_TIER", "LEADERSHIP_CHANGE", "NEW_EXECUTIVE",
292 + "EXECUTIVE_NO_LONGER_LISTED", "NEW_LOCATION", "NEWS_RELEASE", "DOC_CHANGE", "CHANGELOG_ENTRY", "BLOG_POST")
293 +
294 +
295 +class EventStatus(StrEnum):
296 + ACTIVE = "active"
297 + RETRACTED = "retracted"
298 + DUPLICATE = "duplicate"
299 + REVIEW = "review"
300 +
301 +
302 +def confidence_label(confidence: float) -> str:
303 + """Spec §50: VERIFIED · HIGH CONFIDENCE · LIKELY · INFERRED · LOW CONFIDENCE."""
304 + if confidence >= 0.95:
305 + return "VERIFIED"
306 + if confidence >= 0.85:
307 + return "HIGH_CONFIDENCE"
308 + if confidence >= 0.7:
309 + return "LIKELY"
310 + if confidence >= 0.5:
311 + return "INFERRED"
312 + return "LOW_CONFIDENCE"
313 +
314 +
315 +# ------------------------------------------------------------------------------------------------------------ metrics
316 +
317 +
318 +class Metric(StrEnum):
319 + ACTIVITY_SCORE = "activity_score"
320 + HIRING_MOMENTUM_7D = "hiring_momentum_7d"
321 + HIRING_MOMENTUM_30D = "hiring_momentum_30d"
322 + HIRING_MOMENTUM_90D = "hiring_momentum_90d"
323 + OPEN_JOBS = "open_jobs"
324 + AI_ADOPTION = "ai_adoption"
325 + PRODUCT_VELOCITY = "product_velocity"
326 + GEO_EXPANSION = "geo_expansion"
327 + DEVELOPER_MOMENTUM = "developer_momentum"
328 + COMMUNICATION_ACTIVITY = "communication_activity"
329 + PRICING_ACTIVITY = "pricing_activity"
330 + LEADERSHIP_ACTIVITY = "leadership_activity"
331 + CORPORATE_CHANGE_INDEX = "corporate_change_index"
332 + ANOMALY_SCORE = "anomaly_score"
333 + HISTORICAL_COVERAGE = "historical_coverage"
334 +
335 +
336 +# Corporate Change Index weights (spec §35) — formula version bumps when weights change.
337 +CCI_FORMULA_VERSION = "cci-v1"
338 +CCI_WEIGHTS: dict[str, float] = {
339 + Metric.HIRING_MOMENTUM_30D: 0.25,
340 + Metric.PRODUCT_VELOCITY: 0.20,
341 + Metric.GEO_EXPANSION: 0.15,
342 + Metric.LEADERSHIP_ACTIVITY: 0.15,
343 + Metric.DEVELOPER_MOMENTUM: 0.10,
344 + Metric.COMMUNICATION_ACTIVITY: 0.10,
345 + Metric.PRICING_ACTIVITY: 0.05,
346 +}
347 +METRICS_FORMULA_VERSION = "metrics-v1"
348 +
349 +# Observable AI-adoption signals (spec §31): keywords matched in job titles, product names, docs headings. Case-insensitive.
350 +AI_KEYWORDS = ("machine learning", "artificial intelligence", "deep learning", " ai ", "ai/ml", "ml engineer", "llm", "large language",
351 + "generative", "genai", "gen ai", "nlp", "computer vision", "data scientist", "mlops", "agentic", "copilot",
352 + "foundation model", "neural", "inference", "rag ", "retrieval-augmented", "prompt engineer", "ai engineer", "ai product")
353 +
354 +# ------------------------------------------------------------------------------------------------------------ companies
355 +
356 +COMPANY_IMPORTANCE_TIERS = {1: "global", 2: "major", 3: "notable", 4: "long_tail"}
357 +
358 +
359 +class CollectionMethod(StrEnum):
360 + LIVE = "live"
361 + BACKFILL = "backfill"
362 +
363 +
364 +__all__ = [
365 + "AI_KEYWORDS", "CCI_FORMULA_VERSION", "CCI_WEIGHTS", "COMPANY_IMPORTANCE_TIERS", "EVENT_SUBTYPES", "FAILURE_POLICY",
366 + "METRICS_FORMULA_VERSION", "MVP_EVENT_SUBTYPES", "SURFACE_BASE_INTERVAL_S", "SURFACE_IMPORTANCE", "ChangeKind", "CollectionMethod",
367 + "CompanyStatus", "EventStatus", "EventType", "FailureClass", "FetchMode", "Metric", "OnboardingStatus", "SensorStatus", "Surface",
368 + "change_kind", "confidence_label", "tier_for_interval",
369 +]
added src/companyatlas/urls.py +209 −0
@@ -0,0 +1,209 @@
1 +"""URL canonicalisation, domain identity and surface classification heuristics (spec §11, §117, §118).
2 +
3 +`canonicalize_url` produces the stable key stored in `sensors.canonical_url`; the original URL is always kept separately.
4 +`classify_url` maps a URL (+ optional anchor text / title) to a Surface with a confidence in 0–1 — deterministic, no LLM.
5 +"""
6 +from __future__ import annotations
7 +
8 +import re
9 +from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
10 +
11 +import tldextract
12 +
13 +from companyatlas.taxonomy import Surface
14 +
15 +_extract = tldextract.TLDExtract(suffix_list_urls=(), fallback_to_snapshot=True) # offline PSL snapshot: no network at import
16 +
17 +TRACKING_PREFIXES = ("utm_", "ref", "fbclid", "gclid", "dclid", "msclkid", "mc_cid", "mc_eid", "_hs", "hsa_", "igshid", "yclid", "_ga",
18 + "_gl", "source", "campaign", "mkt_tok", "trk", "cmpid", "s_kwcid", "ef_id", "sessionid", "session_id", "phpsessid",
19 + "jsessionid", "sid", "cid", "icid", "ncid", "spm", "srsltid")
20 +SESSION_PATH_RE = re.compile(r";jsessionid=[^/?#]+", re.I)
21 +MULTI_SLASH_RE = re.compile(r"/{2,}")
22 +STATIC_EXT = (".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico", ".css", ".js", ".mjs", ".woff", ".woff2", ".ttf", ".eot", ".mp4",
23 + ".mp3", ".webm", ".mov", ".zip", ".gz", ".tar", ".dmg", ".exe", ".pkg", ".apk", ".ics", ".xlsx", ".pptx", ".docx")
24 +DOCUMENT_EXT = (".pdf",)
25 +
26 +
27 +def canonicalize_url(url: str) -> str:
28 + """Stable key: lowercase scheme/host, default ports dropped, tracking/session params removed, params sorted, fragment dropped,
29 + duplicate slashes collapsed, trailing slash removed (except root). Preserves path case (many servers are case-sensitive)."""
30 + p = urlparse(url.strip())
31 + scheme = (p.scheme or "https").lower()
32 + host = (p.hostname or "").lower().rstrip(".")
33 + port = p.port
34 + if port and not ((scheme == "https" and port == 443) or (scheme == "http" and port == 80)):
35 + host = f"{host}:{port}"
36 + path = SESSION_PATH_RE.sub("", p.path or "/")
37 + path = MULTI_SLASH_RE.sub("/", path)
38 + if len(path) > 1:
39 + path = path.rstrip("/") or "/"
40 + kept = [(k, v) for k, v in parse_qsl(p.query, keep_blank_values=False) if not k.lower().startswith(TRACKING_PREFIXES)]
41 + query = urlencode(sorted(kept), doseq=True)
42 + return urlunparse((scheme, host, path, "", query, ""))
43 +
44 +
45 +def registrable_domain(url_or_host: str) -> str:
46 + """`https://jobs.eu.stripe.com/x` → `stripe.com` (public-suffix aware, offline)."""
47 + host = url_or_host if "://" not in url_or_host else (urlparse(url_or_host).hostname or "")
48 + host = host.lower().strip().rstrip(".")
49 + ext = _extract(host)
50 + if ext.domain and ext.suffix:
51 + return f"{ext.domain}.{ext.suffix}"
52 + return host.removeprefix("www.")
53 +
54 +
55 +def host_of(url: str) -> str:
56 + return (urlparse(url).hostname or "").lower()
57 +
58 +
59 +def same_company_host(url: str, canonical_domain: str) -> bool:
60 + """Is this URL on the company's registrable domain (any subdomain)?"""
61 + return registrable_domain(url) == registrable_domain(canonical_domain)
62 +
63 +
64 +def is_static_asset(url: str) -> bool:
65 + path = urlparse(url).path.lower()
66 + return path.endswith(STATIC_EXT)
67 +
68 +
69 +def is_document(url: str) -> bool:
70 + return urlparse(url).path.lower().endswith(DOCUMENT_EXT)
71 +
72 +
73 +def absolutize(base: str, href: str) -> str | None:
74 + href = (href or "").strip()
75 + if not href or href.startswith(("#", "mailto:", "tel:", "javascript:", "data:", "sms:", "whatsapp:")):
76 + return None
77 + try:
78 + out = urljoin(base, href)
79 + except ValueError:
80 + return None
81 + if not out.startswith(("http://", "https://")):
82 + return None
83 + return out
84 +
85 +
86 +# ------------------------------------------------------------------------------------------------------ crawl-trap heuristics
87 +
88 +TRAP_PARAM_RE = re.compile(r"(^|[?&])(page|p|offset|start|sort|order|filter|facet|color|size|price|min|max|year|month|day|date|q|s|search)=", re.I)
89 +CALENDAR_RE = re.compile(r"/(19|20)\d{2}/(0?[1-9]|1[0-2])(/|$)")
90 +
91 +
92 +def looks_like_trap(url: str) -> bool:
93 + p = urlparse(url)
94 + if p.query.count("&") >= 4:
95 + return True
96 + if TRAP_PARAM_RE.search("?" + p.query) and p.query.count("=") >= 2:
97 + return True
98 + if CALENDAR_RE.search(p.path) and p.path.count("/") > 4:
99 + return True
100 + return len(url) > 400
101 +
102 +
103 +# ------------------------------------------------------------------------------------------------------ surface classification
104 +# Each rule: (surface, path regex, anchor/title regex, base confidence). Path evidence weighs more than anchor evidence; when both
105 +# match, confidence is boosted. Order matters only for tie-breaks (first rule wins on equal confidence).
106 +
107 +_R = re.compile
108 +RULES: list[tuple[Surface, re.Pattern[str] | None, re.Pattern[str] | None, float]] = [
109 + (Surface.PRICING, _R(r"/(pricing|plans|plans-and-pricing|pricing-plans|tarifs|preise|precios|prezzi|价格)(/|$)", re.I), _R(r"^(pricing|plans( & pricing| and pricing)?|see pricing|view pricing|tarifs|preise|precios)$", re.I), 0.95),
110 + (Surface.JOBS_BOARD, _R(r"(boards\.greenhouse\.io|job-boards\.greenhouse\.io|jobs\.lever\.co|jobs\.ashbyhq\.com|jobs\.smartrecruiters\.com|careers\.smartrecruiters\.com|myworkdayjobs\.com|apply\.workable\.com|jobs\.jobvite\.com|recruiting\.paylocity\.com|bamboohr\.com/careers|breezy\.hr|recruitee\.com|personio\.(de|com)|teamtailor\.com|icims\.com|taleo\.net|successfactors\.com|eightfold\.ai|phenom\.com|wd\d\.myworkdaysite\.com|careers-page\.com|homerun\.co|pinpointhq\.com|rippling-ats\.com|jobs\.gem\.com)", re.I), None, 0.97),
111 + (Surface.CAREERS, _R(r"/(careers?|jobs?|join(-us|us)?|work-with-us|work-for-us|working-at|open-positions|opportunities|vacancies|recruit(ing|ment)?|employment|karriere|stellen|emplois?|carri[eè]res?|empleo|trabaja-con-nosotros|lavora-con-noi|saiyou|採用)(/|$)", re.I), _R(r"^(careers?|jobs?|join (us|the team|our team)|work (with|for|at) us|open (roles|positions)|we'?re hiring|hiring|opportunities|vacancies|karriere|emplois?|carri[eè]res?|empleo)$", re.I), 0.93),
112 + (Surface.NEWSROOM, _R(r"/(news(room)?|press(-releases?|room|-center|-centre)?|media(-center|-centre|-room)?|announcements|releases|actualit[eé]s|presse|noticias|prensa|ニュース)(/|$)", re.I), _R(r"^(news(room)?|press( releases?| room| center)?|media( center| room)?|announcements|in the news|actualit[eé]s|presse|noticias)$", re.I), 0.9),
113 + (Surface.INVESTOR_RELATIONS, _R(r"/(investors?|investor-relations|ir|shareholders?|financials?|earnings|sec-filings|annual-reports?|investisseurs|investoren|inversores)(/|$)|^https?://(ir|investors?|investor)\.", re.I), _R(r"^(investors?|investor relations|shareholders?|financials?|ir|investisseurs|investoren)$", re.I), 0.92),
114 + (Surface.LEADERSHIP, _R(r"/(leadership|management(-team)?|executive(s|-team|-leadership)?|our-team|the-team|team|board(-of-directors)?|directors|founders|people|who-we-are|direction|equipe|équipe|equipo|vorstand|management-board|governance/(board|leadership))(/|$)", re.I), _R(r"^(leadership( team)?|management( team)?|executive (team|leadership)|our (team|leadership|people)|meet the team|board of directors|founders|team|direction|équipe)$", re.I), 0.85),
115 + (Surface.LOCATIONS, _R(r"/(locations?|offices?|our-offices|where-we-are|global-presence|worldwide|stores?|store-locator|find-a-store|branches|dealers?|showrooms?|sites|standorte|bureaux|ubicaciones|拠点)(/|$)", re.I), _R(r"^(locations?|our (locations|offices)|offices?|where we are|global presence|find a store|store locator|branches|standorte|bureaux)$", re.I), 0.88),
116 + (Surface.CHANGELOG, _R(r"/(changelog|change-log|changes|release-notes|releases|whats-new|what's-new|updates|product-updates)(/|$)|^https?://(changelog|releases|updates)\.", re.I), _R(r"^(changelog|release notes|what'?s new|product updates|updates|releases)$", re.I), 0.9),
117 + (Surface.API, _R(r"/(api|apis|api-reference|api-docs|reference)(/|$)|^https?://api-?docs?\.", re.I), _R(r"^(api( reference| docs| documentation)?|apis|rest api|graphql)$", re.I), 0.82),
118 + (Surface.DEVELOPER, _R(r"/(developers?|dev|devs|developer-portal|platform|sdks?|integrations?|build)(/|$)|^https?://(developers?|dev|build)\.", re.I), _R(r"^(developers?|developer (portal|center|hub)|for developers|sdks?|integrations?|build)$", re.I), 0.8),
119 + (Surface.DOCS, _R(r"/(docs|documentation|help-center|help|guides?|manuals?|knowledge-base|kb|learn|tutorials?)(/|$)|^https?://(docs|documentation|help|support|kb|learn|guides?)\.", re.I), _R(r"^(docs|documentation|guides?|help center|knowledge base|manuals?|tutorials?|learn)$", re.I), 0.8),
120 + (Surface.STATUS, _R(r"^https?://(status|health|uptime|trust)\.|/(status|system-status|service-status)(/|$)", re.I), _R(r"^(status|system status|service status|status page)$", re.I), 0.85),
121 + (Surface.BLOG, _R(r"/(blog|blogs|insights|stories|articles|journal|magazine|perspectives|thinking|ideas|posts|editorial|le-blog)(/|$)|^https?://(blog|insights|stories|medium)\.", re.I), _R(r"^(blog|insights|stories|articles|journal|perspectives|ideas|our blog)$", re.I), 0.82),
122 + (Surface.RESEARCH, _R(r"/(research|labs?|science|publications|papers|whitepapers?|reports|studies)(/|$)|^https?://(research|labs?|science)\.", re.I), _R(r"^(research|labs?|publications|whitepapers?|reports|science)$", re.I), 0.78),
123 + (Surface.PRODUCTS, _R(r"/(products?|product-catalog|catalog(ue)?|shop|store|collections|portfolio|offerings|our-products|produits|produkte|productos|prodotti|製品)(/|$)|^https?://(shop|store|products?)\.", re.I), _R(r"^(products?|our products|product catalog|catalog(ue)?|shop|store|portfolio|offerings|produits|produkte)$", re.I), 0.82),
124 + (Surface.SERVICES, _R(r"/(services?|what-we-do|capabilities|expertise|offerings|our-services|prestations|leistungen|servicios|servizi)(/|$)", re.I), _R(r"^(services?|our services|what we do|capabilities|expertise|leistungen|prestations)$", re.I), 0.78),
125 + (Surface.SOLUTIONS, _R(r"/(solutions?|use-cases|platform|technology|technologies|features)(/|$)", re.I), _R(r"^(solutions?|use cases|platform|features)$", re.I), 0.7),
126 + (Surface.INDUSTRIES, _R(r"/(industries|industry|sectors?|markets?|verticals?|who-we-serve)(/|$)", re.I), _R(r"^(industries|sectors?|markets?|who we serve|verticals?)$", re.I), 0.7),
127 + (Surface.CUSTOMERS, _R(r"/(customers?|customer-stories|case-studies|success-stories|clients?|references|testimonials|showcase|wall-of-love)(/|$)", re.I), _R(r"^(customers?|customer stories|case studies|success stories|clients?|our customers|testimonials)$", re.I), 0.78),
128 + (Surface.PARTNERS, _R(r"/(partners?|partnerships?|partner-program|alliances|ecosystem|marketplace|resellers?|channel)(/|$)|^https?://(partners?|marketplace)\.", re.I), _R(r"^(partners?|partnerships?|partner program|alliances|ecosystem|become a partner|marketplace)$", re.I), 0.78),
129 + (Surface.SECURITY, _R(r"/(security|trust(-center|-portal)?|compliance|privacy-and-security)(/|$)|^https?://(security|trust)\.", re.I), _R(r"^(security|trust( center)?|compliance|trust & safety)$", re.I), 0.8),
130 + (Surface.SUSTAINABILITY, _R(r"/(sustainability|esg|responsibility|corporate-responsibility|csr|impact|environment|climate|social-impact|citizenship|purpose|d[eé]veloppement-durable|nachhaltigkeit|sostenibilidad)(/|$)|^https?://(sustainability|esg|impact)\.", re.I), _R(r"^(sustainability|esg|(corporate |social )?responsibility|csr|impact|environment|climate|our impact|purpose)$", re.I), 0.82),
131 + (Surface.LEGAL_PRIVACY, _R(r"/(privacy(-policy|-notice|-statement)?|privacypolicy|datenschutz|confidentialit[eé]|privacidad|cookie-policy|cookies)(/|$)", re.I), _R(r"^(privacy( policy| notice| statement)?|datenschutz(erkl[aä]rung)?|politique de confidentialit[eé]|cookie policy)$", re.I), 0.9),
132 + (Surface.LEGAL_TERMS, _R(r"/(terms(-of-(service|use|sale|business))?|tos|legal|legal-notice|terms-and-conditions|conditions|eula|agb|mentions-l[eé]gales|cgu|cgv|aviso-legal|impressum|acceptable-use(-policy)?|aup)(/|$)", re.I), _R(r"^(terms( of (service|use|sale))?|terms (and|&) conditions|legal( notice)?|agb|mentions l[eé]gales|impressum|eula|acceptable use policy)$", re.I), 0.88),
133 + (Surface.SUPPORT, _R(r"/(support|customer-support|customer-service|contact-support|faq|faqs|community|forum)(/|$)|^https?://(community|forum|faq)\.", re.I), _R(r"^(support|customer (support|service|care)|faqs?|community|forum|help & support)$", re.I), 0.72),
134 + (Surface.CONTACT, _R(r"/(contact(-us|us|-sales)?|get-in-touch|reach-us|kontakt|contactez-nous|contacto|お問い合わせ)(/|$)", re.I), _R(r"^(contact( us| sales)?|get in touch|talk to (us|sales)|kontakt|contactez-nous|contacto)$", re.I), 0.85),
135 + (Surface.ABOUT, _R(r"/(about(-us|us|-company|-[a-z0-9-]+)?|company|our-company|our-story|who-we-are|mission|history|overview|corporate|a-propos|qui-sommes-nous|[uü]ber-uns|unternehmen|sobre-nosotros|chi-siamo|会社概要|企業情報)(/|$)", re.I), _R(r"^(about( us| the company)?|company|our (company|story|mission|history)|who we are|mission|history|overview|a propos|qui sommes-nous|[uü]ber uns|unternehmen)$", re.I), 0.85),
136 + (Surface.FEED, _R(r"/(feed|rss|atom|feeds)(\.xml|/|$)|\.(rss|atom)$|/rss\.xml|/feed\.xml|/atom\.xml", re.I), _R(r"^(rss|atom|feed|subscribe via rss)$", re.I), 0.9),
137 + (Surface.SITEMAP, _R(r"/sitemap[^/]*\.xml(\.gz)?$|/sitemap_index\.xml$|/sitemaps?/", re.I), None, 0.95),
138 +]
139 +
140 +# Anchors that are almost always navigation noise, never surfaces.
141 +_NOISE_ANCHOR = re.compile(r"^(home|back|next|previous|prev|more|read more|learn more|skip to content|menu|close|login|log in|sign in|sign up|"
142 + r"register|search|cart|account|download|share|print|top|en|fr|de|es|it|ja|zh|português|english|français|deutsch)$", re.I)
143 +
144 +
145 +def classify_url(url: str, *, anchor: str | None = None, title: str | None = None, canonical_domain: str | None = None) -> tuple[Surface, float]:
146 + """Return (surface, confidence). Homepage detection needs `canonical_domain`. Unknown → (OTHER, 0.1)."""
147 + p = urlparse(url)
148 + path = p.path or "/"
149 + text = " ".join(x.strip() for x in (anchor, title) if x).strip()
150 + text_norm = re.sub(r"\s+", " ", text).strip(" -–|·»›>").lower()
151 + if canonical_domain and path in ("/", "") and not p.query and registrable_domain(url) == registrable_domain(canonical_domain) \
152 + and host_of(url) in (canonical_domain.lower(), "www." + canonical_domain.lower(), canonical_domain.lower().removeprefix("www.")):
153 + return Surface.HOMEPAGE, 0.99
154 + if is_static_asset(url):
155 + return Surface.OTHER, 0.0
156 + best: tuple[Surface, float] = (Surface.OTHER, 0.1)
157 + full = f"{p.scheme}://{p.netloc}{path}"
158 + depth = max(0, path.strip("/").count("/"))
159 + for surface, path_re, anchor_re, base in RULES:
160 + score = 0.0
161 + path_hit = bool(path_re and path_re.search(full))
162 + anchor_hit = bool(anchor_re and text_norm and anchor_re.search(text_norm))
163 + if path_hit:
164 + score = base
165 + if depth >= 2:
166 + score -= 0.12 * (depth - 1) # /news/2024/foo is an article, not the newsroom
167 + if anchor_hit:
168 + score = max(score, base - 0.2) + (0.05 if path_hit else 0.0)
169 + if is_document(url):
170 + score -= 0.3
171 + if p.query and surface not in (Surface.JOBS_BOARD, Surface.SITEMAP):
172 + score -= 0.1
173 + if score > best[1]:
174 + best = (surface, round(min(0.99, max(0.0, score)), 3))
175 + if best[0] is Surface.OTHER and text_norm and _NOISE_ANCHOR.match(text_norm):
176 + return Surface.OTHER, 0.0
177 + return best
178 +
179 +
180 +ATS_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
181 + ("greenhouse", re.compile(r"https?://(?:boards|job-boards)\.greenhouse\.io/([a-z0-9_-]+)", re.I)),
182 + ("greenhouse", re.compile(r"https?://boards-api\.greenhouse\.io/v1/boards/([a-z0-9_-]+)", re.I)),
183 + ("lever", re.compile(r"https?://jobs\.(?:eu\.)?lever\.co/([a-z0-9_-]+)", re.I)),
184 + ("ashby", re.compile(r"https?://jobs\.ashbyhq\.com/([a-z0-9_.-]+)", re.I)),
185 + ("smartrecruiters", re.compile(r"https?://(?:careers|jobs)\.smartrecruiters\.com/([a-z0-9_-]+)", re.I)),
186 + ("workable", re.compile(r"https?://apply\.workable\.com/([a-z0-9_-]+)", re.I)),
187 + ("workday", re.compile(r"https?://([a-z0-9-]+)\.(wd\d+)\.myworkdayjobs\.com/(?:[a-z]{2}-[A-Z]{2}/)?([A-Za-z0-9_-]+)", re.I)),
188 + ("recruitee", re.compile(r"https?://([a-z0-9-]+)\.recruitee\.com", re.I)),
189 + ("personio", re.compile(r"https?://([a-z0-9-]+)\.jobs\.personio\.(?:de|com)", re.I)),
190 + ("teamtailor", re.compile(r"https?://([a-z0-9-]+)\.teamtailor\.com", re.I)),
191 + ("bamboohr", re.compile(r"https?://([a-z0-9-]+)\.bamboohr\.com/careers", re.I)),
192 + ("breezy", re.compile(r"https?://([a-z0-9-]+)\.breezy\.hr", re.I)),
193 + ("jobvite", re.compile(r"https?://jobs\.jobvite\.com/([a-z0-9_-]+)", re.I)),
194 + ("pinpoint", re.compile(r"https?://([a-z0-9-]+)\.pinpointhq\.com", re.I)),
195 + ("rippling", re.compile(r"https?://ats\.rippling\.com/([a-z0-9_-]+)", re.I)),
196 +]
197 +
198 +
199 +def detect_ats(url: str) -> tuple[str, str] | None:
200 + """(vendor, board token) when the URL is a public ATS board we have a structured connector for."""
201 + for vendor, pat in ATS_PATTERNS:
202 + m = pat.search(url)
203 + if m:
204 + return vendor, m.group(1)
205 + return None
206 +
207 +
208 +__all__ = ["ATS_PATTERNS", "RULES", "absolutize", "canonicalize_url", "classify_url", "detect_ats", "host_of", "is_document",
209 + "is_static_asset", "looks_like_trap", "registrable_domain", "same_company_host"]
210