SPB Git forge

spb/pdb-api

Public
1commits 1branches 0releases
436.0 KBsize
maindefault branch
2 h agolast push
JavaScript 58.4% Python 26.8% CSS 7.5% Objective-C 3% R 2.6% HTML 1.7%
59.2 KB · 338 lines javascript
Raw Blame History
1/* PDB — page API & playground (démarrage rapide, playground, recettes, référence, SDK). */2window.PDBPlayground = (() => {3  const LANGS = [["curl", "curl"], ["py", "Python"], ["js", "JavaScript"], ["r", "R"]];4  const KEY_PH = "VOTRE_CLE";5  const key = () => localStorage.getItem("pdb_key") || "";6  const lang = () => localStorage.getItem("pdb_lang") || "py";7  const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));8  const qs = (o) => Object.entries(o).filter(([, v]) => v !== "" && v != null).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");9  const fmtN = (n) => n == null ? "—" : Number(n).toLocaleString("fr-CA");1011  /* ------------------------------------------------------------------ catalogue des routes */12  const P = (name, desc, opts = {}) => ({ name, desc, ...opts });13  const PROJECT_FILTERS = [14    P("q", "texte libre : nom, description, entreprise, ticker"), P("type", "project_type, liste séparée par des virgules", { enum: "types" }),15    P("sector", "secteur GICS (contient)", { enum: "sectors" }), P("status", "planned | in_progress | completed | mentioned (liste possible)"),16    P("ticker", "ticker exact"), P("cik", "CIK SEC (10 chiffres ou moins)"), P("location", "localisation canonique (contient)"),17    P("tech", "technologie (contient)"), P("partner", "partenaire cité dans une mention (contient)"),18    P("min_amount", "montant divulgué minimal, US$", { type: "number" }), P("max_amount", "montant maximal, US$", { type: "number" }),19    P("year_from", "dernière observation ≥ année", { type: "number" }), P("year_to", "première observation ≤ année", { type: "number" }),20    P("min_confidence", "confiance moyenne minimale (0–1)", { type: "number" }), P("has_amount", "true : seulement les projets avec montant"),21  ];22  const PAGE = [P("sort", "clé de tri"), P("order", "asc | desc"), P("limit", "≤ 500 (défaut 50)", { type: "number" }), P("offset", "décalage", { type: "number" })];23  const ENDPOINTS = [24    { g: "Découverte", m: "GET", p: "/v1/health", key: false, d: "État du service : compteur de projets, état du modèle sémantique. Sans clé.", params: [], ex: [{ l: "État", v: {} }] },25    { g: "Découverte", m: "GET", p: "/v1/stats", d: "Vue d'ensemble : compteurs, répartitions par type, secteur, statut, formulaire, année, thèmes émergents, top entreprises/technologies/lieux. C'est la source du tableau de bord.", params: [], ex: [{ l: "Tout", v: {} }] },26    { g: "Découverte", m: "GET", p: "/v1/taxonomy", d: "Les 21 types de projets (clé, libellé, nombre de projets, capital divulgué).", params: [], ex: [{ l: "Liste", v: {} }] },27    { g: "Découverte", m: "GET", p: "/v1/sectors", d: "Les 11 secteurs GICS avec nombre de projets, d'entreprises et capital.", params: [], ex: [{ l: "Liste", v: {} }] },28    { g: "Découverte", m: "GET", p: "/v1/technologies", d: "Technologies citées dans les projets, par fréquence.", params: [P("q", "filtre texte"), P("limit", "≤ 500", { type: "number" })], ex: [{ l: "Top 25", v: { limit: 25 } }, { l: "Batteries", v: { q: "batter" } }] },29    { g: "Découverte", m: "GET", p: "/v1/locations", d: "Localisations canoniques, par fréquence, avec capital.", params: [P("q", "filtre texte"), P("limit", "≤ 500", { type: "number" })], ex: [{ l: "Top 25", v: { limit: 25 } }, { l: "Texas", v: { q: "texas" } }] },30    { g: "Découverte", m: "GET", p: "/v1/partners", d: "Partenaires cités dans les mentions (coentreprises, clients, fournisseurs nommés).", params: [P("q", "filtre texte"), P("limit", "≤ 500", { type: "number" })], ex: [{ l: "Top 25", v: { limit: 25 } }, { l: "Microsoft", v: { q: "microsoft" } }] },31    { g: "Projets", m: "GET", p: "/v1/projects", d: "Recherche de projets avec filtres combinables. Réponse paginée {total, limit, offset, items}. Tri : amount, mentions, filings, first_seen, last_seen, confidence, name, ticker, type.", params: [...PROJECT_FILTERS, ...PAGE], ex: [32      { l: "Centres de données > 500 M$", v: { type: "data_center", min_amount: "5e8", sort: "amount", limit: 10 } },33      { l: "IA depuis 2024", v: { type: "ai_initiative", year_from: 2024, sort: "last_seen", limit: 20 } },34      { l: "Usines au Texas", v: { type: "plant_construction,manufacturing_expansion", location: "texas", limit: 20 } },35      { l: "Batteries, terminées", v: { tech: "battery", status: "completed", limit: 20 } },36      { l: "Tesla", v: { ticker: "TSLA", sort: "mentions", limit: 20 } },37      { l: "Partenaire NVIDIA", v: { partner: "nvidia", limit: 20 } },38    ] },39    { g: "Projets", m: "GET", p: "/v1/projects/export.csv", d: "Export CSV des projets filtrés (mêmes filtres que /v1/projects, ≤ 50 000 lignes, listes jointes par |).", params: PROJECT_FILTERS, raw: true, ex: [{ l: "Tous les centres de données", v: { type: "data_center" } }, { l: "Services publics avec montant", v: { sector: "Utilities", has_amount: "true" } }] },40    { g: "Projets", m: "GET", p: "/v1/projects/{project_id}", d: "Fiche complète d'un projet : attributs, chronologie (une ligne par mention datée), mentions détaillées (lieux, technologies, partenaires, bénéfices, risques), voisins du graphe et projets similaires par embeddings.", params: [P("project_id", "identifiant MD5 (voir /v1/projects)", { path: true })], ex: [{ l: "Tesla — Energy Storage Products", v: { project_id: "TSLA_ENERGY" } }, { l: "WEC — Data Center Investments", v: { project_id: "46fe2966ba0cbf6228807faca0645b54" } }] },41    { g: "Projets", m: "GET", p: "/v1/projects/{project_id}/similar", d: "Projets sémantiquement proches (similarité cosinus des vecteurs MiniLM 384-d stockés).", params: [P("project_id", "identifiant", { path: true }), P("k", "≤ 50", { type: "number" })], ex: [{ l: "10 voisins", v: { project_id: "46fe2966ba0cbf6228807faca0645b54", k: 10 } }] },42    { g: "Mentions & sections", m: "GET", p: "/v1/mentions", d: "Mentions individuelles (une par projet et par section de filing) : l'unité d'extraction brute, avec formulaire, date, section, montant, confiance. Chaque mention porte le project_id du projet résolu.", params: [P("q", "texte libre"), P("type", "project_type", { enum: "types" }), P("sector", "", { enum: "sectors" }), P("status", ""), P("ticker", ""), P("cik", ""), P("form", "10-K, 10-Q, 8-K (liste possible)"), P("date_from", "AAAA-MM-JJ"), P("date_to", "AAAA-MM-JJ"), P("min_amount", "US$", { type: "number" }), P("min_confidence", "0–1", { type: "number" }), P("accession", "numéro d'accession SEC"), P("sort", "date | amount | confidence | ticker"), P("order", "asc | desc"), P("limit", "≤ 500", { type: "number" }), P("offset", "", { type: "number" })], ex: [43      { l: "8-K de NVIDIA", v: { ticker: "NVDA", form: "8-K", limit: 20 } },44      { l: "IA en 2025, confiance ≥ 0,9", v: { type: "ai_initiative", date_from: "2025-01-01", min_confidence: 0.9, limit: 20 } },45      { l: "Gros montants 10-K", v: { form: "10-K", min_amount: "1e10", sort: "amount", limit: 20 } },46    ] },47    { g: "Mentions & sections", m: "GET", p: "/v1/mentions/{mention_id}", d: "Une mention complète et, si elle vient d'un 10-K, la section source (section_text_url).", params: [P("mention_id", "identifiant SHA-1 (20 car.)", { path: true })], ex: [{ l: "Exemple", v: { mention_id: "MENTION_10K" } }] },48    { g: "Mentions & sections", m: "GET", p: "/v1/sections/{section_id}", d: "Texte intégral d'une section 10-K ingérée par SPID (Items 1, 1A, 2, 7, 7A). Les sections 10-Q/8-K ne sont pas stockées dans la base.", params: [P("section_id", "UUID de section", { path: true }), P("highlight", "mot à repérer : renvoie les positions")], ex: [{ l: "Exemple", v: { section_id: "SECTION_10K" } }] },49    { g: "Entreprises", m: "GET", p: "/v1/companies", d: "Les 500 entreprises avec taille de portefeuille (projets, mentions, types, capital, période). Tri : projects, amount, mentions, ticker.", params: [P("q", "nom ou ticker"), P("sector", "", { enum: "sectors" }), P("sort", ""), P("order", ""), P("limit", "≤ 500", { type: "number" }), P("offset", "", { type: "number" })], ex: [{ l: "Top 20", v: { sort: "projects", limit: 20 } }, { l: "Santé par capital", v: { sector: "Health Care", sort: "amount", limit: 20 } }] },50    { g: "Entreprises", m: "GET", p: "/v1/companies/{ident}", d: "Profil d'une entreprise (ticker ou CIK) : répartitions par type, statut, année, formulaire ; lieux, technologies, partenaires ; portefeuille complet (≤ 500 projets).", params: [P("ident", "ticker ou CIK", { path: true })], ex: [{ l: "Tesla", v: { ident: "TSLA" } }, { l: "Duke Energy", v: { ident: "DUK" } }, { l: "Microsoft (CIK)", v: { ident: "789019" } }] },51    { g: "Graphe", m: "GET", p: "/v1/graph/search", d: "Chercher un nœud du graphe de connaissances par libellé, avec son degré.", params: [P("q", "texte"), P("type", "Company | Project | Location | Technology | Partner"), P("limit", "≤ 200", { type: "number" })], ex: [{ l: "Texas", v: { q: "texas" } }, { l: "Technologies IA", v: { q: "ai", type: "Technology" } }] },52    { g: "Graphe", m: "GET", p: "/v1/graph/node/{node_id}", d: "Un nœud et ses arêtes (owns, located_in, uses) avec les nœuds voisins. Identifiants : C:<cik>, P:<project_id>, L:<lieu en minuscules>, T:<technologie>, PR:<partenaire>.", params: [P("node_id", "ex. T:AI, L:texas, C:0001318605", { path: true }), P("limit", "≤ 2000", { type: "number" })], ex: [{ l: "T:AI", v: { node_id: "T:AI", limit: 50 } }, { l: "L:texas", v: { node_id: "L:texas", limit: 50 } }, { l: "C:Tesla", v: { node_id: "C:0001318605", limit: 50 } }] },53    { g: "Recherche", m: "GET", p: "/v1/search/semantic", d: "Recherche en langage naturel : la requête est encodée avec all-MiniLM-L6-v2 côté serveur et comparée aux 19 227 vecteurs de projets. Anglais recommandé (langue des filings).", params: [P("q", "description en langage naturel"), P("k", "≤ 100", { type: "number" }), P("type", "filtre project_type", { enum: "types" }), P("sector", "filtre secteur", { enum: "sectors" })], ex: [{ l: "Usine de cellules de batteries", v: { q: "battery cell factory", k: 10 } }, { l: "IA générative pour clients", v: { q: "generative AI assistant for customers", k: 10 } }, { l: "Terminal GNL", v: { q: "LNG export terminal", k: 10 } }] },54    { g: "SQL", m: "GET", p: "/v1/schema", d: "Tables, colonnes et types de la base DuckDB.", params: [], ex: [{ l: "Schéma", v: {} }] },55    { g: "SQL", m: "POST", p: "/v1/sql", d: "Bac à sable SQL DuckDB en lecture seule (SELECT, WITH, DESCRIBE, SUMMARIZE). Corps JSON {sql, limit}. 20 s et 5 000 lignes maximum. Réponse {columns, rows, truncated, elapsed_ms}.", params: [P("sql", "requête SQL", { body: true, textarea: true }), P("limit", "≤ 5000", { body: true, type: "number" })], ex: [56      { l: "Projets par type", v: { sql: "select project_type, count(*) n, round(sum(total_amount_usd)/1e9,1) capital_gusd\nfrom projects group by 1 order by n desc", limit: 25 } },57      { l: "Mentions IA par année", v: { sql: "select year(filing_date) as yr, count(*) n\nfrom project_mentions where project_type='ai_initiative' group by 1 order by 1", limit: 30 } },58      { l: "Panel entreprise × année", v: { sql: "select cik, any_value(ticker) ticker, year(first_seen) as yr, count(*) n_projets, sum(total_amount_usd) capital\nfrom projects group by 1,3 order by 1,3", limit: 5000 } },59      { l: "Technologies co-citées", v: { sql: "select a.t t1, b.t t2, count(*) n\nfrom (select project_id, unnest(technologies) t from projects) a\njoin (select project_id, unnest(technologies) t from projects) b on a.project_id=b.project_id and a.t<b.t\ngroup by 1,2 order by n desc", limit: 50 } },60    ] },61  ];62  const GROUPS = [...new Set(ENDPOINTS.map((e) => e.g))];6364  /* ------------------------------------------------------------------ générateurs de code */65  function buildRequest(e, vals) {66    let path = e.p; const query = {}; let body = null;67    for (const prm of e.params) {68      const v = vals[prm.name]; if (v === undefined || v === "") continue;69      if (prm.path) path = path.replace(`{${prm.name}}`, encodeURIComponent(v));70      else if (prm.body) (body ||= {})[prm.name] = prm.type === "number" ? Number(v) : v;71      else query[prm.name] = v;72    }73    path = path.replace(/\{[^}]+\}/g, "");74    return { method: e.m, path, query, body, url: location.origin + path + (Object.keys(query).length ? "?" + qs(query) : "") };75  }76  const pyDict = (o) => "{" + Object.entries(o).map(([k, v]) => `${JSON.stringify(k)}: ${typeof v === "number" ? v : JSON.stringify(v)}`).join(", ") + "}";77  const rList = (o) => "list(" + Object.entries(o).map(([k, v]) => `${/^[a-z_]+$/i.test(k) ? k : "`" + k + "`"} = ${typeof v === "number" ? v : JSON.stringify(v)}`).join(", ") + ")";78  function snippet(l, r, k = KEY_PH, nokey = false) {79    const base = location.origin + r.path;80    if (nokey) return { curl: `curl '${r.url}'`, py: `import requests\n\nr = requests.get("${r.url}")\nr.raise_for_status()\nprint(r.json())`, js: `const r = await fetch("${r.url}");\nconsole.log(await r.json());`, r: `library(httr2)\nrequest("${r.url}") |> req_perform() |> resp_body_json()` }[l];81    if (l === "curl") return r.method === "POST"82      ? `curl -X POST '${base}' \\\n  -H 'X-API-Key: ${k}' -H 'Content-Type: application/json' \\\n  -d '${JSON.stringify(r.body || {})}'`83      : `curl '${r.url}' -H 'X-API-Key: ${k}'`;84    if (l === "py") return r.method === "POST"85      ? `import requests\n\nr = requests.post("${base}",\n    headers={"X-API-Key": "${k}"},\n    json=${pyDict(r.body || {})})\nr.raise_for_status()\ndata = r.json()\nprint(data)`86      : `import requests\n\nr = requests.get("${base}",\n    headers={"X-API-Key": "${k}"},${Object.keys(r.query).length ? `\n    params=${pyDict(r.query)},` : ""}\n)\nr.raise_for_status()\ndata = r.json()\nprint(data)`;87    if (l === "js") return `const r = await fetch("${r.url}", {\n  method: "${r.method}",\n  headers: { "X-API-Key": "${k}"${r.body ? ', "Content-Type": "application/json"' : ""} },${r.body ? `\n  body: JSON.stringify(${JSON.stringify(r.body)}),` : ""}\n});\nif (!r.ok) throw new Error(\`HTTP \${r.status}\`);\nconst data = await r.json();\nconsole.log(data);`;88    if (l === "r") return `library(httr2)\n\nresp <- request("${base}") |>\n  req_headers(\`X-API-Key\` = "${k}") |>${Object.keys(r.query).length ? `\n  req_url_query(!!!${rList(r.query)}) |>` : ""}${r.body ? `\n  req_body_json(${rList(r.body)}) |>` : ""}\n  req_perform()\ndata <- resp_body_json(resp)\nstr(data, max.level = 1)`;89  }9091  /* ------------------------------------------------------------------ recettes */92  const RECIPES = [93    { t: "Premier appel : compter les projets", d: "Vérifier la clé et lire les compteurs globaux.", ep: "/v1/stats", v: {}, code: {94      curl: `curl https://www.pdb-api.co/v1/stats -H 'X-API-Key: ${KEY_PH}' | python3 -m json.tool | head -30`,95      py: `import requests\nBASE, H = "https://www.pdb-api.co/v1", {"X-API-Key": "${KEY_PH}"}\n\nov = requests.get(f"{BASE}/stats", headers=H).json()["overview"]\nprint(f"{ov['projects']:,} projets · {ov['mentions']:,} mentions · {ov['companies']} entreprises")`,96      js: `const BASE = "https://www.pdb-api.co/v1", H = { "X-API-Key": "${KEY_PH}" };\nconst { overview } = await (await fetch(\`\${BASE}/stats\`, { headers: H })).json();\nconsole.log(overview.projects, "projets", overview.mentions, "mentions");`,97      r: `library(httr2)\nBASE <- "https://www.pdb-api.co/v1"; KEY <- "${KEY_PH}"\nov <- request(paste0(BASE, "/stats")) |> req_headers(\`X-API-Key\` = KEY) |> req_perform() |> resp_body_json()\nov$overview$projects` } },98    { t: "Filtrer et trier des projets", d: "Centres de données de plus de 500 M$, du plus gros au plus petit.", ep: "/v1/projects", v: { type: "data_center", min_amount: "5e8", sort: "amount", limit: 10 }, code: {99      curl: `curl 'https://www.pdb-api.co/v1/projects?type=data_center&min_amount=5e8&sort=amount&limit=10' -H 'X-API-Key: ${KEY_PH}'`,100      py: `r = requests.get(f"{BASE}/projects", headers=H, params={\n    "type": "data_center", "min_amount": 5e8, "sort": "amount", "limit": 10})\nfor p in r.json()["items"]:\n    print(f"{p['ticker']:6s} {p['project_name'][:45]:45s} {p['total_amount_usd']/1e9:6.1f} G$  {p['canonical_location']}")`,101      js: `const q = new URLSearchParams({ type: "data_center", min_amount: 5e8, sort: "amount", limit: 10 });\nconst { total, items } = await (await fetch(\`\${BASE}/projects?\${q}\`, { headers: H })).json();\nitems.forEach(p => console.log(p.ticker, p.project_name, p.total_amount_usd / 1e9, "G$"));`,102      r: `res <- request(paste0(BASE, "/projects")) |> req_headers(\`X-API-Key\` = KEY) |>\n  req_url_query(type = "data_center", min_amount = 5e8, sort = "amount", limit = 10) |>\n  req_perform() |> resp_body_json()\ndo.call(rbind, lapply(res$items, \\(p) data.frame(ticker = p$ticker, projet = p$project_name, gusd = p$total_amount_usd / 1e9)))` } },103    { t: "Tout récupérer avec la pagination → DataFrame", d: "Boucler sur limit/offset (≤ 500 par page) pour construire un jeu de données complet.", ep: "/v1/projects", v: { sector: "Utilities", limit: 500 }, code: {104      curl: `# page 1, 2, 3… : incrémenter offset de 500 jusqu'à atteindre total\nfor off in 0 500 1000 1500; do\n  curl -s "https://www.pdb-api.co/v1/projects?sector=Utilities&limit=500&offset=$off" -H 'X-API-Key: ${KEY_PH}' > utilities_$off.json\ndone`,105      py: `import pandas as pd\n\ndef fetch_all(path, **filters):\n    offset, rows = 0, []\n    while True:\n        page = requests.get(f"{BASE}/{path}", headers=H, params={**filters, "limit": 500, "offset": offset}).json()\n        rows += page["items"]\n        offset += len(page["items"])\n        if not page["items"] or offset >= page["total"]:\n            return rows\n\ndf = pd.DataFrame(fetch_all("projects", sector="Utilities"))\ndf["technologies"] = df["technologies"].str.join(" | ")\nprint(df.shape)\ndf.groupby("project_type")["total_amount_usd"].agg(["count", "median"]).sort_values("count", ascending=False)`,106      js: `async function fetchAll(path, filters) {\n  const rows = []; let offset = 0;\n  for (;;) {\n    const q = new URLSearchParams({ ...filters, limit: 500, offset });\n    const page = await (await fetch(\`\${BASE}/\${path}?\${q}\`, { headers: H })).json();\n    rows.push(...page.items); offset += page.items.length;\n    if (!page.items.length || offset >= page.total) return rows;\n  }\n}\nconst utilities = await fetchAll("projects", { sector: "Utilities" });\nconsole.log(utilities.length, "projets");`,107      r: `fetch_all <- function(path, ...) {\n  out <- list(); offset <- 0\n  repeat {\n    page <- request(paste0(BASE, "/", path)) |> req_headers(\`X-API-Key\` = KEY) |>\n      req_url_query(..., limit = 500, offset = offset) |> req_perform() |> resp_body_json()\n    out <- c(out, page$items); offset <- offset + length(page$items)\n    if (length(page$items) == 0 || offset >= page$total) break\n  }\n  out\n}\nprojets <- fetch_all("projects", sector = "Utilities")\ndf <- dplyr::bind_rows(lapply(projets, \\(p) as.data.frame(p[c("ticker","project_type","project_name","status","total_amount_usd","first_seen")])))` } },108    { t: "Export CSV direct", d: "Le plus simple pour Excel, R ou pandas : un seul appel, jusqu'à 50 000 lignes.", ep: "/v1/projects/export.csv", v: { type: "data_center" }, code: {109      curl: `curl 'https://www.pdb-api.co/v1/projects/export.csv?type=data_center' -H 'X-API-Key: ${KEY_PH}' -o data_centers.csv`,110      py: `import io, pandas as pd\ncsv_text = requests.get(f"{BASE}/projects/export.csv", headers=H, params={"type": "data_center"}).text\ndf = pd.read_csv(io.StringIO(csv_text))\ndf.head()`,111      js: `const csv = await (await fetch(\`\${BASE}/projects/export.csv?type=data_center\`, { headers: H })).text();\nrequire("fs").writeFileSync("data_centers.csv", csv);`,112      r: `csv <- request(paste0(BASE, "/projects/export.csv")) |> req_headers(\`X-API-Key\` = KEY) |>\n  req_url_query(type = "data_center") |> req_perform() |> resp_body_string()\ndf <- read.csv(text = csv)` } },113    { t: "Chronologie d'un projet", d: "Suivre un projet de son annonce (8-K) à ses mentions ultérieures (10-K, 10-Q).", ep: "/v1/projects/{project_id}", v: { project_id: "46fe2966ba0cbf6228807faca0645b54" }, code: {114      curl: `PID=$(curl -s 'https://www.pdb-api.co/v1/projects?ticker=TSLA&q=energy%20storage&limit=1' -H 'X-API-Key: ${KEY_PH}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["items"][0]["project_id"])')\ncurl "https://www.pdb-api.co/v1/projects/$PID" -H 'X-API-Key: ${KEY_PH}'`,115      py: `pid = requests.get(f"{BASE}/projects", headers=H, params={"ticker": "TSLA", "q": "energy storage", "limit": 1}).json()["items"][0]["project_id"]\nfiche = requests.get(f"{BASE}/projects/{pid}", headers=H).json()\nprint(fiche["project"]["project_name"], fiche["project"]["status"])\nfor t in fiche["timeline"]:\n    print(t["filing_date"], t["form_type"], t["status"], t["amount_usd"], "—", t["snippet"][:80])\nprint("similaires :", [s["project_name"] for s in fiche["similar"][:5]])`,116      js: `const first = (await (await fetch(\`\${BASE}/projects?ticker=TSLA&q=energy%20storage&limit=1\`, { headers: H })).json()).items[0];\nconst fiche = await (await fetch(\`\${BASE}/projects/\${first.project_id}\`, { headers: H })).json();\nfiche.timeline.forEach(t => console.log(t.filing_date, t.form_type, t.status, t.amount_usd));`,117      r: `pid <- (request(paste0(BASE, "/projects")) |> req_headers(\`X-API-Key\` = KEY) |>\n  req_url_query(ticker = "TSLA", q = "energy storage", limit = 1) |> req_perform() |> resp_body_json())$items[[1]]$project_id\nfiche <- request(paste0(BASE, "/projects/", pid)) |> req_headers(\`X-API-Key\` = KEY) |> req_perform() |> resp_body_json()\nsapply(fiche$timeline, \\(t) paste(t$filing_date, t$form_type, t$status))` } },118    { t: "Profil d'une entreprise", d: "Répartitions par type, année et formulaire, plus le portefeuille complet.", ep: "/v1/companies/{ident}", v: { ident: "DUK" }, code: {119      curl: `curl https://www.pdb-api.co/v1/companies/DUK -H 'X-API-Key: ${KEY_PH}'`,120      py: `duk = requests.get(f"{BASE}/companies/DUK", headers=H).json()\nprint(duk["company"])\npd.DataFrame(duk["by_type"]).set_index("label")["n"].plot.barh(title="Duke Energy — projets par type")`,121      js: `const duk = await (await fetch(\`\${BASE}/companies/DUK\`, { headers: H })).json();\nconsole.table(duk.by_type.map(x => ({ type: x.label, n: x.n })));`,122      r: `duk <- request(paste0(BASE, "/companies/DUK")) |> req_headers(\`X-API-Key\` = KEY) |> req_perform() |> resp_body_json()\nbarplot(sapply(duk$by_type, \\(x) x$n), names.arg = sapply(duk$by_type, \\(x) x$project_type), las = 2)` } },123    { t: "Recherche sémantique", d: "Décrire un projet en langage naturel et obtenir les projets les plus proches.", ep: "/v1/search/semantic", v: { q: "battery cell factory", k: 10 }, code: {124      curl: `curl 'https://www.pdb-api.co/v1/search/semantic?q=battery+cell+factory&k=10' -H 'X-API-Key: ${KEY_PH}'`,125      py: `hits = requests.get(f"{BASE}/search/semantic", headers=H, params={"q": "battery cell factory", "k": 10}).json()["items"]\nfor h in hits:\n    print(f"{h['score']:.3f} {h['ticker']:6s} {h['project_name']}")`,126      js: `const { items } = await (await fetch(\`\${BASE}/search/semantic?\${new URLSearchParams({ q: "battery cell factory", k: 10 })}\`, { headers: H })).json();\nitems.forEach(h => console.log(h.score.toFixed(3), h.ticker, h.project_name));`,127      r: `hits <- request(paste0(BASE, "/search/semantic")) |> req_headers(\`X-API-Key\` = KEY) |>\n  req_url_query(q = "battery cell factory", k = 10) |> req_perform() |> resp_body_json()\nsapply(hits$items, \\(h) sprintf("%.3f %s %s", h$score, h$ticker, h$project_name))` } },128    { t: "SQL : agrégations libres", d: "Quand les filtres ne suffisent pas : une requête DuckDB de lecture, résultat en colonnes/lignes.", ep: "/v1/sql", v: { sql: "select year(filing_date) as yr, project_type, count(*) n\nfrom project_mentions\nwhere project_type in ('ai_initiative','data_center','cloud_migration')\ngroup by 1,2 order by 1,2", limit: 200 }, code: {129      curl: `curl -X POST https://www.pdb-api.co/v1/sql -H 'X-API-Key: ${KEY_PH}' -H 'Content-Type: application/json' \\\n  -d '{"sql":"select year(filing_date) as yr, project_type, count(*) n from project_mentions where project_type in (\\'ai_initiative\\',\\'data_center\\') group by 1,2 order by 1,2","limit":200}'`,130      py: `sql = """\nselect year(filing_date) as yr, project_type, count(*) n\nfrom project_mentions\nwhere project_type in ('ai_initiative','data_center','cloud_migration')\ngroup by 1,2 order by 1,2\n"""\nres = requests.post(f"{BASE}/sql", headers=H, json={"sql": sql, "limit": 500}).json()\ndf = pd.DataFrame(res["rows"], columns=res["columns"])\ndf.pivot(index="yr", columns="project_type", values="n").plot(title="Mentions par année")`,131      js: `const res = await (await fetch(\`\${BASE}/sql\`, { method: "POST", headers: { ...H, "Content-Type": "application/json" },\n  body: JSON.stringify({ sql: "select project_type, count(*) n from projects group by 1 order by n desc", limit: 25 }) })).json();\nconsole.table(res.rows.map(r => Object.fromEntries(res.columns.map((c, i) => [c, r[i]]))));`,132      r: `res <- request(paste0(BASE, "/sql")) |> req_headers(\`X-API-Key\` = KEY) |>\n  req_body_json(list(sql = "select project_type, count(*) n from projects group by 1 order by n desc", limit = 25)) |>\n  req_perform() |> resp_body_json()\ndf <- as.data.frame(do.call(rbind, lapply(res$rows, unlist))); names(df) <- unlist(res$columns); df` } },133    { t: "Graphe : qui construit au Texas avec des batteries ?", d: "Partir d'un nœud Lieu, remonter aux projets, croiser avec une technologie.", ep: "/v1/graph/node/{node_id}", v: { node_id: "L:texas", limit: 100 }, code: {134      curl: `curl 'https://www.pdb-api.co/v1/graph/node/L:texas?limit=100' -H 'X-API-Key: ${KEY_PH}'`,135      py: `node = requests.get(f"{BASE}/graph/node/L:texas", headers=H, params={"limit": 500}).json()\nprojets_texas = {e["neighbor_id"][2:] for e in node["edges"] if e["neighbor_type"] == "Project"}\nbatt = requests.get(f"{BASE}/projects", headers=H, params={"location": "texas", "tech": "batter", "limit": 100}).json()["items"]\nprint(len(projets_texas), "projets au Texas ;", len(batt), "avec batteries :", [(p["ticker"], p["project_name"]) for p in batt][:5])`,136      js: `const node = await (await fetch(\`\${BASE}/graph/node/L:texas?limit=500\`, { headers: H })).json();\nconsole.log(node.degree, "projets rattachés à texas");`,137      r: `node <- request(paste0(BASE, "/graph/node/L:texas")) |> req_headers(\`X-API-Key\` = KEY) |> req_url_query(limit = 500) |> req_perform() |> resp_body_json()\nnode$degree` } },138    { t: "Gérer les erreurs et la limite de débit", d: "401 clé invalide, 404 introuvable, 408 SQL trop long, 429 trop de requêtes (240/min), 501 sémantique indisponible.", ep: "/v1/health", v: {}, code: {139      curl: `curl -s -o /dev/null -w '%{http_code}\\n' https://www.pdb-api.co/v1/stats -H 'X-API-Key: mauvaise'   # 401`,140      py: `import time\n\ndef get(path, **params):\n    for attempt in range(4):\n        r = requests.get(f"{BASE}/{path}", headers=H, params=params, timeout=60)\n        if r.status_code == 429:            # limite de débit : attendre puis réessayer\n            time.sleep(2 * (attempt + 1)); continue\n        if r.status_code >= 400:\n            raise RuntimeError(f"HTTP {r.status_code}: {r.json().get('detail')}")\n        return r.json()\n    raise RuntimeError("trop de tentatives")`,141      js: `async function get(path, params = {}) {\n  for (let i = 0; i < 4; i++) {\n    const r = await fetch(\`\${BASE}/\${path}?\${new URLSearchParams(params)}\`, { headers: H });\n    if (r.status === 429) { await new Promise(s => setTimeout(s, 2000 * (i + 1))); continue; }\n    if (!r.ok) throw new Error(\`HTTP \${r.status}: \${(await r.json()).detail}\`);\n    return r.json();\n  }\n}`,142      r: `resp <- request(paste0(BASE, "/stats")) |> req_headers(\`X-API-Key\` = KEY) |>\n  req_retry(max_tries = 4, is_transient = \\(r) resp_status(r) == 429) |> req_perform()` } },143  ];144145  /* ------------------------------------------------------------------ référence : dictionnaire des champs */146  const FIELDS = {147    projects: [["project_id", "MD5 de cik|type|ancre ; identifiant stable"], ["cik / ticker / company_name / sector", "entreprise (GICS)"], ["project_type", "un des 21 types (voir /v1/taxonomy)"], ["project_name / description", "nom et description de la mention la plus confiante"], ["canonical_location", "première localisation (minuscules) ayant servi d'ancre"], ["technologies[]", "union des technologies citées"], ["status", "statut du dépôt le plus récent : planned, in_progress, completed, mentioned"], ["total_amount_usd", "montant MAXIMAL divulgué parmi les mentions (pas une somme)"], ["n_mentions / n_filings", "nombre de mentions et de dépôts distincts"], ["first_seen / last_seen", "dates de dépôt extrêmes (fiables pour dater)"], ["first_year / last_year", "années citées dans le texte (peuvent être projetées)"], ["avg_confidence", "confiance moyenne du modèle (0,45–1)"]],148    mentions: [["mention_id", "SHA-1[0:20] de accession|section|type|ancre"], ["accession_number / section_id / section_name", "dépôt et section source"], ["form_type / filing_date / fiscal_year", "10-K, 10-Q ou 8-K ; date de dépôt (fiscal_year non renseigné)"], ["project_name / project_type / description / objective", "extraction du LLM"], ["amount_usd / amount_raw", "montant en US$ et chaîne d'origine"], ["locations[] / technologies[] / partners[] / suppliers[]", "listes extraites (≤ 8 éléments)"], ["benefits[] / risks[] / years[]", "bénéfices, risques, années citées"], ["status / confidence / backend", "statut, confiance 0–1, backend (llm)"], ["project_id", "projet résolu auquel la mention est rattachée (ajouté par l'API)"]],149    timeline: [["project_id / accession_number / filing_date / form_type", "un point par mention datée"], ["status / amount_usd / confidence / snippet", "état à cette date, montant, confiance, extrait de 300 caractères"]],150  };151  const ERRORS = [["200", "OK"], ["400", "Requête SQL refusée (mot-clé interdit, syntaxe) ou paramètre invalide"], ["401", "Clé absente ou invalide (en-tête X-API-Key)"], ["403", "Route /app réservée à l'interface web"], ["404", "Projet, mention, section, entreprise ou nœud introuvable"], ["408", "Requête SQL interrompue après 20 s"], ["422", "Paramètre mal typé (voir detail)"], ["429", "Plus de 240 requêtes par minute pour votre adresse"], ["501", "Recherche sémantique indisponible sur le serveur"], ["503", "Service ou clé non configurés"]];152153  /* ------------------------------------------------------------------ rendu */154  let ctx, meta = { types: [], sectors: [] }, cur = 7, tab = "start", exampleIds = {};155  function render(c) {156    ctx = c; const view = c.view;157    const hp = new URLSearchParams((location.hash.split("?")[1] || ""));158    if (hp.get("tab")) tab = hp.get("tab");159    if (hp.get("ep")) { const i = ENDPOINTS.findIndex((e) => e.p === hp.get("ep")); if (i >= 0) { cur = i; tab = "play"; } }160    view.innerHTML = `<div class="page-head"><div><h1>API &amp; playground</h1><p>API REST JSON de la base SPID. Base : <span class="mono">${esc(location.origin)}/v1</span> · authentification par en-tête <span class="mono">X-API-Key</span> · 240 requêtes/min · pagination <span class="mono">limit/offset</span> (≤ 500).</p></div>161      <div class="langsel">Langage des exemples : ${LANGS.map(([k, l]) => `<button class="${lang() === k ? "active" : ""}" data-lang="${k}">${l}</button>`).join("")}</div></div>162      <div class="tabs big" id="api-tabs">${[["start", "Démarrage rapide"], ["play", "Playground"], ["recipes", "Recettes"], ["ref", "Référence"], ["sdk", "SDK Python"]].map(([k, l]) => `<button class="${tab === k ? "active" : ""}" data-tab="${k}">${l}</button>`).join("")}</div>163      <div id="api-body"></div>`;164    view.querySelectorAll("[data-lang]").forEach((b) => b.addEventListener("click", () => { localStorage.setItem("pdb_lang", b.dataset.lang); render(ctx); }));165    view.querySelector("#api-tabs").addEventListener("click", (e) => { if (e.target.dataset.tab) { tab = e.target.dataset.tab; render(ctx); } });166    Promise.all([ctx.api("/taxonomy"), ctx.api("/sectors")]).then(([t, s]) => { meta = { types: t, sectors: s }; if (tab === "play") renderPlay(); }).catch(() => { });167    ({ start: renderStart, play: renderPlay, recipes: renderRecipes, ref: renderRef, sdk: renderSdk }[tab] || renderStart)();168    resolveExampleIds();169  }170  async function resolveExampleIds() {171    if (exampleIds.TSLA_ENERGY) return;172    try {173      const p = await ctx.api("/projects", { ticker: "TSLA", q: "energy storage", limit: 1 }); exampleIds.TSLA_ENERGY = p.items[0]?.project_id;174      const m = await ctx.api("/mentions", { form: "10-K", min_confidence: 0.95, limit: 1 }); exampleIds.MENTION_10K = m.items[0]?.mention_id; exampleIds.SECTION_10K = m.items[0]?.section_id;175    } catch { }176  }177  const sub = (v) => exampleIds[v] || v;178179  const keyBox = () => `<div class="keybox"><label><b>Votre clé d'API</b><input class="inp" id="key" type="password" value="${esc(key())}" placeholder="saisir la clé (reste dans ce navigateur)" autocomplete="off"></label>180    <button class="btn btn-ghost btn-sm" id="key-show">Afficher</button><button class="btn btn-sm" id="key-test">Tester la clé</button><span id="key-st" class="small muted"></span>181    <div class="small muted" style="flex-basis:100%">La clé n'est pas publiée sur ce site. Elle est fournie par l'équipe UQO (<a href="mailto:simon-pierre.boucher@uqo.ca">simon-pierre.boucher@uqo.ca</a>). Elle est conservée en localStorage et envoyée uniquement à cette API.</div></div>`;182  function bindKey(root) {183    const inp = root.querySelector("#key"); if (!inp) return;184    inp.addEventListener("input", () => { localStorage.setItem("pdb_key", inp.value); root.querySelectorAll("pre.code[data-req]").forEach(() => { }); });185    root.querySelector("#key-show").addEventListener("click", () => (inp.type = inp.type === "password" ? "text" : "password"));186    root.querySelector("#key-test").addEventListener("click", async () => {187      const st = root.querySelector("#key-st"); st.textContent = "…";188      const r = await fetch("/v1/taxonomy", { headers: { "X-API-Key": key() } });189      st.innerHTML = r.ok ? `<span class="ok" style="padding:3px 8px">✓ clé valide (HTTP 200)</span>` : `<span class="err" style="padding:3px 8px;display:inline-block;margin:0">✗ HTTP ${r.status} — ${esc((await r.json()).detail || "")}</span>`;190    });191  }192  const codeBlock = (code, id = "") => `<div class="codewrap"><button class="copy" data-copy>Copier</button><pre class="code" ${id ? `id="${id}"` : ""}>${esc(code)}</pre></div>`;193  function bindCopy(root) { root.querySelectorAll("[data-copy]").forEach((b) => b.addEventListener("click", () => { navigator.clipboard.writeText(b.nextElementSibling.textContent); b.textContent = "Copié ✓"; setTimeout(() => (b.textContent = "Copier"), 1500); })); }194195  /* ---- Démarrage rapide */196  function renderStart() {197    const l = lang(); const body = ctx.view.querySelector("#api-body");198    const r1 = buildRequest(ENDPOINTS[0], {}); const r2 = buildRequest(ENDPOINTS[1], {}); const r3 = buildRequest(ENDPOINTS.find((e) => e.p === "/v1/projects"), { type: "ai_initiative", year_from: 2024, sort: "last_seen", limit: 5 });199    const install = { curl: "# curl est déjà installé sur macOS / Linux / Windows 10+", py: "pip install requests pandas        # ou : téléchargez pdb_client.py (onglet SDK Python)", js: "# Node ≥ 18 (fetch natif) ou navigateur — aucune dépendance", r: 'install.packages(c("httr2", "dplyr"))' }[l];200    body.innerHTML = `<div class="grid g2">201      <div class="card" style="grid-column:1/-1">${keyBox()}</div>202      <div class="card"><h3><span class="step">1</span> Installer</h3>${codeBlock(install)}</div>203      <div class="card"><h3><span class="step">2</span> Vérifier le service (sans clé)</h3>${codeBlock(snippet(l, r1, KEY_PH, true))}</div>204      <div class="card"><h3><span class="step">3</span> Premier appel authentifié</h3>${codeBlock(snippet(l, r2, key() || KEY_PH))}<p class="small muted">Réponse : <span class="mono">{overview:{projects, mentions, companies…}, by_type:[…], by_sector:[…], mentions_by_year:[…]}</span></p></div>205      <div class="card"><h3><span class="step">4</span> Filtrer des projets</h3>${codeBlock(snippet(l, r3, key() || KEY_PH))}<p class="small muted">Réponse paginée : <span class="mono">{total, limit, offset, items:[{project_id, ticker, project_name, project_type, status, total_amount_usd, …}]}</span>. Pour tout récupérer, incrémentez <span class="mono">offset</span> de <span class="mono">limit</span> jusqu'à <span class="mono">total</span> (recette « pagination »).</p></div>206      <div class="card" style="grid-column:1/-1"><h3>Modèle de données en 30 secondes</h3><div class="grid g3">207        <div><b>Projet</b> (19 227) — unité principale. Un projet = mentions fusionnées par entreprise × type × ancre (lieu ou premier mot du nom). <span class="mono">/v1/projects</span>, <span class="mono">/v1/projects/{id}</span>.</div>208        <div><b>Mention</b> (39 930) — l'extraction brute : un projet cité dans une section d'un filing, avec date, formulaire, montant, confiance. <span class="mono">/v1/mentions</span>. La chronologie d'un projet = ses mentions triées par date.</div>209        <div><b>Graphe</b> (32 226 nœuds) et <b>embeddings</b> (384-d) — navigation par lieu/technologie/partenaire et recherche par le sens. <span class="mono">/v1/graph/*</span>, <span class="mono">/v1/search/semantic</span>, <span class="mono">/v1/projects/{id}/similar</span>.</div></div>210        <p class="small muted" style="margin-top:8px">Point d'attention : <span class="mono">total_amount_usd</span> est le montant <i>maximal</i> divulgué pour le projet, auto-déclaré et hétérogène ; <span class="mono">first_seen/last_seen</span> datent l'observation, <span class="mono">first_year/last_year</span> sont les années citées dans le texte (parfois projetées).</p></div>211      <div class="card" style="grid-column:1/-1"><h3>Codes de réponse</h3><table class="ref"><tbody>${ERRORS.map(([c, d]) => `<tr><td class="mono"><b>${c}</b></td><td>${esc(d)}</td></tr>`).join("")}</tbody></table></div>212    </div>`;213    bindKey(body); bindCopy(body);214  }215216  /* ---- Playground */217  const HIST_KEY = "pdb_hist";218  function renderPlay() {219    const body = ctx.view.querySelector("#api-body"); const e = ENDPOINTS[cur];220    body.innerHTML = `<div class="api-layout"><div class="card"><h3>Points d'accès</h3><ul class="endpoints">${GROUPS.map((g) => `<li class="grp">${esc(g)}</li>` + ENDPOINTS.map((x, i) => x.g === g ? `<li data-i="${i}" class="${i === cur ? "active" : ""}"><span class="m ${x.m === "POST" ? "post" : ""}">${x.m}</span><span class="mono">${esc(x.p.replace("/v1", ""))}</span></li>` : "").join("")).join("")}</ul>221      <h3 style="margin-top:14px">Historique</h3><div id="hist" class="hist"></div></div>222      <div><div class="card">${keyBox()}</div><div class="card" id="pg"></div></div></div>`;223    body.querySelectorAll(".endpoints li[data-i]").forEach((li) => li.addEventListener("click", () => { cur = +li.dataset.i; renderPlay(); }));224    bindKey(body); renderHist(body);225    const pg = body.querySelector("#pg");226    const ex0 = e.ex?.[0]?.v || {};227    pg.innerHTML = `<h3><span class="m ${e.m === "POST" ? "post" : ""}" style="font-family:var(--mono);color:${e.m === "POST" ? "var(--orange)" : "var(--vert)"}">${e.m}</span> <span class="mono">${esc(e.p)}</span>${e.key === false ? ' <span class="pill pill-vert">sans clé</span>' : ""}</h3><p class="muted">${esc(e.d)}</p>228      ${e.ex?.length ? `<div class="examples"><span class="small muted" style="align-self:center">Exemples :</span>${e.ex.map((x, i) => `<button data-ex="${i}">${esc(x.l)}</button>`).join("")}</div>` : ""}229      <div class="params">${e.params.map((p) => p.textarea ? `<label style="grid-column:1/-1"><b>${esc(p.name)}</b> <span>${esc(p.desc)}</span><textarea class="sql" data-p="${esc(p.name)}" rows="5">${esc(sub(ex0[p.name] ?? ""))}</textarea></label>` :230        `<label><b>${esc(p.name)}${p.path ? " *" : ""}</b><span>${esc(p.desc)}</span>${p.enum ? `<input class="inp" list="dl-${p.enum}" data-p="${esc(p.name)}" value="${esc(sub(ex0[p.name] ?? ""))}">` : `<input class="inp" type="${p.type === "number" ? "text" : "text"}" data-p="${esc(p.name)}" value="${esc(sub(ex0[p.name] ?? ""))}" placeholder="${esc(p.desc.slice(0, 40))}">`}</label>`).join("") || `<span class="muted small">Aucun paramètre.</span>`}</div>231      <datalist id="dl-types">${meta.types.map((t) => `<option value="${t.project_type}">${esc(t.label)}</option>`).join("")}</datalist><datalist id="dl-sectors">${meta.sectors.map((s) => `<option value="${esc(s.sector)}">`).join("")}</datalist>232      <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap"><button class="btn" id="send">▶ Envoyer</button><span class="muted small" id="st"></span><span class="sp" style="flex:1"></span><a id="explore" class="btn btn-ghost btn-sm" hidden>Ouvrir dans l'explorateur</a></div>233      <div class="tabs" style="margin-top:14px" id="reqtabs">${LANGS.map(([k, l]) => `<button class="${lang() === k ? "active" : ""}" data-lang="${k}">${l}</button>`).join("")}</div>234      ${codeBlock("", "req")}235      <div style="display:flex;gap:10px;align-items:center;margin-top:14px"><h3 style="margin:0">Réponse</h3><div class="tabs" style="margin:0;border:0" id="resptabs"><button class="active" data-v="json">JSON</button><button data-v="table">Tableau</button></div><span class="sp" style="flex:1"></span><button class="btn btn-ghost btn-sm" id="dl" hidden>Télécharger</button></div>236      <div id="resp"><pre class="code">—</pre></div>`;237    const vals = () => { const o = {}; pg.querySelectorAll("[data-p]").forEach((i) => { if (i.value !== "") o[i.dataset.p] = i.value; }); return o; };238    const showReq = () => { const r = buildRequest(e, vals()); pg.querySelector("#req").textContent = snippet(lang(), r, key() || KEY_PH); const ex = exploreLink(e, vals(), r); const a = pg.querySelector("#explore"); a.hidden = !ex; if (ex) a.href = ex; };239    pg.querySelectorAll("[data-p]").forEach((i) => i.addEventListener("input", showReq)); showReq();240    pg.querySelectorAll("[data-ex]").forEach((b) => b.addEventListener("click", () => { const v = e.ex[b.dataset.ex].v; pg.querySelectorAll("[data-p]").forEach((i) => (i.value = sub(v[i.dataset.p] ?? ""))); showReq(); send(); }));241    pg.querySelector("#reqtabs").addEventListener("click", (ev) => { if (ev.target.dataset.lang) { localStorage.setItem("pdb_lang", ev.target.dataset.lang); pg.querySelectorAll("#reqtabs button").forEach((b) => b.classList.toggle("active", b.dataset.lang === ev.target.dataset.lang)); ctx.view.querySelectorAll(".langsel button").forEach((b) => b.classList.toggle("active", b.dataset.lang === ev.target.dataset.lang)); showReq(); } });242    bindCopy(pg);243    let last = null, lastRaw = "";244    const renderResp = () => {245      const v = pg.querySelector("#resptabs .active").dataset.v; const box = pg.querySelector("#resp");246      if (!last) { box.innerHTML = `<pre class="code">${esc(lastRaw.slice(0, 60000))}</pre>`; return; }247      if (v === "table") { const rows = tabular(last); box.innerHTML = rows ? rows : `<div class="warn">Réponse non tabulaire — voir JSON.</div>`; }248      else { const txt = JSON.stringify(last, null, 2); box.innerHTML = `<div class="codewrap"><button class="copy" data-copy>Copier</button><pre class="code json">${hl(txt.length > 80000 ? txt.slice(0, 80000) + "\n… (tronqué)" : txt)}</pre></div>`; bindCopy(box); }249    };250    pg.querySelector("#resptabs").addEventListener("click", (ev) => { if (ev.target.dataset.v) { pg.querySelectorAll("#resptabs button").forEach((b) => b.classList.toggle("active", b === ev.target)); renderResp(); } });251    async function send() {252      const r = buildRequest(e, vals()); const st = pg.querySelector("#st"); st.textContent = "…"; const t0 = performance.now();253      try {254        const resp = await fetch(r.url, { method: r.method, headers: { ...(e.key === false ? {} : { "X-API-Key": key() }), ...(r.body ? { "Content-Type": "application/json" } : {}) }, body: r.body ? JSON.stringify(r.body) : undefined });255        lastRaw = await resp.text(); last = null; try { last = JSON.parse(lastRaw); } catch { }256        const ms = Math.round(performance.now() - t0);257        st.innerHTML = `<span class="${resp.ok ? "pill pill-vert" : "pill pill-rouge"}">HTTP ${resp.status}</span> ${ms} ms · ${(lastRaw.length / 1024).toFixed(1)} ko${last?.total != null ? ` · total ${fmtN(last.total)}` : ""}`;258        const dl = pg.querySelector("#dl"); dl.hidden = false; dl.onclick = () => { const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([lastRaw], { type: e.raw ? "text/csv" : "application/json" })); a.download = e.raw ? "export.csv" : "response.json"; a.click(); };259        pushHist({ ep: e.p, vals: vals(), status: resp.status, ms, t: Date.now() }); renderHist(body); renderResp();260      } catch (err) { st.textContent = ""; pg.querySelector("#resp").innerHTML = `<div class="err">${esc(String(err))}</div>`; }261    }262    pg.querySelector("#send").addEventListener("click", send);263  }264  function exploreLink(e, vals, r) {265    if (e.p === "/v1/projects" || e.p === "/v1/projects/export.csv") return "#/projects?" + qs(Object.fromEntries(Object.entries(r.query).filter(([k]) => !["limit", "offset"].includes(k))));266    if (e.p.startsWith("/v1/projects/{") && vals.project_id) return "#/project/" + encodeURIComponent(vals.project_id);267    if (e.p === "/v1/companies/{ident}" && vals.ident) return "#/company/" + encodeURIComponent(vals.ident);268    if (e.p === "/v1/mentions") return "#/mentions?" + qs(r.query);269    if (e.p === "/v1/mentions/{mention_id}" && vals.mention_id) return "#/mention/" + encodeURIComponent(vals.mention_id);270    if (e.p === "/v1/graph/node/{node_id}" && vals.node_id) return "#/graph?node=" + encodeURIComponent(vals.node_id);271    if (e.p === "/v1/graph/search" && vals.q) return "#/graph?q=" + encodeURIComponent(vals.q);272    if (e.p === "/v1/search/semantic" && vals.q) return "#/semantic?q=" + encodeURIComponent(vals.q);273    if (e.p === "/v1/sql") return "#/sql";274    return null;275  }276  function tabular(d) {277    let rows = Array.isArray(d) ? d : d?.items || d?.edges || d?.projects || d?.timeline || null;278    if (d?.columns && d?.rows) rows = d.rows.map((r) => Object.fromEntries(d.columns.map((c, i) => [c, r[i]])));279    if (!rows || !rows.length || typeof rows[0] !== "object") return null;280    const cols = Object.keys(rows[0]).filter((c) => !["description", "props", "neighbor_props", "vector"].includes(c)).slice(0, 14);281    return `<div class="table-wrap" style="max-height:60vh"><table><thead><tr>${cols.map((c) => `<th>${esc(c)}</th>`).join("")}</tr></thead><tbody>${rows.slice(0, 500).map((r) => `<tr>${cols.map((c) => { const v = r[c]; return `<td class="${typeof v === "number" ? "num" : ""}">${esc(Array.isArray(v) ? v.join(" | ") : v && typeof v === "object" ? JSON.stringify(v) : v)}</td>`; }).join("")}</tr>`).join("")}</tbody></table></div><div class="small muted" style="margin-top:6px">${fmtN(rows.length)} lignes${rows.length > 500 ? " (500 affichées)" : ""}</div>`;282  }283  const hl = (txt) => esc(txt).replace(/("(?:\\.|[^"\\])*")(\s*:)?/g, (m, s, c) => c ? `<span class="k">${s}</span>${c}` : `<span class="s">${s}</span>`).replace(/\b(-?\d+(?:\.\d+)?(?:e[+-]?\d+)?)\b/g, `<span class="n">$1</span>`).replace(/\b(true|false|null)\b/g, `<span class="b">$1</span>`);284  function pushHist(h) { const a = JSON.parse(localStorage.getItem(HIST_KEY) || "[]"); a.unshift(h); localStorage.setItem(HIST_KEY, JSON.stringify(a.slice(0, 12))); }285  function renderHist(root) {286    const a = JSON.parse(localStorage.getItem(HIST_KEY) || "[]"); const box = root.querySelector("#hist"); if (!box) return;287    box.innerHTML = a.length ? a.map((h, i) => `<div class="hist-it" data-h="${i}"><span class="pill ${h.status < 400 ? "pill-vert" : "pill-rouge"}">${h.status}</span> <span class="mono small">${esc(h.ep.replace("/v1", ""))}</span><div class="small muted">${esc(Object.entries(h.vals).map(([k, v]) => `${k}=${String(v).slice(0, 18)}`).join(" ")) || "—"} · ${h.ms} ms</div></div>`).join("") + `<button class="btn btn-ghost btn-sm" id="hist-clear">Effacer</button>` : `<span class="small muted">Aucune requête envoyée.</span>`;288    box.querySelectorAll(".hist-it").forEach((d) => d.addEventListener("click", () => { const h = a[d.dataset.h]; cur = ENDPOINTS.findIndex((e) => e.p === h.ep); renderPlay(); const pg = root.querySelector("#pg"); pg.querySelectorAll("[data-p]").forEach((i) => (i.value = h.vals[i.dataset.p] ?? "")); pg.querySelector("[data-p]")?.dispatchEvent(new Event("input")); }));289    box.querySelector("#hist-clear")?.addEventListener("click", () => { localStorage.removeItem(HIST_KEY); renderHist(root); });290  }291292  /* ---- Recettes */293  function renderRecipes() {294    const l = lang(); const body = ctx.view.querySelector("#api-body");295    const pre = { py: `# Préambule commun aux recettes Python\nimport requests, pandas as pd\nBASE = "https://www.pdb-api.co/v1"\nH = {"X-API-Key": "${key() || KEY_PH}"}`, js: `// Préambule commun (Node ≥ 18 ou navigateur)\nconst BASE = "https://www.pdb-api.co/v1";\nconst H = { "X-API-Key": "${key() || KEY_PH}" };`, r: `# Préambule commun aux recettes R\nlibrary(httr2); library(dplyr)\nBASE <- "https://www.pdb-api.co/v1"; KEY <- "${key() || KEY_PH}"`, curl: `# Remplacez ${KEY_PH} par votre clé. Ajoutez « | python3 -m json.tool » pour lire le JSON.` }[l];296    body.innerHTML = `<div class="card">${codeBlock(pre)}</div><div class="grid g2">${RECIPES.map((r, i) => `<div class="card recipe"><h3>${i + 1}. ${esc(r.t)}</h3><p class="muted small">${esc(r.d)}</p>${codeBlock(r.code[l].replace(new RegExp(KEY_PH, "g"), key() || KEY_PH))}<div style="margin-top:8px"><button class="btn btn-ghost btn-sm" data-try="${i}">Essayer dans le playground</button></div></div>`).join("")}</div>`;297    bindCopy(body);298    body.querySelectorAll("[data-try]").forEach((b) => b.addEventListener("click", () => { const r = RECIPES[b.dataset.try]; cur = ENDPOINTS.findIndex((e) => e.p === r.ep); tab = "play"; render(ctx); const pg = ctx.view.querySelector("#pg"); pg.querySelectorAll("[data-p]").forEach((i) => (i.value = sub(r.v[i.dataset.p] ?? ""))); pg.querySelector("[data-p]")?.dispatchEvent(new Event("input")); window.scrollTo(0, 0); }));299  }300301  /* ---- Référence */302  function renderRef() {303    const body = ctx.view.querySelector("#api-body");304    body.innerHTML = `<div class="card"><h3>Conventions</h3><ul class="small"><li><b>Authentification</b> : en-tête <span class="mono">X-API-Key: &lt;clé&gt;</span>, ou <span class="mono">Authorization: Bearer &lt;clé&gt;</span>, ou paramètre <span class="mono">?api_key=</span> (déconseillé). <span class="mono">/v1/health</span> est libre.</li>305      <li><b>Pagination</b> : <span class="mono">limit</span> (≤ 500) et <span class="mono">offset</span> ; réponse <span class="mono">{total, limit, offset, items}</span>.</li>306      <li><b>Filtres texte</b> : insensibles à la casse, correspondance « contient » (sauf <span class="mono">ticker</span> et <span class="mono">cik</span>, exacts). Les paramètres <span class="mono">type</span>, <span class="mono">status</span>, <span class="mono">form</span> acceptent des listes séparées par des virgules.</li>307      <li><b>Montants</b> en US$ (<span class="mono">5e8</span> accepté). <b>Dates</b> au format ISO <span class="mono">AAAA-MM-JJ</span>. <b>Listes</b> renvoyées comme tableaux JSON ; en CSV, jointes par <span class="mono">|</span>.</li>308      <li><b>Limite</b> : 240 requêtes par minute et par adresse IP (429 au-delà). Bac à sable SQL : 20 s, 5 000 lignes, lecture seule, pas d'accès aux fichiers.</li>309      <li><b>OpenAPI</b> : <a href="/openapi.json" target="_blank">openapi.json</a> · <a href="/docs" target="_blank">Swagger UI</a> · <a href="/redoc" target="_blank">ReDoc</a>.</li></ul></div>310      <div class="card"><h3>Routes</h3><div class="table-wrap"><table class="ref"><thead><tr><th>Méthode</th><th>Route</th><th>Description</th><th>Paramètres</th></tr></thead><tbody>${ENDPOINTS.map((e) => `<tr><td class="mono"><b>${e.m}</b></td><td class="mono"><a href="#/api?tab=play&ep=${encodeURIComponent(e.p)}">${esc(e.p)}</a></td><td>${esc(e.d)}</td><td class="small">${e.params.map((p) => `<span class="mono">${esc(p.name)}</span>`).join(", ") || "—"}</td></tr>`).join("")}</tbody></table></div></div>311      ${Object.entries(FIELDS).map(([t, f]) => `<div class="card"><h3>Champs — ${t}</h3><table class="ref"><tbody>${f.map(([k, d]) => `<tr><td class="mono">${esc(k)}</td><td>${esc(d)}</td></tr>`).join("")}</tbody></table></div>`).join("")}312      <div class="card"><h3>Types de projets</h3><table class="ref"><thead><tr><th>project_type</th><th>Libellé</th><th class="num">Projets</th></tr></thead><tbody>${meta.types.map((t) => `<tr><td class="mono">${t.project_type}</td><td>${esc(t.label)}</td><td class="num">${fmtN(t.n)}</td></tr>`).join("") || "<tr><td colspan=3 class='muted'>chargement…</td></tr>"}</tbody></table></div>313      <div class="card"><h3>Codes de réponse</h3><table class="ref"><tbody>${ERRORS.map(([c, d]) => `<tr><td class="mono"><b>${c}</b></td><td>${esc(d)}</td></tr>`).join("")}</tbody></table></div>`;314    if (!meta.types.length) ctx.api("/taxonomy").then((t) => { meta.types = t; if (tab === "ref") renderRef(); }).catch(() => { });315  }316317  /* ---- SDK */318  function renderSdk() {319    const body = ctx.view.querySelector("#api-body"); const k = key() || KEY_PH;320    body.innerHTML = `<div class="grid g2">321      <div class="card" style="grid-column:1/-1"><h3>pdb_client.py — client Python sans dépendance</h3><p class="muted">Un fichier à déposer à côté de votre script ou notebook. Pagination automatique, réessais sur 429, conversion pandas/CSV, toutes les routes.</p>322        <a class="btn" href="/sdk/pdb_client.py" download>⬇ Télécharger pdb_client.py</a> <a class="btn btn-ghost" href="/sdk/pdb_client.py" target="_blank">Voir le source</a> &nbsp; <a class="btn btn-or" href="/sdk/pdb_api.R" download>⬇ Script R prêt à l'emploi (pdb_api.R)</a> &nbsp; <a class="btn btn-ghost" href="/sdk/pdb_api.m" download>⬇ MATLAB (pdb_api.m)</a> <a class="btn btn-ghost" href="/sdk/pdb_api.do" download>⬇ Stata (pdb_api.do)</a></div>323      <div class="card" style="grid-column:1/-1"><h3>R — pdb_api.R</h3><p class="muted">Fichier R complet (httr2 + dplyr) : fonctions <span class="mono">pdb_get()</span>, <span class="mono">pdb_sql()</span>, <span class="mono">pdb_all()</span> (pagination) et dix exemples exécutables (filtres, secteur entier, CSV, chronologie, entreprise, sémantique, SQL, panel entreprise × année, graphe). Testé avec R 4.6.</p>${codeBlock(`install.packages(c("httr2", "dplyr"))\nSys.setenv(PDB_API_KEY = "${k}")\nsource("pdb_api.R")            # définit pdb_get / pdb_sql / pdb_all\n\ndc   <- pdb_get("projects", type = "data_center", min_amount = 5e8, sort = "amount", limit = 10)$items\nutil <- pdb_all("projects", sector = "Utilities")          # data.frame complet\nia   <- pdb_sql("select year(filing_date) as yr, count(*) n from project_mentions where project_type = 'ai_initiative' group by 1 order by 1")`)}</div>324      <div class="card"><h3>MATLAB — pdb_api.m</h3><p class="muted">webread / webwrite natifs (R2018a+), fonctions <span class="mono">pdb_get</span>, <span class="mono">pdb_sql</span>, <span class="mono">pdb_all</span> et dix sections exécutables. Non testé sur MATLAB ici : signalez toute erreur.</p>${codeBlock(`BASE = "${location.origin}/v1"; KEY = "${k}";\nopts = weboptions("HeaderFields", ["X-API-Key", KEY], "ContentType", "json");\nr  = webread(BASE + "/projects", "type", "data_center", "min_amount", "5e8", "sort", "amount", "limit", "10", opts);\ndc = struct2table(r.items);\n\nres = webwrite(BASE + "/sql", struct("sql", "select project_type, count(*) n from projects group by 1 order by n desc", "limit", 25), ...\n    weboptions("HeaderFields", ["X-API-Key", KEY], "MediaType", "application/json"));\nT = cell2table(res.rows, "VariableNames", res.columns);`)}</div>325      <div class="card"><h3>Stata — pdb_api.do</h3><p class="muted">Stata ne peut pas envoyer d'en-tête HTTP : le .do définit <span class="mono">pdb_projects</span>, <span class="mono">pdb_mentions</span>, <span class="mono">pdb_sql</span> via Python intégré (Stata 16+) et une voie <span class="mono">shell curl</span> + <span class="mono">import delimited</span> pour toute version. Non testé sur Stata ici.</p>${codeBlock(`global PDB_KEY "${k}"\ndo pdb_api.do                       // définit les programmes\npdb_projects, filters(type=data_center min_amount=5e8 sort=amount)\npdb_sql "select year(filing_date) as yr, count(*) n from project_mentions where project_type = 'ai_initiative' group by 1 order by 1"\n\n* sans Python :\nshell curl -s "${location.origin}/v1/projects/export.csv?type=plant_construction" -H "X-API-Key: $PDB_KEY" -o usines.csv\nimport delimited using "usines.csv", clear varnames(1) encoding(utf8)`)}</div>326      <div class="card"><h3>Prise en main</h3>${codeBlock(`from pdb_client import PDB\n\npdb = PDB("${k}")            # ou export PDB_API_KEY=...\nprint(pdb.health())\n\nov = pdb.stats()["overview"]\nprint(ov["projects"], "projets")\n\n# une page\npage = pdb.projects(type="data_center", min_amount=5e8, sort="amount", limit=10)\nfor p in page["items"]:\n    print(p["ticker"], p["project_name"], p["total_amount_usd"])`)}</div>327      <div class="card"><h3>Tout un jeu de données en DataFrame</h3>${codeBlock(`# pagination automatique (500 par page)\nrows = pdb.iter_projects(sector="Utilities")\ndf = PDB.to_dataframe(rows)\nprint(df.shape)\n\n# mentions 8-K de 2025 sur l'IA\nm = PDB.to_dataframe(pdb.iter_mentions(type="ai_initiative", form="8-K", date_from="2025-01-01"))\nm.groupby("ticker").size().sort_values(ascending=False).head(10)\n\n# export CSV\nPDB.to_csv(pdb.iter_projects(type="plant_construction"), "usines.csv")`)}</div>328      <div class="card"><h3>Fiche, similaires, entreprise, graphe</h3>${codeBlock(`fiche = pdb.project(page["items"][0]["project_id"])\nfiche["project"], fiche["timeline"], fiche["mentions"], fiche["similar"]\n\npdb.similar(fiche["project"]["project_id"], k=5)\n\nduk = pdb.company("DUK")          # ticker ou CIK\nduk["by_type"], duk["projects"][:3]\n\nnode = pdb.graph_node("L:texas", limit=500)\n[e["neighbor_label"] for e in node["edges"]][:10]`)}</div>329      <div class="card"><h3>Sémantique et SQL</h3>${codeBlock(`for h in pdb.semantic("battery cell factory", k=5):\n    print(round(h["score"], 3), h["ticker"], h["project_name"])\n\nres = pdb.sql("""\n  select year(filing_date) as yr, count(*) n\n  from project_mentions where project_type = 'data_center'\n  group by 1 order by 1\n""")\nimport pandas as pd\npd.DataFrame(res["rows"], columns=res["columns"]).set_index("yr").plot()\n\n# ou directement en enregistrements\npdb.sql_records("select ticker, count(*) n from projects group by 1 order by n desc limit 5")`)}</div>330      <div class="card"><h3>Dans un notebook Jupyter / Colab</h3>${codeBlock(`!curl -sO ${location.origin}/sdk/pdb_client.py\nimport os; os.environ["PDB_API_KEY"] = "${k}"\nfrom pdb_client import PDB\npdb = PDB()`)}</div>331      <div class="card"><h3>JavaScript / TypeScript (sans SDK)</h3>${codeBlock(`const BASE = "${location.origin}/v1";\nconst H = { "X-API-Key": process.env.PDB_API_KEY };\n\nexport async function pdb(path, params = {}) {\n  const r = await fetch(\`\${BASE}/\${path}?\${new URLSearchParams(params)}\`, { headers: H });\n  if (!r.ok) throw new Error(\`HTTP \${r.status}: \${(await r.json()).detail}\`);\n  return r.json();\n}\nexport async function* iterate(path, params = {}) {\n  for (let offset = 0; ; ) {\n    const page = await pdb(path, { ...params, limit: 500, offset });\n    yield* page.items; offset += page.items.length;\n    if (!page.items.length || offset >= page.total) return;\n  }\n}`)}</div>332    </div>`;333    bindCopy(body);334  }335336  return { render };337})();338