{
 "id": "r1b1ad6f7951",
 "found": true,
 "parent_id": "refae296a3bf",
 "created_at": "2026-09-01 15:27:00.494567+00:00",
 "rows": [
  {
   "url": "https://raw.githubusercontent.com/KonstantinMB/exploreyc/master/backend/public_api.py",
   "content_excerpt": "\"\"\"\nExploreYC Public API \u2014 a mounted FastAPI sub-app served at /api/v1.\n\nIsolated from the internal app so its OpenAPI (/api/v1/docs, /api/v1/openapi.json)\nexposes ONLY the public read-only endpoints and gets its own permissive CORS. Every\nroute requires a valid API key (Authorization: Bearer eyc_live_\u2026 or X-API-Key) and is\nrate-limited per key against the DB-backed usage log (plans in backend/plans.py).\n\nMounted from main.py:  app.mount(\"/api/v1\", create_public_api(db, company_cache))\n\"\"\"\n\nfrom datetime import datetime, timedelta, timezone\nfrom typing import List, Optional\n\nfrom fastapi import FastAPI, APIRouter, Depends, Header, HTTPException, Query, Request\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi.security import HTTPBearer\nfrom pydantic import BaseModel\n\nfrom password_utils import hash_token\nfrom plans import plan_limit\n\nRATE_WINDOW = timedelta(hours=24)\nMAX_PAGE = 100\n\n\ndef _to_epoch(value) -> Optional[int]:\n    \"\"\"Normalize a DB timestamp (SQLite 'YYYY-MM-DD HH:MM:SS' str or PG datetime) to unix seconds (UTC).\"\"\"\n    if value is None:\n        return None\n    if isinstance(value, str):\n        try:\n            dt = datetime.strptime(value, \"%Y-%m-%d %H:%M:%S\").replace(tzinfo=timezone.utc)\n        except ValueError:\n            return None\n    else:\n        dt = value if value.tzinfo else value.replace(tzinfo=timezone.utc)\n    return int(dt.timestamp())\n\n\nclass PublicCompany(BaseModel):\n    id: int\n    source: Optional[str] = None\n    source_id: Optional[str] = None\n    name: Optional[str] = None\n    slug: Optional[str] = None\n    website: Optional[str] = None\n    one_liner: Optional[str] = None\n    long_description: Optional[str] = None\n    team_size: Optional[int] = None\n    batch: Optional[str] = None\n    status: Optional[str] = None\n    industry: Optional[str] = None\n    subindustry: Optional[str] = None\n    all_locations: Optional[str] = None\n    is_hiring: Optional[bool] = None\n    top_company: Optional[bool] = None\n    nonprofit: Optional[bool] = None\n    stage: Optional[str] = None\n    country: Optional[str] = None\n    latitude: Optional[float] = None\n    longitude: Optional[float] = None\n    small_logo_thumb_url: Optional[str] = None\n    founders: Optional[str] = None\n    year_founded: Optional[int] = None\n    exit_type: Optional[str] = None\n    acquirer: Optional[str] = None\n    ticker_symbol: Optional[str] = None\n    funded_date: Optional[str] = None\n    source_url: Optional[str] = None\n    funding_total_usd: Optional[float] = None\n    funding_last_round_usd: Optional[float] = None\n    funding_last_round_name: Optional[str] = None\n    funding_last_round_date: Optional[str] = None\n    valuation_usd: Optional[float] = None\n    employee_count: Optional[int] = None\n    employee_growth_6m: Optional[float] = None\n    # Postgres returns datetime objects (SQLite returns strings) \u2014 accept both, serialize to ISO\n    created_at: Optional[datetime] = None\n    updated_at: Optional[datetime] = None\n\n    class Config:\n        extra = \"ignore\"  # cache dicts carry internal fields (raw_json, \u2026) \u2014 dropped from the public shape\n\n\nclass CompanyListResponse(BaseModel):\n    companies: List[PublicCompany]\n    total: int\n    limit: int\n    offset: int\n    has_more: bool\n\n\ndef create_public_api(db, company_cache) -> FastAPI:\n    api = FastAPI(\n        title=\"ExploreYC Public API\",\n        version=\"1.0.0\",\n        description=(\n            \"Read-only programmatic access to the ExploreYC dataset: Y Combinator and a16z \"\n            \"portfolio companies with funding, stage, and exit data.\\n\\n\"\n            \"**Auth:** send your key as `Authorization: Bearer eyc_live_\u2026` (or `X-API-Key`). \"\n            \"Create a key at https://exploreyc.com/dashboard.\\n\\n\"\n            \"**Rate limits:** per key, rolling 24h. Free = 5 requests/day. \"\n            \"Responses include `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`.\"\n        ),\n        docs_url=\"/docs\",\n        redoc_url=\"/redoc\",\n        openapi_url=\"/openapi.json\",\n    )\n\n    # Public API is server-to-server with an API key (no cookies) -> wildcard origins,\n    # no credentials. (You cannot combine '*' with allow_credentials=True.)\n    api.add_middleware(\n        CORSMiddleware,\n        allow_origins=[\"*\"],\n        allow_credentials=False,\n        allow_methods=[\"GET\", \"OPTIONS\"],\n        allow_headers=[\"Authorization\", \"X-API-Key\", \"Content-Type\"],\n    )\n\n    bearer = HTTPBearer(auto_error=False, description=\"Your ExploreYC API key (eyc_live_\u2026)\")\n\n    def verify_api_key(\n        request: Request,\n        authorization: Optional[str] = Header(None),\n        x_api_key: Optional[str] = Header(None),\n        _scheme=Depends(bearer),  # populates the Swagger \"Authorize\" box\n    ) -> dict:\n        raw = x_api_key\n        if not raw and authorization and authorization.startswith(\"Bearer \"):\n            raw = authorization[7:].strip()\n        if not raw:\n            raise HTTPException(status_code=401, detail=\"Missing API key. Send 'Authorization: Bearer <key>' or 'X-API-Key'.\")\n\n        row = db.get_api_key_by_hash(hash_token(raw))\n        if not row or not row.get(\"is_active\") or row.get(\"user_status\") != \"active\":\n            raise HTTPException(status_code=401, detail=\"Invalid, revoked, or suspended API key.\")\n\n        now = datetime.now(timezone.utc)\n        now_epoch = int(now.timestamp())\n        limit = plan_limit(row.get(\"plan\"))\n        used, oldest = db.count_api_usage_since(row[\"id\"], now - RATE_WINDOW)\n        reset = (_to_epoch(oldest) or now_epoch) + int(RATE_WINDOW.total_seconds())\n        if used >= limit:\n            raise HTTPException(\n                status_code=429, detail=\"Rate limit exceeded for your plan. Upgrade for a higher limit.\",\n                headers={\"X-RateLimit-Limit\": str(limit), \"X-RateLimit-Remaining\": \"0\",\n                         \"X-RateLimit-Reset\": str(reset), \"Retry-After\": str(max(1, reset - now_epoch))},\n            )\n\n        # Allowed: stamp state so the middleware logs usage + sets headers after the response.\n        request.state.api_key_id = row[\"id\"]\n        request.state.rate_limit = limit\n        request.state.rate_remaining = max(0, limit - used - 1)\n        request.state.rate_reset = reset\n        return row\n\n    @api.middleware(\"http\")\n    async def usage_and_headers(request: Request, call_next):\n        response = await call_next(request)\n        key_id = getattr(request.state, \"api_key_id\", None)\n        if key_id is not None:\n            try:\n                db.log_api_usage(key_id, request.url.path, response.status_code)\n            except Exception:\n                pass  # never fail a request because usage logging hiccuped\n            response.headers[\"X-RateLimit-Limit\"] = str(getattr(request.state, \"rate_limit\", \"\"))\n            response.headers[\"X-RateLimit-Remaining\"] = str(getattr(request.state, \"rate_remaining\", \"\"))\n            response.headers[\"X-RateLimit-Reset\"] = str(getattr(request.state, \"rate_reset\", \"\"))\n        return response\n\n    v1 = APIRouter(dependencies=[Depends(verify_api_key)])\n\n    @v1.get(\"/companies\", response_model=CompanyListResponse, tags=[\"Companies\"], summary=\"List / filter companies\")\n    def list_companies(\n        limit: int = Query(50, ge=1, le=MAX_PAGE),\n        offset: int = Query(0, ge=0),\n        source: Optional[str] = Query(\"all\", description=\"'yc', 'a16z', or 'all' (default)\"),\n        batch: Optional[str] = None,\n        industry: Optional[str] = None,\n        country: Optional[str] = None,\n        is_hiring: Optional[bool] = None,\n        top_company: Optional[bool] = None,\n        search: Optional[str] = None,\n    ):\n        kwargs = dict(batch=batch, is_hiring=is_hiring, industry=industry, country=country,\n                      search=search, top_company=top_company, source=source)\n        companies = company_cache.get_companies(limit=limit, offset=offset, **kwargs)\n        total = company_cache.count_companies(**kwargs)\n        return {\"companies\": companies, \"total\": total, \"limit\": limit, \"offset\": offset,\n                \"has_more\": (offset + limit) < total}\n\n    @v1.get(\"/companies/{company_id}\", response_model=PublicCompany, tags=[\"Companies\"], summary=\"Company by id\")\n    def get_company(company_id: int):\n        company = company_cache.get_company_by_id(company_id)\n        if not company and hasattr(db, \"get_company_by_id\"):\n            company = db.get_company_by_id(company_id)\n        if not company:\n            raise HTTPException(status_code=404, detail=\"Company not found\")\n        return company\n\n    @v1.get(\"/companies/slug/{slug}\", response_model=PublicCompany, tags=[\"Companies\"], summary=\"Company by slug\")\n    def get_company_by_slug(slug: str):\n        company = db.get_company_by_slug(slug) if hasattr(db, \"get_company_by_slug\") else None\n        if not company:\n            match = [c for c in company_cache.get_companies(limit=1, offset=0, source=\"all\", search=None)\n                     if c.get(\"slug\") == slug]\n            company = match[0] if match else None\n        if not company:\n            raise HTTPException(status_code=404, detail=\"Company not found\")\n        return company\n\n    @v1.get(\"/search\", response_model=CompanyListResponse, tags=[\"Companies\"], summary=\"Full-text company search\")\n    def search_companies(q: str = Query(..., min_length=1), limit: int = Query(50, ge=1, le=MAX_PAGE),\n                         offset: int = Query(0, ge=0), source: Optional[str] = Query(\"all\")):\n        companies = company_cache.get_companies(limit=limit, offset=offset, search=q, source=source)\n        total = company_cache.count_companies(search=q, source=source)\n        return {\"companies\": companies, \"total\": total, \"limit\": limit, \"offset\": offset,\n                \"has_more\": (offset + limit) < total}\n\n    @v1.get(\"/stats\", tags=[\"Analytics\"], summary=\"Portfolio stats (Y Combinator)\")\n    def stats():\n        return company_cache.get_stats()\n\n    @v1.get(\"/sources\", tags=[\"Metadata\"], summary=\"Available sources (incubators / VCs)\")\n    def sources():\n        return {\"sources\": company_cache.get_sources()}\n\n    @v1.get(\"/batches\", tags=[\"Metadata\"], summary=\"Distinct YC batches\")\n    def batches():\n        return {\"batches\": company_cache.get_unique_batches()}\n\n    @v1.get(\"/industries\", tags=[\"Metadata\"], summary=\"Distinct industries\")\n    def industries():\n        return {\"industries\": company_cache.get_unique_industries()}\n\n    @v1.get(\"/countries\", tags=[\"Metadata\"], summary=\"Distinct countries\")\n    def countries():\n        return {\"countries\": company_cache.get_unique_countries()}\n\n    @v1.get(\"/map\", tags=[\"Analytics\"], summary=\"Geo-located companies\")\n    def geo(batch: Optional[str] = None, is_hiring: Optional[bool] = None):\n        companies = company_cache.get_companies_for_map(batch=batch, is_hiring=is_hiring)\n        return {\"companies\": companies, \"total\": len(companies)}\n\n    @v1.get(\"/batch/{batch_name}/wrapped\", tags=[\"Analytics\"], summary=\"Batch 'wrapped' analytics\")\n    def batch_wrapped(batch_name: str):\n        if not hasattr(db, \"get_batch_wrapped_stats\"):\n            raise HTTPException(status_code=501, detail=\"Batch analytics unavailable on this deployment\")\n        data = db.get_batch_wrapped_stats(batch_name)\n        if not data:\n            raise HTTPException(status_code=404, detail=\"Batch not found\")\n        return data\n\n    @v1.get(\"/founders\", tags=[\"Founders\"], summary=\"Founder leaderboards (ranked)\")\n    def list_founders(\n        metric: str = Query(\"funded\", description=\"Ranking metric: serial | funded | exits | unicorns\"),\n        batch: Optional[str] = Query(None, description=\"Filter to a YC batch, e.g. 'Winter 2012'\"),\n        limit: int = Query(50, ge=1, le=MAX_PAGE),\n        offset: int = Query(0, ge=0),\n    ):\n        \"\"\"Ranked Y Combinator founders. `funded` = total raised across their companies,\n        `serial` = most YC companies, `exits` = biggest exit, `unicorns` = $1B+ valuations.\n        Each row includes the founder, their derived stats, and their rank.\"\"\"\n        if not hasattr(db, \"get_founder_leaderboard\"):\n            raise HTTPException(status_code=501, detail=\"Founder data unavailable on this deployment\")\n        try:\n            data = db.get_founder_leaderboard(metric, batch=batch, limit=limit, offset=offset)\n        except ValueError:\n            raise HTTPException(status_code=400,\n                                detail=\"metric must be one of: serial, funded, exits, unicorns\")\n        results = [{\"rank\": offset + i + 1, **r} for i, r in enumerate(data.get(\"results\", []))]\n        total = data.get(\"total\", 0)\n        return {\"founders\": results, \"metric\": metric, \"total\": total, \"limit\": limit,\n                \"offset\": offset, \"has_more\": (offset + limit) < total}\n\n    @v1.get(\"/founders/{slug}\", tags=[\"Founders\"], summary=\"Founder profile + stats + companies\")\n    def get_founder(slug: str):\n        \"\"\"A single founder: identity, derived stats, the YC companies they founded, every\n        leaderboard rank they hold, and (web-sourced) enrichment if available.\"\"\"\n        if not hasattr(db, \"get_founder_by_slug\"):\n            raise HTTPException(status_code=501, detail=\"Founder data unavailable on this deployment\")\n        founder = db.get_founder_by_slug(slug)\n        if not founder:\n            raise HTTPException(status_code=404, detail=\"Founder not found\")\n        return founder\n\n    api.include_router(v1)\n    return api\n"
  }
 ],
 "notes": [
  "WARNING: 5 of 6 URLs failed and were skipped: https://api.exploreyc.com/api/v1/batches, https://api.exploreyc.com/api/v1/companies, https://api.exploreyc.com/api/v1/companies?limit=100, https://api.exploreyc.com/api/v1/search?query=airbnb, https://api.exploreyc.com/api/v1/search?q=airbnb",
  "Fetch result set: refae296a3bf (1 rows), reference it as FROM refae296a3bf in follow-up queries"
 ]
}