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%
9.0 KB · 204 lines python
Raw Blame History
1"""pdb_client — client Python minimal pour la PDB API (SEC Project Intelligence Database, UQO).23Aucune dépendance obligatoire (urllib). pandas est optionnel pour `to_dataframe`.45    from pdb_client import PDB6    pdb = PDB("VOTRE_CLE")                      # ou variable d'environnement PDB_API_KEY7    pdb.stats()["overview"]8    for p in pdb.iter_projects(type="data_center", min_amount=5e8):9        print(p["ticker"], p["project_name"], p["total_amount_usd"])10    df = pdb.to_dataframe(pdb.iter_projects(sector="Utilities"))11    pdb.sql("select project_type, count(*) n from projects group by 1 order by n desc")12"""13from __future__ import annotations1415import csv16import io17import json18import os19import time20import urllib.error21import urllib.parse22import urllib.request23from typing import Any, Iterator2425__version__ = "1.0.0"26DEFAULT_BASE = "https://www.pdb-api.co/v1"272829class PDBError(RuntimeError):30    def __init__(self, status: int, detail: str, url: str):31        super().__init__(f"HTTP {status} — {detail} ({url})")32        self.status, self.detail, self.url = status, detail, url333435class PDB:36    def __init__(self, api_key: str | None = None, base_url: str = DEFAULT_BASE, timeout: float = 60.0, retries: int = 3):37        self.api_key = api_key or os.environ.get("PDB_API_KEY", "")38        if not self.api_key:39            raise ValueError("Clé d'API manquante : PDB(api_key=...) ou variable PDB_API_KEY.")40        self.base_url = base_url.rstrip("/")41        self.timeout = timeout42        self.retries = retries4344    # ------------------------------------------------------------ transport45    def request(self, method: str, path: str, params: dict | None = None, body: dict | None = None, raw: bool = False) -> Any:46        q = {k: (str(v).lower() if isinstance(v, bool) else v) for k, v in (params or {}).items() if v is not None and v != ""}47        url = f"{self.base_url}/{path.lstrip('/')}" + (f"?{urllib.parse.urlencode(q)}" if q else "")48        data = json.dumps(body).encode() if body is not None else None49        headers = {"X-API-Key": self.api_key, "Accept": "application/json", "User-Agent": f"pdb_client/{__version__}"}50        if data is not None:51            headers["Content-Type"] = "application/json"52        for attempt in range(self.retries + 1):53            req = urllib.request.Request(url, data=data, method=method, headers=headers)54            try:55                with urllib.request.urlopen(req, timeout=self.timeout) as r:56                    payload = r.read()57                    return payload.decode() if raw else json.loads(payload)58            except urllib.error.HTTPError as e:59                detail = e.read().decode(errors="replace")60                try:61                    detail = json.loads(detail).get("detail", detail)62                except Exception:63                    pass64                if e.code in (429, 502, 503, 504) and attempt < self.retries:65                    time.sleep(1.5 * (attempt + 1))66                    continue67                raise PDBError(e.code, detail, url) from None6869    def get(self, path: str, **params) -> Any:70        return self.request("GET", path, params)7172    # ------------------------------------------------------------ découverte73    def health(self) -> dict:74        return self.get("health")7576    def stats(self) -> dict:77        return self.get("stats")7879    def taxonomy(self) -> list[dict]:80        return self.get("taxonomy")8182    def sectors(self) -> list[dict]:83        return self.get("sectors")8485    def technologies(self, q: str | None = None, limit: int = 50) -> list[dict]:86        return self.get("technologies", q=q, limit=limit)8788    def locations(self, q: str | None = None, limit: int = 50) -> list[dict]:89        return self.get("locations", q=q, limit=limit)9091    def partners(self, q: str | None = None, limit: int = 50) -> list[dict]:92        return self.get("partners", q=q, limit=limit)9394    def schema(self) -> list[dict]:95        return self.get("schema")9697    # ------------------------------------------------------------ projets98    def projects(self, **filters) -> dict:99        """Une page : {total, limit, offset, items}. Filtres : q, type, sector, status, ticker, cik, location, tech,100        partner, min_amount, max_amount, year_from, year_to, min_confidence, has_amount, sort, order, limit, offset."""101        return self.get("projects", **filters)102103    def iter_projects(self, page_size: int = 500, max_items: int | None = None, **filters) -> Iterator[dict]:104        """Itère sur tous les projets correspondant aux filtres (pagination automatique)."""105        yield from self._paginate("projects", page_size, max_items, **filters)106107    def project(self, project_id: str) -> dict:108        """Fiche complète : {project, timeline, mentions, graph, similar}."""109        return self.get(f"projects/{project_id}")110111    def similar(self, project_id: str, k: int = 10) -> list[dict]:112        return self.get(f"projects/{project_id}/similar", k=k)113114    def projects_csv(self, **filters) -> str:115        return self.request("GET", "projects/export.csv", filters, raw=True)116117    # ------------------------------------------------------------ mentions / sections118    def mentions(self, **filters) -> dict:119        return self.get("mentions", **filters)120121    def iter_mentions(self, page_size: int = 500, max_items: int | None = None, **filters) -> Iterator[dict]:122        yield from self._paginate("mentions", page_size, max_items, **filters)123124    def mention(self, mention_id: str) -> dict:125        return self.get(f"mentions/{mention_id}")126127    def section(self, section_id: str, highlight: str | None = None) -> dict:128        return self.get(f"sections/{section_id}", highlight=highlight)129130    # ------------------------------------------------------------ entreprises131    def companies(self, **filters) -> dict:132        return self.get("companies", **filters)133134    def iter_companies(self, page_size: int = 500, **filters) -> Iterator[dict]:135        yield from self._paginate("companies", page_size, None, **filters)136137    def company(self, ticker_or_cik: str) -> dict:138        return self.get(f"companies/{ticker_or_cik}")139140    # ------------------------------------------------------------ graphe / recherche / SQL141    def graph_search(self, q: str, type: str | None = None, limit: int = 30) -> list[dict]:142        return self.get("graph/search", q=q, type=type, limit=limit)143144    def graph_node(self, node_id: str, limit: int = 200) -> dict:145        return self.get(f"graph/node/{urllib.parse.quote(node_id, safe=':')}", limit=limit)146147    def semantic(self, q: str, k: int = 20, type: str | None = None, sector: str | None = None) -> list[dict]:148        return self.get("search/semantic", q=q, k=k, type=type, sector=sector)["items"]149150    def sql(self, query: str, limit: int = 500) -> dict:151        """Requête de lecture DuckDB. Retour : {columns, rows, truncated, elapsed_ms}."""152        return self.request("POST", "sql", body={"sql": query, "limit": limit})153154    def sql_records(self, query: str, limit: int = 500) -> list[dict]:155        r = self.sql(query, limit)156        return [dict(zip(r["columns"], row)) for row in r["rows"]]157158    # ------------------------------------------------------------ utilitaires159    def _paginate(self, path: str, page_size: int, max_items: int | None, **filters) -> Iterator[dict]:160        offset, n = int(filters.pop("offset", 0) or 0), 0161        page_size = max(1, min(page_size, 500))162        while True:163            page = self.get(path, limit=page_size, offset=offset, **filters)164            items = page.get("items", [])165            for it in items:166                yield it167                n += 1168                if max_items and n >= max_items:169                    return170            offset += len(items)171            if not items or offset >= page.get("total", 0):172                return173174    @staticmethod175    def to_dataframe(records):176        """Convertit une liste/itérateur de dicts en DataFrame pandas (listes jointes par ' | ')."""177        import pandas as pd  # optionnel178        rows = []179        for r in records:180            rows.append({k: (" | ".join(map(str, v)) if isinstance(v, list) else v) for k, v in r.items()})181        return pd.DataFrame(rows)182183    @staticmethod184    def to_csv(records, path: str) -> int:185        rows = list(records)186        if not rows:187            return 0188        with open(path, "w", newline="", encoding="utf-8") as f:189            w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))190            w.writeheader()191            for r in rows:192                w.writerow({k: (" | ".join(map(str, v)) if isinstance(v, list) else v) for k, v in r.items()})193        return len(rows)194195196if __name__ == "__main__":  # petit test : python pdb_client.py VOTRE_CLE197    import sys198    c = PDB(sys.argv[1] if len(sys.argv) > 1 else None)199    print(json.dumps(c.health(), indent=2))200    ov = c.stats()["overview"]201    print(f"{ov['projects']:,} projets, {ov['mentions']:,} mentions, {ov['companies']} entreprises")202    for p in c.iter_projects(type="data_center", min_amount=5e8, sort="amount", max_items=5):203        print(f"  {p['ticker']:6s} {p['project_name'][:50]:50s} {p['total_amount_usd']/1e9:6.1f} G$")204