"""Google — Gemini API docs (models, pricing, release notes) + Google DeepMind and Google AI blog feeds.
Sources (tier 1):
* models → ai.google.dev/gemini-api/docs/models : model cards (name, endpoint id, status, description) + "Model | Endpoint" tables;
per-model pages followed (Property/Description table: model code, data types, token limits, capabilities, versions, cutoff)
* pricing → …/docs/pricing : one section per model (h2 + endpoint id) with Standard / Batch / Flex / Priority tables
(paid tier, USD per 1M tokens; long-context tiers and scheduled changes kept in `features`)
* release notes → …/docs/changelog : dated sections → ANNOUNCEMENT events
* DeepMind blog → deepmind.google/blog/rss.xml (organization google-deepmind)
* Google AI blog → blog.google/innovation-and-ai/technology/ai/rss/ (organization google)
"""
from __future__ import annotations
import re
from html import unescape as html_unescape
from typing import Any
from selectolax.parser import Node
from aiatlas.registry import org_ref, provider_ref
from aiatlas.sdk.connector import BaseConnector, Parsed, RunContext
from aiatlas.sdk.extract.dates import parse_datetime
from aiatlas.sdk.extract.html import node_text
from aiatlas.sdk.facts import EntityRef, Facts, Target
from aiatlas.sdk.fetch import FetchResult
from ._common import (
MODEL_WORDS,
RELEASE_WORDS,
announcement_events,
claim_api_aliases,
claim_modalities,
claim_status,
clean_cell,
model_ref,
money,
month_year,
normalize_capabilities,
slug_of,
tokens,
)
DOCS = "https://ai.google.dev/gemini-api/docs"
DEEPMIND_RSS = "https://deepmind.google/blog/rss.xml"
GOOGLE_AI_RSS = "https://blog.google/innovation-and-ai/technology/ai/rss/"
PROVIDER_KEY = "google-gemini-api"
ID_SCHEME = "gemini_model_id"
MAX_MODEL_PAGES = 45
ENDPOINT_ID = re.compile(r"^[a-z0-9][a-z0-9.\-]*$")
CAPABILITY = re.compile(r"(Audio generation|Caching|Code execution|Computer use|File search|Function calling|Grounding with Google Maps|Grounding with Google Search|"
r"Image generation|Live API|Search grounding|Structured outputs|Thinking|URL context|Batch API|Flex inference|Priority inference|Tuning|"
r"Video generation|Audio understanding|Image understanding)\s+(Supported|Not supported)(?:\s*\(([^)]*)\))?")
MODALITY_WORDS = {"text": "text", "image": "image", "images": "image", "video": "video", "audio": "audio", "pdf": "pdf", "code": "code", "embedding": "embedding"}
class GoogleConnector(BaseConnector):
name = "google"
label = "Google — Gemini API models, pricing, release notes; DeepMind & Google AI blogs"
description = "Gemini API developer docs (model cards, model pages, pricing, release notes) plus the Google DeepMind and Google AI blog feeds."
source_key = "ai.google.dev"
version = "1"
parser_version = "1"
interval_seconds = 3600
min_interval_seconds = 1800
rate_per_min = 15
tier = 1
priority = 0
expected_min_records = 30
concurrency = 2
async def discover(self, ctx: RunContext) -> list[Target]:
return [
Target(url=f"{DOCS}/models", doc_type="model_docs", key="models", min_bytes=5000),
Target(url=f"{DOCS}/pricing", doc_type="pricing", key="pricing", min_bytes=5000),
Target(url=f"{DOCS}/changelog", doc_type="listing", key="changelog", min_bytes=3000),
Target(url=DEEPMIND_RSS, doc_type="feed", key="deepmind_feed", min_bytes=1000),
Target(url=GOOGLE_AI_RSS, doc_type="feed", key="google_ai_feed", min_bytes=1000),
]
async def extract(self, ctx: RunContext, target: Target, res: FetchResult, parsed: Parsed) -> Facts:
facts = Facts()
google = org_ref("google")
key = target.key or ""
if key == "deepmind_feed" and parsed.kind == "feed":
deepmind = org_ref("google-deepmind")
facts.entities.append(deepmind)
announcement_events(facts, deepmind, parsed.feed_items, source_name="deepmind.google/blog", max_follow=15)
facts.document_title, facts.document_entity = "Google DeepMind blog", deepmind
return facts
facts.entities.append(google)
if key == "google_ai_feed" and parsed.kind == "feed":
announcement_events(facts, google, parsed.feed_items, source_name="blog.google/technology/ai", max_follow=10)
facts.document_title, facts.document_entity = "Google AI blog", google
elif key == "models" and parsed.html:
self._models(facts, google, parsed)
elif key == "pricing" and parsed.html:
self._pricing(facts, google, parsed)
elif key == "changelog" and parsed.html:
self._changelog(facts, google, parsed, res.final_url or res.url)
elif target.doc_type == "model_page" and parsed.html:
self._model_page(facts, google, target, parsed)
return facts
# ------------------------------------------------------------------------------------------ models overview
def _models(self, facts: Facts, org: EntityRef, parsed: Parsed) -> None:
html = parsed.html
assert html
facts.document_title, facts.document_entity = "Gemini models", org
ids: list[str] = []
for card in html.css("a.gemini-card-centered"):
href = card.attributes.get("href") or ""
api_id = slug_of(href)
h3 = card.css_first("h3")
if not h3 or not ENDPOINT_ID.match(api_id):
continue
name = _clean_name(node_text(h3))
status_node = card.css_first("p.status-subtext")
status = _status(node_text(status_node)) if status_node else None
# two cards may share a title (GA endpoint + its preview): the later one takes its status as suffix so alias
# resolution never fuses separately priced endpoints ("Gemini Omni Flash" / "Gemini Omni Flash Preview")
if any(e.entity_type == "model" and e.name.lower() == name.lower() and e.identifiers.get(ID_SCHEME) != api_id for e in facts.entities):
suffix = "Preview" if status == "preview" else api_id
name = f"{name} {suffix}" if not name.lower().endswith(suffix.lower()) else name
ref = _model(facts, org, name, api_id)
desc = card.css_first("p.description-centered")
facts.claim(ref, "description", node_text(desc) if desc else None)
claim_status(facts, ref, status)
if api_id not in ids:
ids.append(api_id)
api_aliases: dict[int, tuple[EntityRef, set[str]]] = {}
for t in html.tables:
hs = [clean_cell(h).lower() for h in t["headers"]]
if not hs or hs[0] != "model" or "endpoint" not in hs:
continue
i_end = hs.index("endpoint")
i_desc = hs.index("description") if "description" in hs else None
for r in t["rows"]:
if len(r) <= i_end:
continue
raw_name = clean_cell(r[0]).replace("\xa0", " ")
endpoints = [e for e in re.split(r"\s+", clean_cell(r[i_end])) if ENDPOINT_ID.match(e)]
if not endpoints:
continue
name = _clean_name(raw_name)
# the same display name may carry several endpoints (GA + preview / live variants); the first one is the identity,
# the others are recorded as a claim — never as aliases, which would merge separately priced endpoints
same_name = next((e for e in facts.entities if e.entity_type == "model" and e.name.lower() == name.lower()), None)
ref = same_name or _model(facts, org, name, endpoints[0])
extra = {*endpoints} - {ref.identifiers.get(ID_SCHEME, "")}
if extra:
api_aliases.setdefault(id(ref), (ref, set()))[1].update(extra)
note = re.search(r"\(([^)]*)\)", raw_name)
if note:
claim_status(facts, ref, _status(note.group(1)))
if i_desc is not None and i_desc < len(r):
facts.claim(ref, "description", clean_cell(r[i_desc]))
if endpoints[0] not in ids:
ids.append(endpoints[0])
for ref, extra in api_aliases.values():
claim_api_aliases(facts, ref, sorted(extra))
for api_id in ids[:MAX_MODEL_PAGES]:
facts.follow(f"{DOCS}/models/{api_id}", doc_type="model_page", key=f"model:{api_id}", meta={"api_id": api_id}, min_bytes=3000, priority=1)
# ------------------------------------------------------------------------------------------ model page
def _model_page(self, facts: Facts, org: EntityRef, target: Target, parsed: Parsed) -> None:
html = parsed.html
assert html
table = next((t for t in html.tables if [clean_cell(h).lower() for h in t["headers"]][:2] == ["property", "description"]), None)
if not table:
return
props: dict[str, str] = {}
for r in table["rows"]:
if len(r) >= 2:
label = re.sub(r"^[a-z0-9_]+\s+", "", clean_cell(r[0])) # drop the leading material-icon name ("id_card Model code")
props[label.lower().replace("[*]", "").strip()] = clean_cell(r[1])
api_id = props.get("model code") or target.meta.get("api_id")
if not api_id or not ENDPOINT_ID.match(api_id):
return
name = _clean_name(next((t for lvl, t in html.headings if lvl == 1), None) or api_id)
ref = _model(facts, org, name, api_id)
facts.document_title, facts.document_entity = name, ref
facts.claim(ref, "official_url", f"{DOCS}/models/{api_id}")
types = props.get("supported data types", "")
m = re.search(r"Inputs?\s+(.*?)\s+Outputs?\s+(.*)$", types, re.IGNORECASE)
if m:
claim_modalities(facts, ref, _modalities(m.group(1)), _modalities(m.group(2))) # pdf → document, images → image (ontology)
limits = props.get("token limits", "")
m_in = re.search(r"Input token limit\s+([\d,]+)", limits)
m_out = re.search(r"Output token limit\s+([\d,]+)", limits)
if m_in:
facts.claim(ref, "context_length", tokens(m_in.group(1)), unit="tokens")
if m_out:
facts.claim(ref, "max_output_tokens", tokens(m_out.group(1)), unit="tokens")
caps = props.get("capabilities", "")
if caps:
supported: dict[str, Any] = {}
for cap, state, detail in CAPABILITY.findall(caps):
supported[cap] = (detail or True) if state == "Supported" else False
if supported:
labels = sorted(k for k, v in supported.items() if v)
facts.claim(ref, "capabilities", normalize_capabilities(labels))
facts.claim(ref, "capabilities_raw", labels)
if "Function calling" in supported:
facts.claim(ref, "tool_calling", bool(supported["Function calling"]))
if "Structured outputs" in supported:
facts.claim(ref, "structured_output", bool(supported["Structured outputs"]))
if "Thinking" in supported:
facts.claim(ref, "reasoning", bool(supported["Thinking"]))
versions: dict[str, list[str]] = {}
for label, vid in re.findall(r"(Stable|Preview|Latest|Experimental):\s*([a-z0-9][a-z0-9.\-]*)", props.get("versions", "")):
versions.setdefault(label.lower(), []).append(vid)
facts.claim(ref, "versions", versions or None)
facts.claim(ref, "knowledge_cutoff", month_year(props.get("knowledge cutoff", "")))
facts.claim(ref, "latest_update", month_year(props.get("latest update", "")))
for label, prop in (("release date", "release_date"), ("deprecation date", "deprecation_date"), ("shutdown date", "retirement_date"), ("retirement date", "retirement_date")):
if props.get(label):
dt = parse_datetime(props[label])
facts.claim(ref, prop, dt.date().isoformat() if dt else None)
# ------------------------------------------------------------------------------------------ pricing
def _pricing(self, facts: Facts, org: EntityRef, parsed: Parsed) -> None:
html = parsed.html
assert html
provider = provider_ref(PROVIDER_KEY)
facts.entities.append(provider)
facts.document_title, facts.document_entity = "Gemini Developer API pricing", provider
for section in html.css("div.models-section"):
h2 = section.css_first("h2")
code = section.css_first("em a code") or section.css_first("em code")
if not h2 or not code:
continue
api_id = node_text(code)
if not ENDPOINT_ID.match(api_id):
continue
name = _clean_name(node_text(h2))
ref = _model(facts, org, name, api_id)
tiers = _tier_tables(section)
if not tiers:
continue
obs = facts.price(model=ref, provider=provider, provider_model_id=api_id, meta={"from": "pricing page", "tier": "paid, standard"})
per_unit = {k: v for k, v in tiers.items() if k.startswith("per_")}
for unit_key, rows in per_unit.items():
# per-second (Veo), per-request/song (Lyria), per-image tables: keep every row as stated
table: dict[str, list[str]] = {label: lines for label, _free, lines in rows if lines and not label.startswith("used to")}
if table:
obs.features[unit_key] = table
if unit_key == "per_request":
obs.per_request = next((money(lines[0]) for lines in table.values() if money(lines[0]) is not None), None)
if unit_key == "per_image":
obs.per_image = next((money(lines[0]) for lines in table.values() if money(lines[0]) is not None), None)
standard = tiers.get("standard") or next((v for k, v in tiers.items() if not k.startswith("per_")), None)
if standard:
_apply_paid_rows(obs, standard, prefix="")
if "batch" in tiers:
batch: dict[str, Any] = {}
_apply_paid_rows(batch, tiers["batch"], prefix="", as_dict=True)
obs.batch_input_per_mtok = batch.get("input_per_mtok")
obs.batch_output_per_mtok = batch.get("output_per_mtok")
for tier in ("flex", "priority"):
if tier in tiers:
extra: dict[str, Any] = {}
_apply_paid_rows(extra, tiers[tier], prefix="", as_dict=True)
for k in ("input_per_mtok", "output_per_mtok"):
if extra.get(k) is not None:
obs.features[f"{tier}_{k}"] = extra[k]
obs.features = {k: v for k, v in obs.features.items() if v not in (None, {}, [])}
facts.prices = [p for p in facts.prices if any(v is not None for v in p.price_tuple()[:-1]) or any(k.startswith("per_") for k in p.features)]
# ------------------------------------------------------------------------------------------ release notes
def _changelog(self, facts: Facts, org: EntityRef, parsed: Parsed, url: str) -> None:
html = parsed.html
assert html
facts.document_title, facts.document_entity = "Gemini API release notes", org
n_dates = 0
for h2 in html.css("h2"):
date = parse_datetime(node_text(h2)) if re.fullmatch(r"[A-Z][a-z]+ \d{1,2}, \d{4}", node_text(h2)) else None
if not date:
continue
n_dates += 1
if n_dates > 80:
break
anchor = h2.attributes.get("id") or date.date().isoformat()
sib = h2.next
i = 0
while sib is not None and sib.tag != "h2":
if sib.tag == "ul":
for li in sib.iter():
if li.tag != "li":
continue
strong = li.css_first("strong")
title = node_text(strong).rstrip(":") if strong else node_text(li)[:120]
text = node_text(li)
if not title:
continue
i += 1
codes = sorted({node_text(c) for c in li.css("code") if ENDPOINT_ID.match(node_text(c))})
is_release = bool(RELEASE_WORDS.search(text) and MODEL_WORDS.search(text)) or bool(re.search(r"\b(GA|generally available|released|preview|deprecat)", text, re.IGNORECASE))
facts.event("ANNOUNCEMENT", "release" if is_release else "company", f"Gemini API: {title}", entity=org, importance=2 if is_release else 1,
effective_at=date, dedupe_key=f"ANNOUNCEMENT:{url}#{anchor}:{i}", source_url=f"{url}#{anchor}",
meta={"source": "ai.google.dev/gemini-api/docs/changelog", "summary": text[:300], "models": codes[:10], "is_release": is_release})
sib = sib.next
# ---------------------------------------------------------------------------------------------- helpers
def _model(facts: Facts, org: EntityRef, name: str, api_id: str) -> EntityRef:
for e in facts.entities:
if e.entity_type == "model" and e.identifiers.get(ID_SCHEME) == api_id:
return e
# `gemini_model_id` stays the primary scheme (existing rows resolve on it); `google_model_id` is emitted alongside for the provider key
ref = model_ref(facts, name, org, api_id=api_id, provider_key="gemini", aliases=[api_id] if api_id != name else [], identifiers={"google_model_id": api_id})
facts.claim(ref, "api_model_id", api_id)
return ref
def _clean_name(name: str) -> str:
name = name.replace("\xa0", " ").replace("🍌", "").strip()
name = re.sub(r"\((Shut down|Deprecated|Retired)\)", "", name, flags=re.IGNORECASE).strip()
return re.sub(r"\s+", " ", name)
def _status(text: str) -> str | None:
low = (text or "").lower()
if "shut down" in low or "retired" in low:
return "retired"
if "deprecat" in low:
return "deprecated"
if "experimental" in low or "preview" in low:
return "preview"
if "stable" in low or "ga" == low.strip():
return "active"
return None
def _modalities(text: str) -> list[str]:
"""Raw modality words of a 'Supported data types' cell (canonicalised by `claim_modalities`: pdf → document)."""
out: list[str] = []
for tok in re.split(r"[,/]|\band\b", text.lower()):
tok = tok.strip().strip(".")
mod = MODALITY_WORDS.get(tok)
if mod and mod not in out:
out.append(mod)
return out
def _table_rows(table: Node) -> tuple[str, list[tuple[str, str, list[str]]]]:
"""(paid-tier unit: tokens|second|request|image, [(row label, free cell, paid cell lines)])"""
unit = "tokens"
for th in table.css("thead th"):
m = re.search(r"per\s+(1M tokens|second|request|image|minute)", node_text(th), re.IGNORECASE)
if m:
unit = m.group(1).lower().replace("1m ", "")
rows: list[tuple[str, str, list[str]]] = []
for tr in table.css("tbody tr"):
tds = tr.css("td")
if len(tds) < 2:
continue
label = node_text(tds[0]).lower()
free = node_text(tds[1]) if len(tds) >= 3 else ""
paid_html = tds[-1].html or ""
lines = [clean_cell(html_unescape(re.sub(r"<[^>]+>", " ", part))) for part in re.split(r"
", paid_html)]
rows.append((label, free, [ln for ln in lines if ln]))
return unit, rows
def _tier_tables(section: Node) -> dict[str, list[tuple[str, str, list[str]]]]:
"""Tables that follow one `div.models-section` until the next one: {tier: [(row label, free cell, paid cell lines)]}.
Token-priced models use a `devsite-selector` with one `section` per tier (Standard / Batch / Flex / Priority); per-second /
per-request models (Veo, Lyria…) have a single bare table, exposed under the pseudo tier `per_`."""
tiers: dict[str, list[tuple[str, str, list[str]]]] = {}
sib = section.next
while sib is not None:
if sib.tag == "div" and "models-section" in (sib.attributes.get("class") or ""):
break
if sib.tag in ("h2",):
break
if sib.tag != "-text":
sections = [sib] if sib.tag == "section" else sib.css("section")
for sec in sections:
h3 = sec.css_first("h3")
table = sec.css_first("table")
if not table:
continue
unit, rows = _table_rows(table)
tier = (node_text(h3).lower() if h3 else "standard").split()[0]
if unit != "tokens":
tier = f"per_{unit}"
if rows:
tiers.setdefault(tier, rows)
if not sections:
for table in ([sib] if sib.tag == "table" else sib.css("table")):
unit, rows = _table_rows(table)
if rows:
tiers.setdefault("standard" if unit == "tokens" else f"per_{unit}", rows)
sib = sib.next
return tiers
def _apply_paid_rows(target: Any, rows: list[tuple[str, str, list[str]]], *, prefix: str, as_dict: bool = False) -> None:
"""Map 'Input price' / 'Output price' / 'Context caching price' rows of a paid-tier table onto a PriceObs (or a dict)."""
def put(key: str, value: Any) -> None:
if value is None:
return
if as_dict:
target[key] = value
elif hasattr(target, key):
setattr(target, key, value)
else:
target.features[key] = value
unit_rx = re.compile(r"per (image|second|video|request|query|minute|song)|/\s*(image|second|sec|video|request|query|min)\b", re.IGNORECASE)
qualifier_rx = re.compile(r"\(([^)]*)\)")
for label, free, lines in rows:
if not lines:
continue
first = lines[0]
bare_first = qualifier_rx.sub("", first) # "(text / image / video)" is a modality list, not a unit
primary = money(bare_first) if not unit_rx.search(bare_first) else None
if re.match(r"(text )?(input|output) price", label):
kind = "input" if "input" in label.split(" price")[0] else "output"
put(f"{kind}_per_mtok", primary)
if unit_rx.search(bare_first) and re.search(r"per image|/\s*image", bare_first, re.IGNORECASE):
put("per_image", money(bare_first))
for ln in lines[1:]:
if ln.lower().startswith("equivalent"):
continue
v = money(qualifier_rx.sub("", ln)) if not unit_rx.search(qualifier_rx.sub("", ln)) else None
if v is None:
continue
qual = qualifier_rx.search(ln)
if re.search(r">\s*200k|>\s*128k|long context|prompts >", ln, re.IGNORECASE):
put(f"long_context_{kind}_per_mtok", v)
elif re.search(r"\bstarting\b", ln, re.IGNORECASE):
put(f"scheduled_{kind}_per_mtok", v)
put(f"scheduled_{kind}_effective", re.sub(r"^\$[\d.,]+\s*", "", qualifier_rx.sub("", ln)).strip())
elif qual:
mod = re.sub(r"[^a-z]+", "_", qual.group(1).lower()).strip("_")
put(f"{mod}_{kind}_per_mtok", v)
if "through" in bare_first.lower() and not as_dict:
target.features.setdefault("promotional_until", re.sub(r"^\$[\d.,]+\s*(through)?\s*", "", bare_first).strip().rstrip("."))
if free.lower().startswith("free of charge") and not as_dict:
target.features["free_tier"] = True
elif re.match(r"(image|audio|video) input price", label):
put(f"{label.split()[0]}_input_per_mtok", primary)
elif label.startswith("context caching price"):
for ln in lines:
v = money(ln)
if v is None:
continue
if "storage" in ln.lower():
put("cache_storage_per_mtok_hour", v)
break
if not (as_dict and "cached_input_per_mtok" in target) and not (not as_dict and target.cached_input_per_mtok is not None):
put("cached_input_per_mtok", v)
elif re.search(r">\s*200k|prompts >", ln, re.IGNORECASE):
put("long_context_cached_input_per_mtok", v)
elif label.startswith("grounding with google search"):
m = re.search(r"\$\s*([\d.]+)\s*(?:per|/)\s*1,?000", " ".join(lines))
if m:
put("search_grounding_per_1k_requests", float(m.group(1)))
CONNECTORS = [GoogleConnector]