Query a public OpenDataSoft (ODS) procurement portal for currently-open SERVICES-type avis relevant to a mobility-planning consultancy, and classify each one as relevant, travaux (works), or excluded (out-of-domain).
Built with Bun + TypeScript. Hits the portal's REST API directly — no browser, no scraping. Optional semantic classification through OpenRouter (any model, with a fallback chain and price-sorted providers).
Two ways to use it:
- Local CLI (
bun run src/index.ts) — writes timestamped markdown reports, as before. - Deployed service (Clever Cloud) — daily cron run stored in PostgreSQL, a French web dashboard with passwordless email-code login (
src/server.ts+src/auth.ts), and a daily email digest via MailPace (src/run.ts). See Deployment.
- Builds a single ODS v2.1
where=clause combining a French keyword OR (mobilité OR vélo OR stationnement OR "schéma directeur" …) with a date filter (datelimitereponse >= todayordatefindiffusion >= todaywhen no deadline is set). - Pages through every matching record (limit 100/req, sorted by deadline ASC), extracts structured fields (id, objet, acheteur, département, date limite, type d'avis, procédure) and caches the lot to
.cache/scrape.json. - Reads a second source, the achatpublic.com buyer-profile platform (where many sub-threshold MAPA studies are published without ever reaching BOAMP): every open SERVICES and FOURNITURES consultation is listed (~120 form POSTs, 10 cards each, ~2–3 minutes) and matched locally against per-family keyword lists (seeded from
src/defaults.ts, mirroring the consultancy's manual watch procedure, then edited on/configuration): mobilité (services, classified like BOAMP avis), kiomda (fournitures — counters/sensors for bike and pedestrian counting) and inondations (services — AMC / PAPI / GEMAPI flood-prevention studies). The two non-mobility families are classified by their own rule insrc/familles.ts, never by the LLM. Only retained consultations have their public detail page fetched (description, CPV, opening date). The source is non-blocking: if the site is down, the run keeps the BOAMP results and records a warning.ACHATPUBLIC=0disables it. - Reads a third source, the AFD notices on dgMarket (afd.dgmarket.com, category « Transports », open notices only): list, category filter and full notice text are public (only document downloads need a subscription); the first request goes through an anonymous auto-login redirect chain that sets a session cookie, handled by a small per-domain cookie jar in
src/afd.ts. Notices are abroad and often in English; they go through the normal mobility classifiers. About 10 requests per run.AFD=0disables it. - Reads the atexo MPE platforms listed in
MPE_SITES(src/mpe.ts): Maximilien (marches.maximilien.fr, Île-de-France, ~450 open consultations) and AMPA (demat-ampa.fr, Marchés publics d'Aquitaine, ~300). Same software, same HTML: the full list of open consultations (20 per page, each next page a PRADO postback replaying the page state with the session cookie) is filtered locally on SERVICES + the client's keyword list (seeded fromMPE_SEARCHESinsrc/defaults.ts, edited on/configuration; families mobilité and inondations); detail pages (public) are fetched only for retained consultations (CPV, type d'annonce).MAXIMILIEN=0/AMPA=0disable a site; adding another MPE site (marches-publics.gouv.fr is one) is one entry inMPE_SITES. - Reads Marchés Online (marchesonline.com, Le Moniteur's aggregator, "en accès libre"): no account for what we use — the public keyword pages (
/appels-offres/top-recherches/<mot>, 20 per page) are walked for every keyword of the client's five search profiles (seeded fromMARCHESONLINE_SEARCHESinsrc/defaults.ts, edited on/configuration: mobilité, kiomda counters, inondations; ~250 pages per run) and filtered locally on the card's activity label (services / études vs fournitures) and on the keywords at start of word (the site's search is lexical). Public detail pages are read only for retained notices the title alone does not already exclude (descriptif, CPV). The site rate-limits (Cloudflare), so requests are paced and wide keywords are capped to their newest pages (~3–4 minutes per run once known notices are reused; ~15 minutes the first time).MARCHESONLINE=0disables it. - Classifies each avis into
relevant/travaux/excluded. Writes two timestamped markdown reports per run (avis-en-cours-YYYY-MM-DD-HH-MM-SS.mdand the travaux equivalent). Excluded avis are logged to stderr with their reason.
Deployed, the same pipeline runs daily into PostgreSQL behind a French dashboard (passwordless email-code login). Each avis has a detail page with a live comment thread and a collaborative status (« à évaluer », « répondu », « gagné »… — list editable by admins on /admin, every change kept as history in the thread). The dashboard's primary click opens the internal detail page (the official notice stays one click away), lets you change an avis' status from the list, filters by text, status, source (with counts) and family, sorts by deadline or recency, marks what is new since your previous visit, applies filters as you change them, and can hide rejected avis; the same appel d'offres published on several platforms (or republished / rectified on BOAMP) is grouped into one row listing every publication, with one status and one comment thread; /configuration lists the sources (what each reads, active/disabled/failed, current avis per source) and, while an update runs, shows its milestones (one step per source, then classification, then the email) with counters and a progress bar, updated in place through the Skip engine (the job writes runs.progress, the adapter watches runs, the page refetches a server-rendered fragment on each event); the dashboard banner carries a one-line summary the same way. /configuration (admins, or users delegated the right from /admin) edits the pipeline live: search keywords (one list per search engine: BOAMP query, achatpublic, the atexo MPE sites, Marchés Online, with a family per term), « toujours garder / toujours exclure » rules (each one shows how many current avis it touches, and a candidate rule can be tried on the current avis before being added; keywords and rules can be paused instead of deleted and carry an optional note; a rule can be limited to one source, to the title only, or to the whole word), classifier mode, LLM model chain (with live OpenRouter prices), digest window, départements, a switch per secondary source (the server-side <SOURCE>=0 variables still win), the reading limits (BOAMP pages, Marchés Online pages per keyword) and the OpenRouter price cap, plus a « Relancer maintenant » button; every change is listed in a « Journal des modifications » card with its author. Threads update in real time through the Skip reactive engine running in-process (WASM) on Postgres LISTEN/NOTIFY; set LIVE_COMMENTS=0 to disable it (threads then show as unavailable). /sante reports live (engine up) and livePg (its Postgres connection + number of watched tables).
bun install
cp .env.example .env # then edit — see belowThat's it — no browser binaries needed.
Everything goes in .env (auto-loaded by Bun):
| Variable | Required | Default | Notes |
|---|---|---|---|
PORTAL_API_URL |
yes | — | ODS v2.1 datasets base URL, no trailing slash. e.g. https://<portal>/api/explore/v2.1/catalog/datasets. |
PORTAL_DATASET |
yes | — | Dataset id. Run will fail at startup if either is unset. |
OPENROUTER_API_KEY |
no | — | Enables semantic (LLM) and hybrid classifier modes. |
LLM_MODELS |
no | mistralai/mistral-nemo,… (see src/defaults.ts) |
Comma-separated OpenRouter model ids, fallback order (max 5). Overridden by the /configuration setting. |
LLM_MAX_PRICE_PER_M |
no | 1 |
Price cap, USD per million tokens, applied to every model of the chain. Overridden by the /configuration setting. |
OPENROUTER_BASE_URL |
no | https://openrouter.ai/api/v1 |
Override for tests / a proxy. |
CLASSIFIER |
no | hybrid if key set, else regex |
Override: regex | llm | hybrid. |
MAX_PAGES |
no | 100 |
Pagination cap (100 items/page). Stops earlier when results are exhausted. Overridden by the /configuration setting (server only). |
USE_CACHE |
no | unset | 1 skips the API and re-runs classification on .cache/scrape.json. |
# fresh fetch + classify + write reports
bun run src/index.ts
# pass a custom OR-query (overrides the default keyword list)
bun run src/index.ts "mobilité OR cyclable OR stationnement"
# re-classify using the cached scrape (fast, ~seconds)
USE_CACHE=1 bun run src/index.ts
# force a specific classifier
CLASSIFIER=regex USE_CACHE=1 bun run src/index.tsAll sources are read in parallel, so a run lasts as long as its slowest source (Marchés Online, a few minutes). A full run hits the portal API ~30 times (the first page alone, then four pages at a time with retry; ~3 000 records over a 14-keyword query), re-reads a notice's detail page only when its card changed (URL/version, title or deadline — the rest is reused from the previous run), and only asks the LLM about avis it has not already classified with the same text, prompt and model chain (the verdict is stored with a content key in announcements.llm_key; LLM_MEMO=0 forces a full reclassification). The first run sends ~2 000 avis to the LLM, about 2.4 M prompt tokens — roughly 5 cents of OpenRouter credit with the default chain (mistral-nemo, 39/40 on bun run eval and the cheapest JSON-capable model in the catalog; the previous llama-3.3-70b head cost ~27 cents per run) when the hybrid classifier is on (the exact cost, token counts and which models answered are recorded on each run and shown on /configuration and in the digest footer). Each call carries the whole model chain: OpenRouter switches to the next model on outage, rate limit, moderation or context overflow, and the client retries timeouts / 5xx and re-asks the rest of the chain once when a model returns unusable JSON. A dead key, an empty credit or five consecutive chain failures open a circuit breaker: the rest of the run is classified by regex, the run is flagged with a warning, ALERT_RECIPIENT gets an email, and the digest is held until the next healthy run.
bun run typecheck && bun run lint && bun test && bun run build # unit tests only
TEST_DATABASE_URL=postgresql://<user>@localhost:5432/avis_test bun test # + integration tests — what CI runsUnit tests are pure logic (no DB, no network). With TEST_DATABASE_URL pointing at a throwaway local Postgres (createdb avis_test) the three integration files also run, and CI runs them against a postgres:16 service container before every deploy:
db.integration— the real schema: the jsonb shape stored for run statistics and its migration, the run advisory lock seen from a second connection, orphaned « running » rows closed at start, dashboard filters by source and family, counts per source.server.integration— spawns the realbun src/server.tson a test port, logs in with the dev login code printed on stderr, and checks/sante, the dashboard (badges,?source=,?famille=),/configuration(sources card, LLM statistics, an orphaned run being closed) and a notice page.live.integration— boots the Skip engine, subscribes to a thread over SSE, writes comments and status changes, kills the engine's Postgres connection withpg_terminate_backendand checks the thread keeps updating. Run it before touching the Skip packages, their patch, orsrc/live.ts.
regex— deterministic rules insrc/classify.ts. Free, instant, reproducible. Good at hard rules (télécom, contrôle de travaux, assurances…). Bad at nuance.llm— one OpenRouter call per avis with a few-shot French prompt (src/classify-llm.ts, prompt tuned on mistral-small). Good at semantic distinctions (e.g. "restructuration des services" vs. physical restructuring). Cheap but adds some non-determinism. Occasionally misses hard rules.hybrid(default whenOPENROUTER_API_KEYis set) — regex first. If the regex confidently labels an avisexcludedortravaux, that stands. Otherwise the LLM makes the final call. Best of both: hard rules are enforced deterministically, borderline cases get semantic judgement. Only the items that pass regex go to the LLM.
The scope is hard-coded for mobility-planning consultancy work. Three places to edit:
- Keyword list —
src/defaults.ts: theKEYWORDSarray gets OR'd into a singlewhere=clause. - Regex rules —
src/classify.ts: hard exclusions (télécom, contrôle de travaux, operator procurement, etc.) and travaux patterns (MOE, rénovation, fouilles, reconnaissance géotechnique). - LLM prompt —
src/classify-llm.ts: the system prompt describing in-scope vs. out-of-scope work, plus a few-shot of labelled examples.
All three files are intentionally small and self-contained. Fork and tweak.
src/
defaults.ts KEYWORDS list + default search params (type_marche=SERVICES, deadline>=today)
params.ts SearchParams type + ODS v2.1 URL builder (where= with search() + date filter)
scraper.ts Paginated fetch loop + ODS record → Announcement mapping (BOAMP)
achatpublic.ts Second source: achatpublic.com form scraper (session cookie, 0-based pages, card + fiche parsers)
afd.ts Third source: AFD notices on dgMarket, « Transports » category (cookie jar, redirect chain, list + notice parsers)
progress.ts Run progress: ProgressTracker (steps, throttled writes), summary/percent helpers
sources.ts Registry of sources (label, URL, description, env kill switch) for the pipeline, dashboard filter and configuration page
mpe.ts atexo MPE platforms (Maximilien, AMPA…): PRADO postback pagination, row + fiche parsers, MPE_SITES registry
marchesonline.ts Marchés Online (Le Moniteur): public keyword pages walked per keyword, card + fiche parsers
mois.ts Month name (FR/EN, short/long) → « 01 »…« 12 », shared by the sources with dates in words
familles.ts Watch families (mobilité / kiomda / inondations): keyword matcher + per-family classification rule
departements.ts Département name → INSEE code (achatpublic only exposes the label)
classify.ts Regex classifier (normalise, hard exclusions, travaux patterns)
classify-llm.ts LLM classifier (system prompt + few-shot, per-run context: chain, stats, breaker)
llm.ts OpenRouter client: fallback chain, price-sorted providers, retries, stats, circuit breaker
http.ts postWithRetry shared by llm.ts and email.ts (retries statuses, timeouts and network errors)
openrouter-catalog.ts Public model catalog (prices, JSON support) + key credit, for /configuration
eval-llm.ts `bun run eval`: labelled set × candidate models → accuracy, cost, latency
classify-hybrid.ts Regex-then-LLM composition
pipeline.ts Shared core: fetch → dedupe → classify (used by CLI and server)
report.ts Markdown renderer (sorted by deadline)
index.ts Local CLI: pipeline → write markdown reports
rules.ts « toujours garder / exclure » substring rules applied before the classifier
db.ts PostgreSQL (Bun.sql): schema, run tracking, upserts, dashboard queries
auth.ts Passwordless auth: email OTP codes, opaque session cookies, users, rate limits
run.ts Daily job: pipeline → upsert DB → email digest (alerts on failure)
email.ts MailPace sender + French digest/alert/login-code HTML
live.ts Live threads: Skip reactive graph over Postgres LISTEN/NOTIFY + SSE relay
router.ts Tiny path matcher for routes with :param segments
server.ts Dashboard + login + admin/configuration pages (Bun.serve) + /sante health endpoint
*.test.ts Unit tests; live.integration.test.ts needs TEST_DATABASE_URL (see Checks)
patches/ Local patch to @skip-adapter/postgres (reconnects after a dropped LISTEN connection)
clevercloud/
cron.json Daily schedule (04:30 UTC)
daily-run.sh Cron entry: env-loading wrapper around src/run.ts
.cache/ Gitignored scrape cache (raw API output)
Per-run outputs (avis-en-cours-*.md, avis-travaux-*.md) are gitignored.
One Node.js app (Bun is auto-detected from bun.lock; scripts.start serves the dashboard on port 8080) plus a PostgreSQL add-on. The Clever Cloud filesystem is ephemeral — PostgreSQL is the source of truth.
- Create a Node.js application (1 instance, smallest size, disable autoscaling) and a PostgreSQL add-on (DEV plan), and link them — this injects
POSTGRESQL_ADDON_URI. - Email first: MailPace token with a DKIM-verified sending domain (the add-on injects no env var — copy the token manually). Login is by emailed code, so a deployed app without working email is unreachable. Smoke-test the token with a curl to
https://app.mailpace.com/api/v1/sendbefore deploying; a 403 means the domain is not verified. - Set the env vars from
.env.example: portal vars,OPENROUTER_API_KEY(set it before the push: the cron and the « Relancer maintenant » button inherit the app env),MAILPACE_API_TOKEN,EMAIL_FROM,OTP_PEPPER,ADMIN_EMAILS(your email — seeded as admin at startup),ALERT_RECIPIENT,DASHBOARD_URL. clever deploy. Tables are created automatically at startup;ADMIN_EMAILSaccounts are upserted as admins.- Log in at
/connexionwith your email code, then add teammates from/admin. - The cron (
clevercloud/cron.json) fires every day at 04:30 UTC: scrape → classify → store → digest email. On failure,ALERT_RECIPIENTgets an alert email and the dashboard banner turns red.
The daily digest goes to users who ticked the opt-in on /profil; it is sent every day even when empty — if it stops arriving, something is wrong. It is skipped (with a log line) when MAILPACE_API_TOKEN is unset or nobody opted in.
No license specified yet. Add one before publishing if you want others to reuse the code.