"""GitHub — seed list of AI repositories (registry/repositories.yaml), server-rendered pages only (no API). Per repository (tier 2): * https://github.com// → the repository overview embeds `react-app.embeddedData` JSON (`payload.sidebarAbout`: description, website, topics, stargazerCount, forksCount, license, release count; `repo.createdAt`). CSS fallbacks: `#repo-stars-counter-star[title]`, `#repo-network-counter[title]`, `[data-content$=" license"]`, `a[href^="/topics/"]`. The language bar is client-loaded → no `language` claim. * https://github.com///releases.atom → `latest_version` / `latest_release_at` (VERSION_RELEASED emitted by the writer on change) + one RELEASE event per feed entry (dedupe key = release URL). * https://raw.githubusercontent.com///HEAD/README.md → archived text for later LLM summarisation (deterministic: title only). Entity type follows the canonical kind (aiatlas.ontology.taxonomy.normalize_framework_kind): `agent` for agents (with `agent_kind`), `tool` for tool | application | mcp-server, `repository` for model-code releases, `framework` for everything else (libraries, SDKs, inference engines…). Identifiers `{"github_repo": "owner/name"}` (+ `pypi` when the seed maps a package) so the PyPI connector merges into the same entity. `kind` is the canonical value, `kind_raw` the seed label when it differed; `license` is the ontology key. """ from __future__ import annotations import re from datetime import UTC from typing import Any from aiatlas.ontology.licenses import normalize_license from aiatlas.ontology.taxonomy import normalize_framework_kind from aiatlas.registry import load, org_by_github, org_ref, organizations from aiatlas.sdk.connector import BaseConnector, Parsed, RunContext from aiatlas.sdk.extract.dates import parse_datetime from aiatlas.sdk.extract.feeds import FeedItem from aiatlas.sdk.extract.html import find_in_json from aiatlas.sdk.facts import EntityRef, Facts, Target from aiatlas.sdk.fetch import FetchResult GH = "https://github.com" RAW = "https://raw.githubusercontent.com" TOOL_KINDS = {"tool", "application", "mcp-server"} VERSION_TAG = re.compile(r"^v?(\d+(?:\.\d+)+(?:[-+.][0-9A-Za-z.]+)?)$") def repo_entries() -> list[dict[str, Any]]: return load("repositories") def entity_type_for(kind: str | None) -> str: """Canonical kind → entity type (agents and tools get their own surfaces; model code stays a repository).""" canon = normalize_framework_kind(kind) if kind else None if kind == "model": return "repository" if canon == "agent": return "agent" if canon in TOOL_KINDS: return "tool" return "framework" def claim_license(facts: Facts, ref: EntityRef, raw: str | None, *, prop: str = "license") -> str | None: if not raw: return None key = normalize_license(raw) facts.claim(ref, prop, key or raw) if key != raw: facts.claim(ref, f"{prop}_raw", raw) return key def repo_entity(facts: Facts, entry: dict[str, Any]) -> EntityRef: """One EntityRef per seed repository — shared by the GitHub and PyPI connectors so both resolve to the same row.""" owner, name = entry["repo"].split("/", 1) kind = entry.get("kind") etype = entity_type_for(kind) for e in facts.entities: if e.entity_type == etype and e.identifiers.get("github_repo") == entry["repo"]: return e ids = {"github_repo": entry["repo"]} if entry.get("pypi"): ids["pypi"] = entry["pypi"] org = None if entry.get("organization") in organizations(): org = org_ref(entry["organization"]) else: known = org_by_github(owner) if known: org = org_ref(known["key"]) aliases = [entry["repo"], entry["key"]] + ([entry["pypi"]] if entry.get("pypi") else []) ref = facts.entity(etype, name, identifiers=ids, organization=org, aliases=[a for a in aliases if a != name], slug_hint=entry["key"], identity_confidence="high") canon = normalize_framework_kind(kind) if kind and kind != "model" else kind facts.claim(ref, "kind", canon or kind) if canon and canon != kind: facts.claim(ref, "kind_raw", kind) if etype == "agent" and entry.get("agent_kind"): facts.claim(ref, "agent_kind", entry["agent_kind"]) facts.claim(ref, "repository_url", f"{GH}/{entry['repo']}") if entry.get("pypi"): facts.claim(ref, "pypi", entry["pypi"]) if org: facts.entities.append(org) facts.relate(org, "develops", ref) return ref class GitHubConnector(BaseConnector): name = "github" label = "GitHub — AI frameworks, runtimes, agents and model repositories" description = "Repository pages, release feeds and raw READMEs of the seed list in registry/repositories.yaml (HTML only, no API)." source_key = "github.com" version = "2" parser_version = "2" interval_seconds = 12 * 3600 min_interval_seconds = 6 * 3600 max_interval_seconds = 3 * 86400 rate_per_min = 20 tier = 2 priority = 1 expected_min_records = 40 concurrency = 2 async def discover(self, ctx: RunContext) -> list[Target]: out: list[Target] = [] for entry in repo_entries(): repo = entry["repo"] meta = {"repo": repo, "key": entry["key"]} out.append(Target(url=f"{GH}/{repo}", doc_type="repository", key=f"repo:{repo}", meta=meta, min_bytes=20000, priority=1)) out.append(Target(url=f"{GH}/{repo}/releases.atom", doc_type="feed", key=f"releases:{repo}", meta=meta, min_bytes=200, priority=2)) out.append(Target(url=f"{RAW}/{repo}/HEAD/README.md", doc_type="readme", key=f"readme:{repo}", meta={**meta, "content_type": "text/markdown", "llm_task": "repository_summary"}, min_bytes=200, priority=3, rate_per_min=30)) return out async def extract(self, ctx: RunContext, target: Target, res: FetchResult, parsed: Parsed) -> Facts: facts = Facts() entry = next((e for e in repo_entries() if e["repo"] == target.meta.get("repo")), None) if not entry: return facts ref = repo_entity(facts, entry) key = target.key or "" if key.startswith("repo:") and parsed.html: self._repo_page(facts, ref, entry, parsed) elif key.startswith("releases:") and parsed.kind == "feed": self._releases(facts, ref, entry, parsed.feed_items) elif key.startswith("readme:") and parsed.markdown: h1 = next((t for lvl, t in parsed.markdown.headings if lvl == 1), None) facts.claim(ref, "readme_url", target.url) facts.document_title = h1 or f"{entry['repo']} README" facts.document_entity = ref return facts # ------------------------------------------------------------------------------------------ repository page def _repo_page(self, facts: Facts, ref: EntityRef, entry: dict[str, Any], parsed: Parsed) -> None: html = parsed.html assert html about: dict[str, Any] = {} repo_meta: dict[str, Any] = {} for blob in html.embedded_json.values(): hits = find_in_json(blob, "sidebarAbout", max_hits=1) if hits and isinstance(hits[0], dict): about = hits[0] layout = find_in_json(blob, "codeViewLayoutRoute", max_hits=1) if layout and isinstance(layout[0], dict) and isinstance(layout[0].get("repo"), dict): repo_meta = layout[0]["repo"] break description = about.get("description") or _og_description(html, entry["repo"]) facts.claim(ref, "description", description[:2000] if description else None) website = about.get("website") facts.claim(ref, "homepage", website if isinstance(website, str) and website.startswith("http") else None) topics = [t.get("name") for t in about.get("topics") or [] if isinstance(t, dict) and t.get("name")] if not topics: topics = [href.rsplit("/topics/", 1)[1] for href, _ in html.links if "/topics/" in href][:30] facts.claim(ref, "topics", topics[:30]) stars = about.get("stargazerCount") if stars is None: stars = _counter(html, "#repo-stars-counter-star") forks = about.get("forksCount") if forks is None: forks = _counter(html, "#repo-network-counter") facts.claim(ref, "metric.stars", stars) facts.claim(ref, "metric.forks", forks) facts.claim(ref, "metric.watchers", about.get("watcherCount")) sections = about.get("sections") or {} rel = sections.get("releases") if isinstance(sections, dict) else None if isinstance(rel, dict): facts.claim(ref, "metric.releases", rel.get("releaseCount")) facts.claim(ref, "metric.tags", rel.get("tagCount")) lic = ((about.get("repo") or {}).get("license") or {}) if isinstance(about.get("repo"), dict) else {} spdx = lic.get("spdxId") if isinstance(lic, dict) else None if not spdx: node = next((n for n in html.css("[data-content]") if (n.attributes.get("data-content") or "").endswith(" license")), None) spdx = node.attributes["data-content"].removesuffix(" license").strip() if node else None claim_license(facts, ref, spdx if spdx and spdx.upper() != "NOASSERTION" else None) facts.claim(ref, "license_name", lic.get("name") if isinstance(lic, dict) else None) created = parse_datetime(repo_meta.get("createdAt")) if repo_meta.get("createdAt") else None facts.claim(ref, "created_at", created.astimezone(UTC).isoformat(timespec="seconds") if created else None) facts.claim(ref, "default_branch", repo_meta.get("defaultBranch")) arch = (about.get("repo") or {}).get("isArchived") if isinstance(about.get("repo"), dict) else None if arch is True: facts.claim(ref, "status", "archived") facts.document_title = html.title # ------------------------------------------------------------------------------------------ releases feed def _releases(self, facts: Facts, ref: EntityRef, entry: dict[str, Any], items: list[FeedItem]) -> None: items = [i for i in items if i.url and i.title] if not items: return items.sort(key=lambda i: (i.updated_at or i.published_at or _EPOCH), reverse=True) latest = next((i for i in items if VERSION_TAG.match(i.title.strip())), items[0]) m = VERSION_TAG.match(latest.title.strip()) facts.claim(ref, "latest_version", m.group(1) if m else latest.title.strip()[:100]) facts.claim(ref, "latest_release_tag", latest.title.strip()[:100]) when = latest.updated_at or latest.published_at facts.claim(ref, "latest_release_at", when.astimezone(UTC).isoformat(timespec="seconds") if when else None) facts.claim(ref, "releases_url", f"{GH}/{entry['repo']}/releases") for it in items[:30]: when = it.updated_at or it.published_at facts.event("RELEASE", "release", f"{entry['repo']} released {it.title.strip()}", entity=ref, importance=1, effective_at=when, dedupe_key=f"RELEASE:{it.url}", source_url=it.url, meta={"tag": it.title.strip()[:100], "authors": it.authors[:3], "summary": (it.summary or "")[:300]}) facts.document_title = f"{entry['repo']} releases" def _counter(html: Any, selector: str) -> int | None: node = html.css_first(selector) if node is None: return None raw = (node.attributes.get("title") or node.text(strip=True) or "").replace(",", "") return int(raw) if raw.isdigit() else None def _og_description(html: Any, repo: str) -> str | None: d = html.og.get("og:description") or html.description or "" d = re.sub(rf"\s*-\s*{re.escape(repo)}\s*$", "", d).strip() return d or None from datetime import datetime _EPOCH = datetime(1970, 1, 1, tzinfo=UTC) CONNECTORS = [GitHubConnector]