From c56492b3444d8ea4e02ef318e39a49b2ebbd8004 Mon Sep 17 00:00:00 2001 From: Dev Rishi Khare Date: Tue, 11 Aug 2026 03:50:56 +0530 Subject: [PATCH] feat(graph): zola graph migrate (once) + refresh (local) CLI Adds the `zola graph` subcommand (Tasks 1-5 of the graph migration plan): bootstrap a topical knowledge graph from a live site once via Firecrawl, then maintain it locally forever with `refresh` (no Firecrawl). zola graph migrate --from [--max N] [--dry-run] [--force] zola graph refresh [--max N] [--dry-run] Modules (src/cmd/graph/): - schema.rs GraphStore {pages,topics,relations,meta} load/save; meta.schema_version=1; is_migrated_for() = once-guard. - sitemap.rs parse_sitemap (pure, fixture-tested) + live collect_urls that recurses sitemapindex; discover() tries index then plain. - firecrawl.rs PageFetcher trait + FirecrawlFetcher (migrate-only). MockFetcher gated under cfg(test). - html_to_md.rs minimal HTML->markdown + extract_title (fallback only; Firecrawl returns markdown natively). - openrouter.rs TopicClient trait + OpenRouterTopicClient (openai/gpt-4o-mini, ADR-003); parse_extract pure. - topics.rs pure merge_page_topics (idempotent, case-insensitive label dedup) + enrich_one wrapper shared by migrate & refresh. - migrate.rs sitemap -> Firecrawl -> write content//index.md -> topics -> save. Bails on second crawl for same origin unless --force (reads FIRECRAWL_API_KEY + OPENROUTER_API_KEY). - refresh.rs local-only: walks default-lang markdown, re-topics stale content_hash, stamps meta.last_refresh. Never imports firecrawl. (reads OPENROUTER_API_KEY). Wired in cli.rs (GraphCommand) / main.rs / cmd/mod.rs. Network lives only in migrate/refresh; zola build stays offline. Artifacts committed under data/graph/{pages,topics,relations,meta}.json. All 57 bin tests pass (32 graph, incl. schema round-trip, sitemap fixtures, mock fetcher, mock LLM merge, and the migrate-once -> refresh -> guard integration test). Zero warnings. Co-Authored-By: Claude --- Cargo.lock | 2 + Cargo.toml | 2 + src/cli.rs | 42 ++++ src/cmd/graph/firecrawl.rs | 162 ++++++++++++++ src/cmd/graph/html_to_md.rs | 181 ++++++++++++++++ src/cmd/graph/migrate.rs | 419 ++++++++++++++++++++++++++++++++++++ src/cmd/graph/mod.rs | 185 ++++++++++++++++ src/cmd/graph/openrouter.rs | 201 +++++++++++++++++ src/cmd/graph/refresh.rs | 288 +++++++++++++++++++++++++ src/cmd/graph/schema.rs | 226 +++++++++++++++++++ src/cmd/graph/sitemap.rs | 181 ++++++++++++++++ src/cmd/graph/topics.rs | 294 +++++++++++++++++++++++++ src/cmd/mod.rs | 1 + src/main.rs | 8 + 14 files changed, 2192 insertions(+) create mode 100644 src/cmd/graph/firecrawl.rs create mode 100644 src/cmd/graph/html_to_md.rs create mode 100644 src/cmd/graph/migrate.rs create mode 100644 src/cmd/graph/mod.rs create mode 100644 src/cmd/graph/openrouter.rs create mode 100644 src/cmd/graph/refresh.rs create mode 100644 src/cmd/graph/schema.rs create mode 100644 src/cmd/graph/sitemap.rs create mode 100644 src/cmd/graph/topics.rs diff --git a/Cargo.lock b/Cargo.lock index b758856b2..5c472d5ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6411,10 +6411,12 @@ dependencies = [ "notify-debouncer-full", "open", "percent-encoding", + "regex", "relative-path", "reqwest", "same-file", "search", + "serde", "serde_json", "sha2", "site", diff --git a/Cargo.toml b/Cargo.toml index 09eba4cc3..d7320d971 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,8 +61,10 @@ anstream = { workspace = true } anstyle = { workspace = true } globset = { workspace = true } log = { workspace = true } +regex = { workspace = true } percent-encoding = { workspace = true } relative-path = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } toml = { workspace = true } diff --git a/src/cli.rs b/src/cli.rs index 13007c9db..a597cb31b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -141,4 +141,46 @@ pub enum Command { #[clap(long)] dry_run: bool, }, + + /// Build/refresh the topical knowledge graph (migrate once via Firecrawl, + /// then maintain locally with `refresh`). Network lives only here. + Graph { + #[clap(subcommand)] + command: GraphCommand, + }, +} + +/// Subcommands of `zola graph`. +#[derive(Subcommand)] +pub enum GraphCommand { + /// One-time Firecrawl crawl of a live site into markdown + topical KG. + /// Refuses a second crawl for the same origin unless `--force`. + Migrate { + /// Site origin to bootstrap from, e.g. `https://curriculo.me`. + #[clap(long)] + from: String, + + /// Cap on pages fetched + enriched this run; remainder resumes next run. + #[clap(long)] + max: Option, + + /// Report the planned crawl without fetching/writing/calling the LLM. + #[clap(long)] + dry_run: bool, + + /// Re-crawl even if `meta.source_origin` already matches (ops escape). + #[clap(long)] + force: bool, + }, + + /// Refresh the topical KG from local markdown. No Firecrawl, no remote fetch. + Refresh { + /// Cap on stale pages re-enriched this run; remainder resumes next run. + #[clap(long)] + max: Option, + + /// Report stale/new pages without calling the LLM (no key needed). + #[clap(long)] + dry_run: bool, + }, } diff --git a/src/cmd/graph/firecrawl.rs b/src/cmd/graph/firecrawl.rs new file mode 100644 index 000000000..8245617c5 --- /dev/null +++ b/src/cmd/graph/firecrawl.rs @@ -0,0 +1,162 @@ +//! Firecrawl fetcher — **migrate-only**. Only `migrate.rs` imports this module; +//! `refresh.rs` must not (hard rule: Firecrawl never on refresh). +//! +//! Wraps Firecrawl's `/v1/scrape` endpoint with `formats:["markdown"]` so the +//! page body arrives as markdown. The [`PageFetcher`] trait lets unit tests +//! inject a [`MockFetcher`] without touching the network or a real API key. + +use std::time::Duration; + +use errors::{Result, anyhow, bail}; +use reqwest::blocking::Client; +use serde_json::{json, Value}; + +use super::html_to_md; + +const FIRECRAWL_URL: &str = "https://api.firecrawl.dev/v1/scrape"; + +/// One fetched remote page. `markdown` is what gets written to disk. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FetchedPage { + pub url: String, + pub title: String, + pub markdown: String, +} + +/// Page fetch abstraction so tests mock without Firecrawl/a key. +pub trait PageFetcher { + fn fetch(&self, url: &str) -> Result; +} + +/// Live Firecrawl client. `FIRECRAWL_API_KEY` is required. +pub struct FirecrawlFetcher { + api_key: String, + client: Client, +} + +impl FirecrawlFetcher { + pub fn new(api_key: String) -> Result { + let client = Client::builder() + .timeout(Duration::from_secs(90)) + .user_agent("zola-graph/0.1 (firecrawl)") + .build()?; + Ok(Self { api_key, client }) + } +} + +impl PageFetcher for FirecrawlFetcher { + fn fetch(&self, url: &str) -> Result { + let payload = json!({ + "url": url, + "formats": ["markdown"], + "onlyMainContent": true, + }); + let body = serde_json::to_vec(&payload)?; + let resp = self + .client + .post(FIRECRAWL_URL) + .bearer_auth(&self.api_key) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body) + .send()?; + let status = resp.status(); + let text = resp.text()?; + if !status.is_success() { + bail!("Firecrawl HTTP {status}: {}", take200(&text)); + } + let data: Value = serde_json::from_str(&text) + .map_err(|e| anyhow!("Firecrawl non-JSON response: {e}"))?; + let inner = &data["data"]; + let md = inner["markdown"].as_str().unwrap_or("").to_string(); + let mut title = inner["metadata"]["title"] + .as_str() + .unwrap_or("") + .to_string(); + // markdown fallback: some sites return html only — convert then. + let (markdown, html) = if md.trim().is_empty() { + let html = inner["html"].as_str().unwrap_or("").to_string(); + (html_to_md::html_to_markdown(&html), html) + } else { + (md, String::new()) + }; + if title.is_empty() && !html.is_empty() { + title = html_to_md::extract_title(&html); + } + if markdown.trim().is_empty() { + bail!("Firecrawl: empty body for {url}"); + } + Ok(FetchedPage { + url: url.to_string(), + title, + markdown, + }) + } +} + +/// In-memory fetcher for tests and offline runs. +#[cfg(test)] +pub struct MockFetcher { + pages: std::collections::HashMap, +} + +#[cfg(test)] +impl MockFetcher { + pub fn new() -> Self { + Self { pages: std::collections::HashMap::new() } + } + pub fn with(mut self, url: &str, title: &str, markdown: &str) -> Self { + self.pages.insert( + url.to_string(), + FetchedPage { + url: url.to_string(), + title: title.to_string(), + markdown: markdown.to_string(), + }, + ); + self + } +} + +#[cfg(test)] +impl Default for MockFetcher { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +impl PageFetcher for MockFetcher { + fn fetch(&self, url: &str) -> Result { + self.pages + .get(url) + .cloned() + .ok_or_else(|| anyhow!("MockFetcher: no page for {url}")) + } +} + +fn take200(s: &str) -> String { + s.chars().take(200).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mock_fetcher_returns_registered_page() { + let f = MockFetcher::new().with( + "https://x/a", + "A", + "# A\n\nBody of a.\n", + ); + let p = f.fetch("https://x/a").unwrap(); + assert_eq!(p.title, "A"); + assert!(p.markdown.contains("Body of a.")); + } + + #[test] + fn mock_fetcher_missing_errors() { + let f = MockFetcher::new(); + assert!(f.fetch("https://nope").is_err()); + } +} diff --git a/src/cmd/graph/html_to_md.rs b/src/cmd/graph/html_to_md.rs new file mode 100644 index 000000000..2b8a5f34b --- /dev/null +++ b/src/cmd/graph/html_to_md.rs @@ -0,0 +1,181 @@ +//! Minimal HTML → Markdown, used only as a fallback when a fetcher returns raw +//! HTML instead of markdown (Firecrawl normally returns markdown natively via +//! `formats:["markdown"]`, so this rarely runs). +//! +//! ponytail: hand-rolled, tag-table converter. Ceiling = nested tables, `
`
+//! whitespace, `";
+        let md = html_to_markdown(h);
+        assert!(md.contains("keep"));
+        assert!(!md.contains("evil"));
+        assert!(!md.contains("color:red"));
+    }
+
+    #[test]
+    fn empty_input_is_empty() {
+        assert_eq!(html_to_markdown(""), "");
+        assert_eq!(html_to_markdown("   "), "");
+    }
+}
diff --git a/src/cmd/graph/migrate.rs b/src/cmd/graph/migrate.rs
new file mode 100644
index 000000000..40a4076de
--- /dev/null
+++ b/src/cmd/graph/migrate.rs
@@ -0,0 +1,419 @@
+//! `zola graph migrate` — the **only Firecrawl entrypoint**. Once-per-origin
+//! bootstrap: sitemap → Firecrawl fetch → write `content//index.md` →
+//! OpenRouter topics → commit `data/graph/*.json`. A second migrate for the
+//! same origin bails unless `--force`.
+//!
+//! Network + keys are read in [`migrate`] (the public entry); the testable
+//! core is [`migrate_with`], which takes injectable sitemap/fetcher/topic
+//! clients so the integration test runs fully offline.
+
+use std::env;
+use std::path::Path;
+
+use errors::{Result, anyhow, bail};
+
+use super::firecrawl::{FirecrawlFetcher, PageFetcher};
+use super::openrouter::{OpenRouterTopicClient, TopicClient, TopicInput};
+use super::schema::{GraphStore, Meta, Page};
+use super::sitemap;
+use super::{content_hash, now_iso, read_langs, summarize, url_to_content_path, write_page};
+
+/// Injectable sitemap source so tests run without network.
+pub trait SitemapSource {
+    fn urls(&self, origin: &str) -> Result>;
+}
+
+/// Live sitemap source (HTTP discovery).
+pub struct LiveSitemap {
+    pub client: reqwest::blocking::Client,
+}
+
+impl SitemapSource for LiveSitemap {
+    fn urls(&self, origin: &str) -> Result> {
+        sitemap::discover(origin, &self.client)
+    }
+}
+
+/// Public entry from `main.rs`. Reads `FIRECRAWL_API_KEY` + `OPENROUTER_API_KEY`
+/// (fail-fast when absent and not a dry-run), wires live clients.
+pub fn migrate(
+    root_dir: &Path,
+    config_file: &Path,
+    from: &str,
+    max: Option,
+    dry_run: bool,
+    force: bool,
+) -> Result<()> {
+    let _ = read_langs(config_file)?; // validates config readability early
+    let (firecrawl_key, openrouter_key) = if dry_run {
+        (String::new(), String::new())
+    } else {
+        let fc = env::var("FIRECRAWL_API_KEY")
+            .ok()
+            .filter(|s| !s.is_empty())
+            .ok_or_else(|| anyhow!("FIRECRAWL_API_KEY not set — migrate needs it"))?;
+        let or = env::var("OPENROUTER_API_KEY")
+            .ok()
+            .filter(|s| !s.is_empty())
+            .ok_or_else(|| anyhow!("OPENROUTER_API_KEY not set — migrate needs it"))?;
+        (fc, or)
+    };
+    let http = sitemap::http_client()?;
+    let fetcher = if dry_run {
+        None
+    } else {
+        Some(FirecrawlFetcher::new(firecrawl_key)?)
+    };
+    migrate_with(
+        root_dir,
+        from,
+        max,
+        dry_run,
+        force,
+        &LiveSitemap { client: http.clone() },
+        fetcher.as_ref(),
+        &OpenRouterTopicClient,
+        &openrouter_key,
+    )
+}
+
+/// Testable core.
+///
+/// - `dry_run` reports the planned crawl without fetching/writing/calling the LLM.
+/// - `force` bypasses the once-per-origin guard.
+#[allow(clippy::too_many_arguments)]
+pub fn migrate_with(
+    root_dir: &Path,
+    from: &str,
+    max: Option,
+    dry_run: bool,
+    force: bool,
+    sitemap_src: &S,
+    fetcher: Option<&F>,
+    topic_client: &C,
+    openrouter_key: &str,
+) -> Result<()>
+where
+    S: SitemapSource,
+    F: PageFetcher,
+    C: TopicClient,
+{
+    let graph_dir = root_dir.join("data/graph");
+    let content_dir = root_dir.join("content");
+    let existing = GraphStore::load(&graph_dir)?;
+
+    if !force && existing.is_migrated_for(from) {
+        bail!(
+            "already migrated for {from} ({} pages). Re-run with --force to re-crawl.",
+            existing.pages.len()
+        );
+    }
+
+    let cap = max.unwrap_or(usize::MAX);
+    let urls = sitemap_src.urls(from)?;
+    let total = urls.len();
+    let planned: Vec<&String> = urls.iter().take(cap).collect();
+    log::info!("migrate: {from} → {total} sitemap URLs, {} in scope", planned.len());
+
+    if dry_run {
+        log::info!(
+            "migrate [dry-run]: would fetch + enrich {} pages; no network, no writes",
+            planned.len()
+        );
+        return Ok(());
+    }
+
+    let fetcher = fetcher.ok_or_else(|| anyhow!("migrate: fetcher required (not a dry-run)"))?;
+
+    // Fresh bootstrap (force re-crawl discards the old graph).
+    let mut store = GraphStore::default();
+    store.meta = Meta {
+        schema_version: super::schema::SCHEMA_VERSION,
+        source_origin: from.to_string(),
+        migrated_at: now_iso(),
+        last_refresh: String::new(),
+    };
+
+    let mut failures = 0usize;
+    let mut enriched = 0usize;
+    for url in &planned {
+        let fetched = match fetcher.fetch(url) {
+            Ok(p) => p,
+            Err(e) => {
+                failures += 1;
+                log::error!("migrate: fetch {url} FAILED: {e}");
+                continue;
+            }
+        };
+        let rel = match url_to_content_path(&fetched.url) {
+            Ok(p) => p,
+            Err(e) => {
+                failures += 1;
+                log::error!("migrate: bad url {}: {e}", fetched.url);
+                continue;
+            }
+        };
+        let disk_path = root_dir.join(&rel);
+        let hash = content_hash(fetched.markdown.trim());
+        let fm = format!(
+            "title = {t:?}\ndescription = {d:?}\n[extra]\nsource_url = {u:?}\ncontent_hash = {h:?}\n",
+            t = fetched.title,
+            d = summarize(&fetched.markdown),
+            u = fetched.url,
+            h = hash,
+        );
+        if let Err(e) = write_page(&disk_path, &fm, &fetched.markdown) {
+            failures += 1;
+            log::error!("migrate: write {}: {e}", disk_path.display());
+            continue;
+        }
+        let page = Page {
+            url: fetched.url.clone(),
+            path: rel,
+            title: fetched.title.clone(),
+            summary: summarize(&fetched.markdown),
+            content_hash: hash,
+            topic_ids: vec![],
+        };
+        store.pages.push(page);
+        let input = TopicInput {
+            title: fetched.title,
+            description: String::new(),
+            body: fetched.markdown,
+        };
+        let page_url = fetched.url.clone();
+        match super::topics::enrich_one(
+            &mut store,
+            &page_url,
+            &input,
+            topic_client,
+            openrouter_key,
+            false,
+        ) {
+            Ok(true) => enriched += 1,
+            Ok(false) => {}
+            Err(e) => {
+                failures += 1;
+                log::error!("migrate: topics {page_url} FAILED: {e}");
+            }
+        }
+    }
+
+    store.save(&graph_dir)?;
+    log::info!(
+        "migrate: wrote {} pages, enriched {enriched}, {failures} failure(s)",
+        store.pages.len()
+    );
+    let _ = content_dir; // content dir created implicitly by write_page
+    if failures > 0 {
+        bail!("migrate completed with {failures} failure(s)");
+    }
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::cmd::graph::openrouter::{TopicExtract, TopicInput, TopicSpec};
+    use crate::cmd::graph::refresh::refresh_with;
+    use crate::cmd::graph::schema::SCHEMA_VERSION;
+    use std::fs;
+    use std::path::PathBuf;
+    use std::sync::atomic::{AtomicUsize, Ordering};
+
+    static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
+
+    struct Fixture {
+        root: PathBuf,
+    }
+    impl Fixture {
+        fn new() -> Self {
+            let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
+            let root =
+                std::env::temp_dir().join(format!("zola-graph-migrate-{id}-{}", std::process::id()));
+            fs::create_dir_all(&root).unwrap();
+            fs::write(
+                root.join("config.toml"),
+                "base_url = \"https://x/\"\ndefault_language = \"en\"\n",
+            )
+            .unwrap();
+            Fixture { root }
+        }
+        fn root(&self) -> &Path {
+            &self.root
+        }
+    }
+    impl Drop for Fixture {
+        fn drop(&mut self) {
+            let _ = fs::remove_dir_all(&self.root);
+        }
+    }
+
+    /// Sitemap source returning a fixed list.
+    struct FixedSitemap(Vec);
+    impl SitemapSource for FixedSitemap {
+        fn urls(&self, _origin: &str) -> Result> {
+            Ok(self.0.clone())
+        }
+    }
+
+    struct FixedTopics;
+    impl TopicClient for FixedTopics {
+        fn extract(&self, input: &TopicInput, _key: &str) -> Result {
+            Ok(TopicExtract {
+                topics: vec![TopicSpec {
+                    label: format!("Topic-{}", input.title),
+                    aliases: vec![],
+                }],
+                relations: vec![],
+            })
+        }
+    }
+
+    #[test]
+    fn migrate_writes_pages_and_graph_then_guards() {
+        let fx = Fixture::new();
+        let urls = vec!["https://x/a".into(), "https://x/b".into()];
+        let fetcher = super::super::firecrawl::MockFetcher::new()
+            .with("https://x/a", "Page A", "Body of A.")
+            .with("https://x/b", "Page B", "Body of B.");
+
+        // first migrate succeeds
+        migrate_with(
+            fx.root(),
+            "https://x",
+            None,
+            false,
+            false,
+            &FixedSitemap(urls.clone()),
+            Some(&fetcher),
+            &FixedTopics,
+            "k",
+        )
+        .unwrap();
+
+        // content written
+        assert!(fx.root().join("content/a/index.md").exists());
+        assert!(fx.root().join("content/b/index.md").exists());
+        let a = fs::read_to_string(fx.root().join("content/a/index.md")).unwrap();
+        assert!(a.contains("title = \"Page A\""));
+        assert!(a.contains("source_url = \"https://x/a\""));
+        assert!(a.contains("content_hash"));
+
+        // graph written
+        let store = GraphStore::load(&fx.root().join("data/graph")).unwrap();
+        assert_eq!(store.pages.len(), 2);
+        assert_eq!(store.meta.source_origin, "https://x");
+        assert_eq!(store.meta.schema_version, SCHEMA_VERSION);
+        assert!(!store.topics.is_empty(), "topics enriched");
+
+        // second migrate without --force bails (once guard)
+        let err = migrate_with(
+            fx.root(),
+            "https://x",
+            None,
+            false,
+            false,
+            &FixedSitemap(urls.clone()),
+            Some(&fetcher),
+            &FixedTopics,
+            "k",
+        )
+        .unwrap_err();
+        assert!(err.to_string().contains("already migrated"));
+
+        // --force re-migrates
+        migrate_with(
+            fx.root(),
+            "https://x",
+            None,
+            false,
+            true,
+            &FixedSitemap(urls),
+            Some(&fetcher),
+            &FixedTopics,
+            "k",
+        )
+        .unwrap();
+    }
+
+    #[test]
+    fn dry_run_writes_nothing() {
+        let fx = Fixture::new();
+        let fetcher = super::super::firecrawl::MockFetcher::new().with("https://x/a", "A", "body");
+        migrate_with(
+            fx.root(),
+            "https://x",
+            None,
+            true,
+            false,
+            &FixedSitemap(vec!["https://x/a".into()]),
+            Some(&fetcher),
+            &FixedTopics,
+            "k",
+        )
+        .unwrap();
+        assert!(!fx.root().join("content/a/index.md").exists());
+        assert!(!fx.root().join("data/graph/meta.json").exists());
+    }
+
+    /// Full loop: migrate once → refresh after a body edit → second migrate w/o
+    /// force fails. Refresh must never touch Firecrawl (no fetcher passed).
+    #[test]
+    fn integration_migrate_then_refresh_then_guard() {
+        let fx = Fixture::new();
+        let urls = vec!["https://x/a".into()];
+        let fetcher =
+            super::super::firecrawl::MockFetcher::new().with("https://x/a", "A", "Original body.");
+
+        migrate_with(
+            fx.root(),
+            "https://x",
+            None,
+            false,
+            false,
+            &FixedSitemap(urls.clone()),
+            Some(&fetcher),
+            &FixedTopics,
+            "k",
+        )
+        .unwrap();
+        let before = GraphStore::load(&fx.root().join("data/graph")).unwrap();
+        let hash_before = before.pages[0].content_hash.clone();
+        assert_eq!(before.pages[0].title, "A");
+
+        // edit the page body locally (preserve frontmatter, change body)
+        let page_path = fx.root().join("content/a/index.md");
+        let mut txt = fs::read_to_string(&page_path).unwrap();
+        txt = txt.replace("Original body.", "Edited body — refreshed.");
+        fs::write(&page_path, txt).unwrap();
+
+        // refresh: local only (no fetcher arg at all) — re-topics stale page
+        refresh_with(
+            fx.root(),
+            None,
+            false,
+            &FixedTopics,
+            "k",
+        )
+        .unwrap();
+        let after = GraphStore::load(&fx.root().join("data/graph")).unwrap();
+        assert_ne!(after.pages[0].content_hash, hash_before, "hash updated post-edit");
+        assert!(!after.meta.last_refresh.is_empty(), "last_refresh stamped");
+
+        // second migrate without --force still bails
+        let err = migrate_with(
+            fx.root(),
+            "https://x",
+            None,
+            false,
+            false,
+            &FixedSitemap(urls),
+            Some(&fetcher),
+            &FixedTopics,
+            "k",
+        )
+        .unwrap_err();
+        assert!(err.to_string().contains("already migrated"));
+    }
+}
diff --git a/src/cmd/graph/mod.rs b/src/cmd/graph/mod.rs
new file mode 100644
index 000000000..0cd83a86d
--- /dev/null
+++ b/src/cmd/graph/mod.rs
@@ -0,0 +1,185 @@
+//! `zola graph` — build and maintain a topical knowledge graph.
+//!
+//! Two subcommands (see `cli.rs::GraphCommand`):
+//!
+//! - `graph migrate --from `: **once-per-origin**. Fetches the origin's
+//!   sitemap, crawls each page via Firecrawl (**the only Firecrawl entrypoint**),
+//!   writes `content//index.md`, enriches topics via OpenRouter, and
+//!   commits `data/graph/*.json`. Refuses a second crawl for the same origin
+//!   unless `--force`.
+//! - `graph refresh`: **local only**. Walks default-language markdown, re-topics
+//!   pages whose `content_hash` changed, updates `meta.last_refresh`. Never
+//!   imports the firecrawl module.
+//!
+//! Network lives only in migrate/refresh — `zola build` stays offline.
+
+pub mod firecrawl;
+pub mod html_to_md;
+pub mod migrate;
+pub mod openrouter;
+pub mod refresh;
+pub mod schema;
+pub mod sitemap;
+pub mod topics;
+
+use std::collections::HashSet;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use errors::{Result, anyhow, bail};
+use sha2::{Digest, Sha256};
+use time::OffsetDateTime;
+use time::format_description::well_known::Rfc3339;
+use toml::Value;
+use url::Url;
+
+use crate::cli::GraphCommand;
+
+const FM_DELIM: &str = "+++";
+
+/// Dispatch entry from `main.rs`.
+pub fn run(root_dir: &Path, config_file: &Path, command: GraphCommand) -> Result<()> {
+    match command {
+        GraphCommand::Migrate { from, max, dry_run, force } => {
+            migrate::migrate(root_dir, config_file, &from, max, dry_run, force)
+        }
+        GraphCommand::Refresh { max, dry_run } => {
+            refresh::refresh(root_dir, config_file, max, dry_run)
+        }
+    }
+}
+
+// ---- shared markdown helpers (used by migrate writer + refresh reader) ----
+
+/// sha256 hex of a markdown body — the page staleness key.
+fn content_hash(body: &str) -> String {
+    let mut h = Sha256::new();
+    h.update(body.as_bytes());
+    h.finalize().iter().map(|b| format!("{b:02x}")).collect()
+}
+
+/// RFC3339 UTC timestamp, empty on format failure.
+fn now_iso() -> String {
+    OffsetDateTime::now_utc().format(&Rfc3339).unwrap_or_default()
+}
+
+/// `(default_language, sorted non-default languages)` from a Zola config file.
+fn read_langs(config_file: &Path) -> Result<(String, Vec)> {
+    let text = fs::read_to_string(config_file).map_err(|e| anyhow!("read config: {e}"))?;
+    let cfg: Value = toml::from_str(&text).map_err(|e| anyhow!("parse config: {e}"))?;
+    let default = cfg
+        .get("default_language")
+        .and_then(|v| v.as_str())
+        .unwrap_or("en")
+        .to_string();
+    let mut langs: Vec = cfg
+        .get("languages")
+        .and_then(|v| v.as_table())
+        .map(|t| t.keys().filter(|k| *k != &default).cloned().collect())
+        .unwrap_or_default();
+    langs.sort();
+    Ok((default, langs))
+}
+
+/// Recursively collect `*.md` files under `dir` (missing dir → empty).
+fn walk_md(dir: &Path, out: &mut Vec) -> Result<()> {
+    let rd = match fs::read_dir(dir) {
+        Ok(r) => r,
+        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
+        Err(e) => return Err(anyhow!("read dir {}: {e}", dir.display())),
+    };
+    for entry in rd {
+        let path = entry?.path();
+        if path.is_dir() {
+            walk_md(&path, out)?;
+        } else if path.extension().and_then(|e| e.to_str()) == Some("md") {
+            out.push(path);
+        }
+    }
+    Ok(())
+}
+
+/// True if `file_name` is a default-language, non-section page (mirror of
+/// `translate.rs`).
+fn is_default_page(file_name: &str, lang_set: &HashSet<&str>) -> bool {
+    let Some(stem) = file_name.strip_suffix(".md") else {
+        return false;
+    };
+    if stem.starts_with("_index") {
+        return false;
+    }
+    !lang_set.iter().any(|l| stem.ends_with(&format!(".{l}")))
+}
+
+/// Split a `+++`-delimited page into (frontmatter, body markdown).
+fn parse_page(path: &Path) -> Result<(Value, String)> {
+    let text = fs::read_to_string(path)?;
+    let mut lines = text.lines();
+    let first = lines.next().unwrap_or("");
+    if first.trim() != FM_DELIM {
+        bail!(
+            "{}: expected `{}` frontmatter (TOML). `zola graph` handles `+++` pages only.",
+            path.display(),
+            FM_DELIM
+        );
+    }
+    let mut fm_buf = String::new();
+    let mut body_buf = String::new();
+    let mut closed = false;
+    for line in lines {
+        if !closed {
+            if line.trim() == FM_DELIM {
+                closed = true;
+            } else {
+                fm_buf.push_str(line);
+                fm_buf.push('\n');
+            }
+        } else {
+            body_buf.push_str(line);
+            body_buf.push('\n');
+        }
+    }
+    if !closed {
+        bail!("{}: frontmatter not terminated by `{}`", path.display(), FM_DELIM);
+    }
+    let fm: Value =
+        toml::from_str(&fm_buf).map_err(|e| anyhow!("{}: frontmatter parse: {e}", path.display()))?;
+    Ok((fm, body_buf))
+}
+
+/// Write a `+++`-delimited page with the given frontmatter + body.
+fn write_page(path: &Path, fm_str: &str, body: &str) -> Result<()> {
+    if let Some(dir) = path.parent() {
+        fs::create_dir_all(dir)?;
+    }
+    fs::write(path, format!("+++\n{fm_str}+++\n\n{}\n", body.trim_end()))?;
+    Ok(())
+}
+
+/// `content//index.md` relative to root for a fetched URL. Homepage →
+/// `content/home/index.md`. ponytail: ignores query string; trailing slashes
+/// collapsed. Ceiling = same URL at ?x=1 and ?x=2 collide — fine for graph.
+fn url_to_content_path(raw_url: &str) -> Result {
+    let parsed = Url::parse(raw_url)?;
+    let mut segments: Vec<&str> = parsed
+        .path()
+        .trim_end_matches('/')
+        .split('/')
+        .filter(|s| !s.is_empty())
+        .collect();
+    if segments.is_empty() {
+        segments.push("home");
+    }
+    Ok(format!("content/{}/index.md", segments.join("/")))
+}
+
+/// Plain-text summary from a markdown body: first ~160 chars of prose.
+fn summarize(body: &str) -> String {
+    let clean: String = body
+        .lines()
+        .map(|l| l.trim_start_matches('#').trim())
+        .filter(|l| !l.is_empty())
+        .collect::>()
+        .join(" ");
+    clean.chars().take(160).collect()
+}
diff --git a/src/cmd/graph/openrouter.rs b/src/cmd/graph/openrouter.rs
new file mode 100644
index 000000000..72a5b7db7
--- /dev/null
+++ b/src/cmd/graph/openrouter.rs
@@ -0,0 +1,201 @@
+//! OpenRouter topical extraction (ADR-003: `openai/gpt-4o-mini`, JSON-object
+//! response). Same transport contract as `cmd::translate` — the call is behind
+//! the [`TopicClient`] trait so tests mock it without a network or key.
+
+use std::time::Duration;
+
+use errors::{Result, anyhow, bail};
+use serde_json::{json, Value};
+
+const OPENROUTER_URL: &str = "https://openrouter.ai/api/v1/chat/completions";
+/// Umbrella ADR-003: gpt-4o-mini only.
+const MODEL: &str = "openai/gpt-4o-mini";
+const MAX_TOKENS: u32 = 4096;
+/// Body chars sent to the model — caps tokens per page.
+const BODY_CHAR_CAP: usize = 6000;
+
+/// Page fields the model sees.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct TopicInput {
+    pub title: String,
+    pub description: String,
+    pub body: String,
+}
+
+/// One extracted topic (label + aliases).
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct TopicSpec {
+    pub label: String,
+    pub aliases: Vec,
+}
+
+/// One inter-topic relation. `kind` ∈ `related` | `broader` | `narrower`.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct TopicRelationSpec {
+    pub from_label: String,
+    pub to_label: String,
+    pub kind: String,
+}
+
+/// Model output for one page.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct TopicExtract {
+    pub topics: Vec,
+    pub relations: Vec,
+}
+
+/// Extraction client. Trait so tests inject a mock.
+pub trait TopicClient {
+    fn extract(&self, input: &TopicInput, key: &str) -> Result;
+}
+
+/// Live OpenRouter client (blocking reqwest).
+pub struct OpenRouterTopicClient;
+
+impl TopicClient for OpenRouterTopicClient {
+    fn extract(&self, input: &TopicInput, key: &str) -> Result {
+        let body = truncate(&input.body, BODY_CHAR_CAP);
+        let payload = json!({
+            "model": MODEL,
+            "response_format": {"type": "json_object"},
+            "max_tokens": MAX_TOKENS,
+            "messages": [
+                {"role": "system", "content":
+                    "You extract a concise topical knowledge graph from a web page for SEO. \
+                     Return a single JSON object with exactly these keys: \
+                     topics (array of {label, aliases}) and \
+                     relations (array of {from_label, to_label, kind}). \
+                     label = short lowercase noun phrase. aliases = up to 3 synonyms. \
+                     kind in {related, broader, narrower}. \
+                     At most 8 topics. No prose."},
+                {"role": "user", "content": json!({
+                    "title": input.title,
+                    "description": input.description,
+                    "body": body,
+                }).to_string()},
+            ],
+        });
+        let bytes = serde_json::to_vec(&payload)?;
+        let resp = reqwest::blocking::Client::builder()
+            .timeout(Duration::from_secs(120))
+            .build()?
+            .post(OPENROUTER_URL)
+            .bearer_auth(key)
+            .header(reqwest::header::CONTENT_TYPE, "application/json")
+            .body(bytes)
+            .send()?;
+        let status = resp.status();
+        let text = resp.text()?;
+        if !status.is_success() {
+            bail!("OpenRouter HTTP {status}: {}", take200(&text));
+        }
+        let data: Value = serde_json::from_str(&text)
+            .map_err(|e| anyhow!("OpenRouter non-JSON response: {e}"))?;
+        let content = data["choices"][0]["message"]["content"]
+            .as_str()
+            .ok_or_else(|| anyhow!("OpenRouter: unexpected shape: {}", take160(&data.to_string())))?;
+        parse_extract(content)
+    }
+}
+
+/// Parse the model's JSON-object content into [`TopicExtract`]. Pure — used by
+/// tests and the live client.
+pub fn parse_extract(content: &str) -> Result {
+    let v: Value = serde_json::from_str(content)
+        .map_err(|_| anyhow!("model returned non-JSON: {}", take160(content)))?;
+    let mut topics = Vec::new();
+    if let Some(arr) = v.get("topics").and_then(|t| t.as_array()) {
+        for t in arr {
+            let label = t.get("label").and_then(|x| x.as_str()).unwrap_or("").trim().to_string();
+            if label.is_empty() {
+                continue;
+            }
+            let aliases = t
+                .get("aliases")
+                .and_then(|a| a.as_array())
+                .map(|a| {
+                    a.iter()
+                        .filter_map(|x| x.as_str().map(|s| s.trim().to_string()))
+                        .filter(|s| !s.is_empty())
+                        .collect()
+                })
+                .unwrap_or_default();
+            topics.push(TopicSpec { label, aliases });
+        }
+    }
+    let mut relations = Vec::new();
+    if let Some(arr) = v.get("relations").and_then(|t| t.as_array()) {
+        for r in arr {
+            let from_label = r.get("from_label").and_then(|x| x.as_str()).unwrap_or("").trim().to_string();
+            let to_label = r.get("to_label").and_then(|x| x.as_str()).unwrap_or("").trim().to_string();
+            let kind = r.get("kind").and_then(|x| x.as_str()).unwrap_or("related").trim().to_string();
+            if from_label.is_empty() || to_label.is_empty() {
+                continue;
+            }
+            relations.push(TopicRelationSpec { from_label, to_label, kind });
+        }
+    }
+    Ok(TopicExtract { topics, relations })
+}
+
+fn truncate(s: &str, cap: usize) -> String {
+    if s.len() <= cap {
+        s.to_string()
+    } else {
+        // ponytail: byte-cap on a char boundary near `cap`; fine for SEO input.
+        let mut end = cap;
+        while end > 0 && !s.is_char_boundary(end) {
+            end -= 1;
+        }
+        format!("{}…", &s[..end])
+    }
+}
+
+fn take200(s: &str) -> String {
+    s.chars().take(200).collect()
+}
+fn take160(s: &str) -> String {
+    s.chars().take(160).collect()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn parse_extract_normal() {
+        let content = r#"{"topics":[{"label":"hiring","aliases":["recruiting"]},{"label":"ats"}],"relations":[{"from_label":"hiring","to_label":"ats","kind":"related"}]}"#;
+        let ex = parse_extract(content).unwrap();
+        assert_eq!(ex.topics.len(), 2);
+        assert_eq!(ex.topics[0].label, "hiring");
+        assert_eq!(ex.topics[0].aliases, vec!["recruiting".to_string()]);
+        assert_eq!(ex.relations.len(), 1);
+        assert_eq!(ex.relations[0].kind, "related");
+    }
+
+    #[test]
+    fn parse_extract_drops_empty_labels() {
+        let content = r#"{"topics":[{"label":""},{"label":"ats"}]}"#;
+        let ex = parse_extract(content).unwrap();
+        assert_eq!(ex.topics.len(), 1);
+    }
+
+    #[test]
+    fn parse_extract_non_json_errors() {
+        assert!(parse_extract("not json").is_err());
+    }
+
+    #[test]
+    fn parse_extract_empty_is_default() {
+        let ex = parse_extract(r#"{"topics":[],"relations":[]}"#).unwrap();
+        assert!(ex.topics.is_empty());
+        assert!(ex.relations.is_empty());
+    }
+
+    #[test]
+    fn truncate_on_boundary() {
+        let s = "abcdef";
+        assert_eq!(truncate(s, 3), "abc…");
+        assert_eq!(truncate(s, 100), s);
+    }
+}
diff --git a/src/cmd/graph/refresh.rs b/src/cmd/graph/refresh.rs
new file mode 100644
index 000000000..5bdc40d0f
--- /dev/null
+++ b/src/cmd/graph/refresh.rs
@@ -0,0 +1,288 @@
+//! `zola graph refresh` — **local only**. Never imports the firecrawl module,
+//! never re-fetches remote HTML. Walks default-language markdown under
+//! `content/`, re-topics pages whose stored `content_hash` no longer matches
+//! the on-disk body, and stamps `meta.last_refresh`.
+//!
+//! Public [`refresh`] reads `OPENROUTER_API_KEY`; [`refresh_with`] is the
+//! offline-testable core taking an injected [`TopicClient`].
+
+use std::collections::HashSet;
+use std::env;
+use std::path::Path;
+
+use errors::{Result, anyhow, bail};
+
+use super::openrouter::{OpenRouterTopicClient, TopicClient, TopicInput};
+use super::schema::Page;
+use super::{
+    content_hash, is_default_page, now_iso, parse_page, read_langs, summarize, walk_md,
+};
+
+/// Public entry from `main.rs`.
+pub fn refresh(
+    root_dir: &Path,
+    config_file: &Path,
+    max: Option,
+    dry_run: bool,
+) -> Result<()> {
+    let (_default_lang, langs) = read_langs(config_file)?;
+    let lang_set: HashSet<&str> = langs.iter().map(|s| s.as_str()).collect();
+    let key = if dry_run {
+        String::new()
+    } else {
+        env::var("OPENROUTER_API_KEY")
+            .ok()
+            .filter(|s| !s.is_empty())
+            .ok_or_else(|| anyhow!("OPENROUTER_API_KEY not set — refresh needs it"))?
+    };
+    refresh_with_inner(root_dir, max, dry_run, &lang_set, &OpenRouterTopicClient, &key)
+}
+
+/// Testable core (offline; no env, no live client). Test seam — not called by
+/// the public [`refresh`] path, hence the allow.
+#[allow(dead_code)]
+pub fn refresh_with(
+    root_dir: &Path,
+    max: Option,
+    dry_run: bool,
+    topic_client: &C,
+    openrouter_key: &str,
+) -> Result<()> {
+    // tests use default-language "en" only
+    refresh_with_inner(root_dir, max, dry_run, &HashSet::new(), topic_client, openrouter_key)
+}
+
+fn refresh_with_inner(
+    root_dir: &Path,
+    max: Option,
+    dry_run: bool,
+    lang_set: &HashSet<&str>,
+    topic_client: &C,
+    openrouter_key: &str,
+) -> Result<()> {
+    let graph_dir = root_dir.join("data/graph");
+    let content_dir = root_dir.join("content");
+    let mut store = super::schema::GraphStore::load(&graph_dir)?;
+
+    if store.meta.source_origin.is_empty() {
+        bail!("no prior migrate found (meta.source_origin empty); run `zola graph migrate` first");
+    }
+
+    let mut files: Vec = Vec::new();
+    walk_md(&content_dir, &mut files)?;
+    files.sort();
+
+    // collect (page_url, input) for stale/new pages
+    let mut todo: Vec<(String, TopicInput)> = Vec::new();
+    let mut failures = 0usize;
+
+    for file in &files {
+        let name = file.file_name().unwrap().to_string_lossy().into_owned();
+        if !is_default_page(&name, lang_set) {
+            continue;
+        }
+        let (fm, body) = match parse_page(file) {
+            Ok(v) => v,
+            Err(e) => {
+                failures += 1;
+                log::error!("refresh: {}: parse failed: {e}", file.display());
+                continue;
+            }
+        };
+        let title = fm.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();
+        let description = fm.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string();
+        let body_trim = body.trim();
+        if body_trim.is_empty() {
+            continue; // ponytail: nothing to topic on empty/stub bodies
+        }
+        let hash = content_hash(body_trim);
+        let rel = file
+            .strip_prefix(root_dir)
+            .map_err(|e| anyhow!("strip prefix {}: {e}", file.display()))?
+            .to_string_lossy()
+            .replace('\\', "/");
+        let url = fm
+            .get("extra")
+            .and_then(|e| e.get("source_url"))
+            .and_then(|v| v.as_str())
+            .map(|s| s.to_string())
+            .unwrap_or_else(|| format!("local:{rel}"));
+
+        let pos = store.pages.iter().position(|p| p.path == rel);
+        match pos {
+            Some(i) if store.pages[i].content_hash == hash => {
+                continue; // fresh
+            }
+            Some(i) => {
+                // stale: detach old topic edges, will re-merge
+                let url = store.pages[i].url.clone();
+                detach_page_topics(&mut store, &url);
+                store.pages[i].title = title.clone();
+                store.pages[i].summary = summarize(body_trim);
+                store.pages[i].content_hash = hash;
+                store.pages[i].topic_ids.clear();
+                let input = TopicInput { title, description, body: body_trim.to_string() };
+                todo.push((url, input));
+            }
+            None => {
+                // new page since migrate
+                let page = Page {
+                    url: url.clone(),
+                    path: rel,
+                    title: title.clone(),
+                    summary: summarize(body_trim),
+                    content_hash: hash,
+                    topic_ids: vec![],
+                };
+                store.pages.push(page);
+                let input = TopicInput { title, description, body: body_trim.to_string() };
+                todo.push((url, input));
+            }
+        }
+    }
+
+    log::info!("refresh: {} page(s) stale/new out of {} default-language files", todo.len(), files.len());
+
+    let cap = max.unwrap_or(usize::MAX);
+    let mut enriched = 0usize;
+    for (i, (url, input)) in todo.iter().enumerate() {
+        if i >= cap {
+            log::info!("refresh: --max {cap} reached; remaining resume next run");
+            break;
+        }
+        if dry_run {
+            log::info!("refresh [dry-run]: would enrich {url}");
+            continue;
+        }
+        match super::topics::enrich_one(&mut store, url, input, topic_client, openrouter_key, false)
+        {
+            Ok(true) => enriched += 1,
+            Ok(false) => {}
+            Err(e) => {
+                failures += 1;
+                log::error!("refresh: topics {url} FAILED: {e}");
+            }
+        }
+    }
+
+    if !dry_run {
+        store.meta.last_refresh = now_iso();
+        store.save(&graph_dir)?;
+    }
+    log::info!("refresh: enriched {enriched}, {failures} failure(s)");
+    if failures > 0 {
+        bail!("refresh completed with {failures} failure(s)");
+    }
+    Ok(())
+}
+
+/// Remove all `page_topic` edges for `url` and drop `url` from every topic's
+/// `page_ids` — prepares a stale page for re-merge.
+fn detach_page_topics(store: &mut super::schema::GraphStore, url: &str) {
+    store.relations.retain(|r| !(r.from == url && r.kind == "page_topic"));
+    for t in store.topics.iter_mut() {
+        t.page_ids.retain(|u| u != url);
+    }
+    // ponytail: topics left with zero page_ids are kept (may reattach); prune
+    // later if the orphan set grows. Ceiling: harmless empty topics.
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::cmd::graph::openrouter::{TopicExtract, TopicInput, TopicSpec};
+    use std::fs;
+    use std::path::PathBuf;
+    use std::sync::atomic::{AtomicUsize, Ordering};
+
+    static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
+
+    fn tmp_root() -> PathBuf {
+        let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
+        let r = std::env::temp_dir().join(format!("zola-graph-refresh-{id}-{}", std::process::id()));
+        fs::create_dir_all(&r).unwrap();
+        r
+    }
+
+    struct FixedTopics;
+    impl TopicClient for FixedTopics {
+        fn extract(&self, input: &TopicInput, _key: &str) -> Result {
+            Ok(TopicExtract {
+                topics: vec![TopicSpec { label: format!("Topic-{}", input.title), aliases: vec![] }],
+                relations: vec![],
+            })
+        }
+    }
+
+    fn seed_migrated(root: &Path) -> super::super::schema::GraphStore {
+        // minimal prior graph: one page, meta.source_origin set
+        let store = super::super::schema::GraphStore {
+            pages: vec![Page {
+                url: "https://x/a".into(),
+                path: "content/a/index.md".into(),
+                title: "A".into(),
+                summary: "s".into(),
+                content_hash: "oldhash".into(),
+                topic_ids: vec![],
+            }],
+            meta: super::super::schema::Meta {
+                source_origin: "https://x".into(),
+                ..Default::default()
+            },
+            ..Default::default()
+        };
+        store.save(&root.join("data/graph")).unwrap();
+        store
+    }
+
+    #[test]
+    fn refresh_bails_without_prior_migrate() {
+        let root = tmp_root();
+        let err = refresh_with(&root, None, false, &FixedTopics, "k").unwrap_err();
+        assert!(err.to_string().contains("no prior migrate"));
+        fs::remove_dir_all(&root).unwrap();
+    }
+
+    #[test]
+    fn refresh_retopics_stale_and_skips_fresh() {
+        let root = tmp_root();
+        seed_migrated(&root);
+        // write the page whose stored hash is "oldhash" → stale
+        let body = "Edited body.\n";
+        let hash = content_hash(body.trim());
+        fs::create_dir_all(root.join("content/a")).unwrap();
+        fs::write(
+            root.join("content/a/index.md"),
+            format!(
+                "+++\ntitle = \"A\"\n[extra]\nsource_url = \"https://x/a\"\n+++\n\n{body}"
+            ),
+        )
+        .unwrap();
+        assert_ne!(hash, "oldhash");
+
+        refresh_with(&root, None, false, &FixedTopics, "k").unwrap();
+        let after = super::super::schema::GraphStore::load(&root.join("data/graph")).unwrap();
+        assert_eq!(after.pages[0].content_hash, hash, "hash updated to current body");
+        assert!(!after.meta.last_refresh.is_empty());
+        assert!(!after.topics.is_empty(), "stale page re-enriched");
+        fs::remove_dir_all(&root).unwrap();
+    }
+
+    #[test]
+    fn refresh_dry_run_no_mutate() {
+        let root = tmp_root();
+        seed_migrated(&root);
+        let body = "Edited body.\n";
+        fs::create_dir_all(root.join("content/a")).unwrap();
+        fs::write(
+            root.join("content/a/index.md"),
+            format!("+++\ntitle = \"A\"\n+++\n\n{body}"),
+        )
+        .unwrap();
+        refresh_with(&root, None, true, &FixedTopics, "k").unwrap();
+        let after = super::super::schema::GraphStore::load(&root.join("data/graph")).unwrap();
+        assert_eq!(after.pages[0].content_hash, "oldhash", "dry-run must not change hash");
+        assert!(after.meta.last_refresh.is_empty(), "dry-run must not stamp");
+        fs::remove_dir_all(&root).unwrap();
+    }
+}
diff --git a/src/cmd/graph/schema.rs b/src/cmd/graph/schema.rs
new file mode 100644
index 000000000..999db5ba2
--- /dev/null
+++ b/src/cmd/graph/schema.rs
@@ -0,0 +1,226 @@
+//! `zola graph` JSON artifact schema + load/save.
+//!
+//! Committed under `/data/graph/`:
+//!
+//! ```text
+//! pages.json       # url, path, title, summary, content_hash, topic_ids
+//! topics.json      # id, label, aliases, page_ids
+//! relations.json   # {from, to, kind}  kind ∈ page_topic | topic_topic | page_page
+//! meta.json        # source_origin, migrated_at, schema_version, last_refresh
+//! ```
+//!
+//! `meta.source_origin` + non-empty `pages` is the migrate-once lock: a second
+//! `graph migrate --from ` bails unless `--force`. `schema_version`
+//! is pinned to 1 for this revision; bump only if the on-disk shape changes.
+
+use std::fs;
+use std::path::Path;
+
+use errors::{Result, anyhow};
+use serde::{Deserialize, Serialize};
+
+/// Pinned on-disk shape. Bump only when the JSON layout changes.
+pub const SCHEMA_VERSION: u32 = 1;
+
+/// In-memory graph; serialised to the four `data/graph/*.json` files.
+#[derive(Default, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct GraphStore {
+    #[serde(default)]
+    pub pages: Vec,
+    #[serde(default)]
+    pub topics: Vec,
+    #[serde(default)]
+    pub relations: Vec,
+    #[serde(default)]
+    pub meta: Meta,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Page {
+    pub url: String,
+    /// Content-relative path of the written markdown, e.g. `blog/foo/index.md`.
+    pub path: String,
+    pub title: String,
+    pub summary: String,
+    /// sha256 over the written markdown body; the refresh staleness key.
+    pub content_hash: String,
+    #[serde(default)]
+    pub topic_ids: Vec,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Topic {
+    pub id: String,
+    pub label: String,
+    #[serde(default)]
+    pub aliases: Vec,
+    #[serde(default)]
+    pub page_ids: Vec,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Relation {
+    /// page url (page_*) or topic id (topic_topic "from").
+    pub from: String,
+    /// topic id (page_topic) / page url (page_page) / topic id (topic_topic "to").
+    pub to: String,
+    /// `page_topic` | `topic_topic` | `page_page`.
+    pub kind: String,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Meta {
+    pub schema_version: u32,
+    #[serde(default)]
+    pub source_origin: String,
+    #[serde(default)]
+    pub migrated_at: String,
+    #[serde(default)]
+    pub last_refresh: String,
+}
+
+impl Default for Meta {
+    fn default() -> Self {
+        Self {
+            schema_version: SCHEMA_VERSION,
+            source_origin: String::new(),
+            migrated_at: String::new(),
+            last_refresh: String::new(),
+        }
+    }
+}
+
+impl GraphStore {
+    /// Directory of the four JSON files (`data/graph`).
+    pub fn load(dir: &Path) -> Result {
+        Ok(GraphStore {
+            pages: load_json(&dir.join("pages.json"))?,
+            topics: load_json(&dir.join("topics.json"))?,
+            relations: load_json(&dir.join("relations.json"))?,
+            meta: load_json(&dir.join("meta.json"))?,
+        })
+    }
+
+    /// Pretty-print all four files, creating `dir` if needed.
+    pub fn save(&self, dir: &Path) -> Result<()> {
+        fs::create_dir_all(dir)?;
+        save_json(&dir.join("pages.json"), &self.pages)?;
+        save_json(&dir.join("topics.json"), &self.topics)?;
+        save_json(&dir.join("relations.json"), &self.relations)?;
+        save_json(&dir.join("meta.json"), &self.meta)?;
+        Ok(())
+    }
+
+    /// True iff this origin was already migrated AND has pages on disk — the
+    /// state in which `migrate` must refuse a second crawl without `--force`.
+    pub fn is_migrated_for(&self, origin: &str) -> bool {
+        !self.pages.is_empty() && self.meta.source_origin == origin
+    }
+}
+
+fn load_json Deserialize<'de> + Default>(path: &Path) -> Result {
+    match fs::read_to_string(path) {
+        Ok(text) if text.trim().is_empty() => Ok(T::default()),
+        Ok(text) => serde_json::from_str(&text)
+            .map_err(|e| anyhow!("{}: parse JSON: {e}", path.display())),
+        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(T::default()),
+        Err(e) => Err(anyhow!("{}: read: {e}", path.display())),
+    }
+}
+
+fn save_json(path: &Path, value: &T) -> Result<()> {
+    let bytes = serde_json::to_vec_pretty(value)?;
+    fs::write(path, bytes)?;
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::path::PathBuf;
+    use std::sync::atomic::{AtomicUsize, Ordering};
+
+    static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
+
+    fn tmp_dir() -> PathBuf {
+        let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
+        let dir = std::env::temp_dir().join(format!("zola-graph-schema-{id}-{}", std::process::id()));
+        fs::create_dir_all(&dir).unwrap();
+        dir
+    }
+
+    fn sample() -> GraphStore {
+        GraphStore {
+            pages: vec![Page {
+                url: "https://x/blog/a".into(),
+                path: "blog/a/index.md".into(),
+                title: "A".into(),
+                summary: "sum".into(),
+                content_hash: "deadbeef".into(),
+                topic_ids: vec!["t1".into()],
+            }],
+            topics: vec![Topic {
+                id: "t1".into(),
+                label: "Hiring".into(),
+                aliases: vec!["Recruiting".into()],
+                page_ids: vec!["https://x/blog/a".into()],
+            }],
+            relations: vec![Relation {
+                from: "https://x/blog/a".into(),
+                to: "t1".into(),
+                kind: "page_topic".into(),
+            }],
+            meta: Meta {
+                schema_version: SCHEMA_VERSION,
+                source_origin: "https://x".into(),
+                migrated_at: "2026-08-11T00:00:00Z".into(),
+                last_refresh: String::new(),
+            },
+        }
+    }
+
+    #[test]
+    fn empty_store_round_trips_and_loads_missing() {
+        let dir = tmp_dir();
+        let empty = GraphStore::default();
+        empty.save(&dir).unwrap();
+        let back = GraphStore::load(&dir).unwrap();
+        assert_eq!(back, empty);
+        assert_eq!(back.meta.schema_version, SCHEMA_VERSION);
+        fs::remove_dir_all(&dir).unwrap();
+    }
+
+    #[test]
+    fn populated_store_round_trips() {
+        let dir = tmp_dir();
+        let store = sample();
+        store.save(&dir).unwrap();
+        let back = GraphStore::load(&dir).unwrap();
+        assert_eq!(back, store);
+        // four distinct files written
+        for f in ["pages.json", "topics.json", "relations.json", "meta.json"] {
+            assert!(dir.join(f).exists(), "missing {f}");
+        }
+        fs::remove_dir_all(&dir).unwrap();
+    }
+
+    #[test]
+    fn load_missing_dir_gives_defaults() {
+        let dir = std::env::temp_dir().join(format!(
+            "zola-graph-schema-missing-{}",
+            std::process::id()
+        ));
+        let back = GraphStore::load(&dir).unwrap();
+        assert!(back.pages.is_empty());
+        assert_eq!(back.meta.schema_version, SCHEMA_VERSION);
+    }
+
+    #[test]
+    fn is_migrated_for_guard_logic() {
+        let mut store = sample();
+        assert!(store.is_migrated_for("https://x"), "origin + pages => migrated");
+        assert!(!store.is_migrated_for("https://other"), "different origin");
+        store.pages.clear();
+        assert!(!store.is_migrated_for("https://x"), "empty pages => not migrated");
+    }
+}
diff --git a/src/cmd/graph/sitemap.rs b/src/cmd/graph/sitemap.rs
new file mode 100644
index 000000000..e642481c1
--- /dev/null
+++ b/src/cmd/graph/sitemap.rs
@@ -0,0 +1,181 @@
+//! Sitemap URL collection for `graph migrate`.
+//!
+//! Pure [`parse_sitemap`] over well-formed XML, then a live [`collect_urls`]
+//! that fetches + recurses `sitemapindex` entries. Namespaced variants
+//! (``) are handled by substring on the local tag name.
+//!
+//! ponytail: regex `` extraction. Ceiling = malformed/CDATA sitemaps,
+//! `.xml.gz` compression, or `` error pages served 200 with empty locs.
+//! Upgrade to `quick-xml` if a real site's sitemap breaks the regex.
+
+use std::collections::HashSet;
+use std::sync::OnceLock;
+use std::time::Duration;
+
+use errors::{Result, anyhow, bail};
+use regex::Regex;
+use reqwest::blocking::Client;
+
+static LOC: OnceLock = OnceLock::new();
+fn loc_re() -> &'static Regex {
+    LOC.get_or_init(|| {
+        // local-name agnostic: matches <...loc> (any namespace prefix) until .
+        Regex::new(r"(?s)<\w*:?\w*?loc>(.*?)").unwrap()
+    })
+}
+
+/// What a single sitemap document contains.
+#[derive(Debug, PartialEq, Eq)]
+pub enum Sitemap {
+    /// A ``: children are more sitemap URLs to recurse into.
+    Index(Vec),
+    /// A ``: leaf page URLs.
+    UrlSet(Vec),
+    /// No `` found (404 HTML body, empty doc, etc.).
+    Empty,
+}
+
+/// Parse one sitemap document. Pure: no I/O, no network — fixture-testable.
+pub fn parse_sitemap(xml: &str) -> Sitemap {
+    let urls: Vec = loc_re()
+        .captures_iter(xml)
+        .map(|c| html_decode(c[1].trim()))
+        .filter(|s| !s.is_empty())
+        .collect();
+    if xml.contains("sitemapindex") {
+        Sitemap::Index(urls)
+    } else if urls.is_empty() {
+        Sitemap::Empty
+    } else {
+        Sitemap::UrlSet(urls)
+    }
+}
+
+/// Fetch `sitemap_url`, recursing sitemap indexes. Returns deduped, sorted leaf
+/// page URLs. Live (network); not unit-tested.
+pub fn collect_urls(sitemap_url: &str, client: &Client) -> Result> {
+    let mut out = Vec::new();
+    let mut seen_req = HashSet::new();
+    recurse(sitemap_url, client, &mut out, &mut seen_req)?;
+    out.sort();
+    out.dedup();
+    Ok(out)
+}
+
+fn recurse(
+    url: &str,
+    client: &Client,
+    out: &mut Vec,
+    seen: &mut HashSet,
+) -> Result<()> {
+    if !seen.insert(url.to_string()) {
+        return Ok(()); // loop guard
+    }
+    let body = fetch_text(url, client)?;
+    match parse_sitemap(&body) {
+        Sitemap::Index(children) => {
+            for child in children {
+                recurse(&child, client, out, seen)?;
+            }
+        }
+        Sitemap::UrlSet(urls) => out.extend(urls),
+        Sitemap::Empty => bail!("{url}: sitemap parsed empty (not a sitemapindex/urlset?)"),
+    }
+    Ok(())
+}
+
+/// Try `/sitemap_index.xml`, then `/sitemap.xml`. Returns the
+/// first that yields leaf URLs.
+pub fn discover(origin: &str, client: &Client) -> Result> {
+    let origin = origin.trim_end_matches('/');
+    for path in ["sitemap_index.xml", "sitemap.xml"] {
+        let url = format!("{origin}/{path}");
+        match collect_urls(&url, client) {
+            Ok(urls) if !urls.is_empty() => return Ok(urls),
+            Ok(_) => log::info!("sitemap: {url} returned no URLs, trying next"),
+            Err(e) => log::info!("sitemap: {url} failed ({e}), trying next"),
+        }
+    }
+    Err(anyhow!("no usable sitemap at {origin} (tried sitemap_index.xml, sitemap.xml)"))
+}
+
+fn fetch_text(url: &str, client: &Client) -> Result {
+    let resp = client.get(url).send()?;
+    let status = resp.status();
+    let text = resp.text()?;
+    if !status.is_success() {
+        bail!("GET {url}: HTTP {status}");
+    }
+    Ok(text)
+}
+
+/// Build the shared blocking client (UA + sane timeout). Live callers use this.
+pub fn http_client() -> Result {
+    Ok(Client::builder()
+        .timeout(Duration::from_secs(60))
+        .user_agent("zola-graph/0.1")
+        .build()?)
+}
+
+/// Minimal entity decode for `` URL contents (`&` `<` `>`).
+fn html_decode(s: &str) -> String {
+    s.replace("&", "&")
+        .replace("<", "<")
+        .replace(">", ">")
+        .replace("'", "'")
+        .replace(""", "\"")
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn parses_yoast_index() {
+        let xml = r#"
+
+  https://x/post-sitemap.xml
+  https://x/page-sitemap.xml
+"#;
+        assert_eq!(
+            parse_sitemap(xml),
+            Sitemap::Index(vec![
+                "https://x/post-sitemap.xml".into(),
+                "https://x/page-sitemap.xml".into(),
+            ])
+        );
+    }
+
+    #[test]
+    fn parses_urlset_and_entities() {
+        let xml = r#"
+
+  https://x/blog/a?x=1&y=2
+  https://x/blog/b
+"#;
+        assert_eq!(
+            parse_sitemap(xml),
+            Sitemap::UrlSet(vec![
+                "https://x/blog/a?x=1&y=2".into(),
+                "https://x/blog/b".into(),
+            ])
+        );
+    }
+
+    #[test]
+    fn empty_or_html_is_empty() {
+        assert_eq!(parse_sitemap("404"), Sitemap::Empty);
+        assert_eq!(parse_sitemap(""), Sitemap::Empty);
+    }
+
+    #[test]
+    fn namespaced_sitemap_works() {
+        // some generators emit a namespace prefix on loc
+        let xml = "\
+                   https://x/c";
+        match parse_sitemap(xml) {
+            Sitemap::UrlSet(v) => assert_eq!(v, vec!["https://x/c".to_string()]),
+            other => panic!("expected UrlSet, got {other:?}"),
+        }
+    }
+}
diff --git a/src/cmd/graph/topics.rs b/src/cmd/graph/topics.rs
new file mode 100644
index 000000000..383b6a429
--- /dev/null
+++ b/src/cmd/graph/topics.rs
@@ -0,0 +1,294 @@
+//! Topical merge + enrichment glue. [`merge_page_topics`] is the pure,
+//! unit-tested core; [`enrich_one`] wraps the OpenRouter call for the
+//! migrate/refresh drivers. Used by **both** commands (initial migrate +
+//! stale-only refresh), so it must not depend on Firecrawl.
+
+use errors::Result;
+
+use super::openrouter::{TopicClient, TopicExtract, TopicInput};
+use super::schema::{GraphStore, Relation, Topic};
+
+/// Hand-rolled slug: lowercase, non-[a-z0-9] runs → `-`, trimmed.
+/// ponytail: no `slug` crate dep. Ceiling = unicode labels collapse to ascii
+/// runs; fine for SEO topic slugs which are short noun phrases.
+pub fn slugify(s: &str) -> String {
+    let mut out = String::with_capacity(s.len());
+    let mut prev_dash = true; // suppresses leading dashes
+    for c in s.chars() {
+        if c.is_ascii_alphanumeric() {
+            for lc in c.to_lowercase() {
+                out.push(lc);
+            }
+            prev_dash = false;
+        } else if !prev_dash {
+            out.push('-');
+            prev_dash = true;
+        }
+    }
+    if out.ends_with('-') {
+        out.pop();
+    }
+    out
+}
+
+/// Pure merge of one page's model output into the store. Idempotent: re-merging
+/// the same extract does not duplicate topics, page_topic edges, or aliases.
+/// Inter-topic relations are stored with kind `topic_topic` (the sub-type
+/// related/broader/narrower is collapsed — ceiling; upgrade schema to preserve).
+pub fn merge_page_topics(store: &mut GraphStore, page_url: &str, extract: &TopicExtract) {
+    // resolve labels → topic ids, reusing existing store topics first, then
+    // ones created earlier this call, then creating new. This is what makes
+    // merge idempotent and case-insensitive across calls.
+    let mut label_to_id: Vec<(String, String)> = Vec::new(); // (lowercase label, id)
+    for spec in &extract.topics {
+        let key = spec.label.to_ascii_lowercase();
+        let id = if let Some(t) = store.topics.iter().find(|t| t.label.to_ascii_lowercase() == key) {
+            t.id.clone()
+        } else if let Some((_, id)) = label_to_id.iter().find(|(l, _)| *l == key) {
+            id.clone()
+        } else {
+            let id = unique_topic_id(&store.topics, &spec.label, &label_to_id);
+            label_to_id.push((key, id.clone()));
+            store.topics.push(Topic {
+                id: id.clone(),
+                label: spec.label.clone(),
+                aliases: spec.aliases.clone(),
+                page_ids: vec![page_url.to_string()],
+            });
+            id
+        };
+        // attach page to topic (dedup)
+        let topic = store.topics.iter_mut().find(|t| t.id == id).unwrap();
+        if !topic.page_ids.iter().any(|u| u == page_url) {
+            topic.page_ids.push(page_url.to_string());
+        }
+        // attach topic to page (dedup)
+        if let Some(page) = store.pages.iter_mut().find(|p| p.url == page_url) {
+            if !page.topic_ids.iter().any(|t| *t == id) {
+                page.topic_ids.push(id.clone());
+            }
+        }
+        upsert_relation(
+            &mut store.relations,
+            &Relation { from: page_url.into(), to: id, kind: "page_topic".into() },
+        );
+    }
+
+    // inter-topic relations
+    for rel in &extract.relations {
+        let Some(from_id) = lookup_label(&label_to_id, &store.topics, &rel.from_label) else {
+            continue;
+        };
+        let Some(to_id) = lookup_label(&label_to_id, &store.topics, &rel.to_label) else {
+            continue;
+        };
+        if from_id == to_id {
+            continue;
+        }
+        upsert_relation(
+            &mut store.relations,
+            &Relation { from: from_id, to: to_id, kind: "topic_topic".into() },
+        );
+    }
+}
+
+/// Run extraction for one page and merge. Returns true if the API was called
+/// and the store mutated (false on dry-run). Network + max cap are the caller's
+/// responsibility; this is the single API-touching step both drivers share.
+pub fn enrich_one(
+    store: &mut GraphStore,
+    page_url: &str,
+    input: &TopicInput,
+    client: &C,
+    key: &str,
+    dry_run: bool,
+) -> Result {
+    if dry_run {
+        log::info!("topics [dry-run]: would enrich {page_url}");
+        return Ok(false);
+    }
+    let extract = client.extract(input, key)?;
+    merge_page_topics(store, page_url, &extract);
+    Ok(true)
+}
+
+fn unique_topic_id(topics: &[Topic], label: &str, taken: &[(String, String)]) -> String {
+    let base = slugify(label);
+    if base.is_empty() {
+        return "topic".into();
+    }
+    let id_exists = |id: &str| topics.iter().any(|t| t.id == id) || taken.iter().any(|(_, t)| t == id);
+    if !id_exists(&base) {
+        return base;
+    }
+    for n in 2.. {
+        let cand = format!("{base}-{n}");
+        if !id_exists(&cand) {
+            return cand;
+        }
+    }
+    unreachable!()
+}
+
+fn lookup_label(
+    fresh: &[(String, String)],
+    topics: &[Topic],
+    label: &str,
+) -> Option {
+    let key = label.to_ascii_lowercase();
+    if let Some((_, id)) = fresh.iter().find(|(l, _)| *l == key) {
+        return Some(id.clone());
+    }
+    topics
+        .iter()
+        .find(|t| t.label.to_ascii_lowercase() == key)
+        .map(|t| t.id.clone())
+}
+
+fn upsert_relation(rels: &mut Vec, rel: &Relation) {
+    let exists = rels
+        .iter()
+        .any(|r| r.from == rel.from && r.to == rel.to && r.kind == rel.kind);
+    if !exists {
+        rels.push(rel.clone());
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::cmd::graph::openrouter::{TopicClient, TopicExtract, TopicInput, TopicSpec};
+
+    fn store_with_page(url: &str) -> GraphStore {
+        GraphStore {
+            pages: vec![super::super::schema::Page {
+                url: url.into(),
+                path: "p/index.md".into(),
+                title: "T".into(),
+                summary: String::new(),
+                content_hash: "h".into(),
+                topic_ids: vec![],
+            }],
+            ..Default::default()
+        }
+    }
+
+    #[test]
+    fn slugify_basic() {
+        assert_eq!(slugify("Applicant Tracking!"), "applicant-tracking");
+        assert_eq!(slugify("  --hi--  "), "hi");
+        assert_eq!(slugify("Über"), "ber"); // non-ascii stripped — documented ceiling
+    }
+
+    #[test]
+    fn merge_creates_topics_and_edges() {
+        let mut store = store_with_page("https://x/a");
+        let extract = TopicExtract {
+            topics: vec![
+                TopicSpec { label: "Hiring".into(), aliases: vec!["Recruiting".into()] },
+                TopicSpec { label: "ATS".into(), aliases: vec![] },
+            ],
+            relations: vec![],
+        };
+        merge_page_topics(&mut store, "https://x/a", &extract);
+        assert_eq!(store.topics.len(), 2);
+        assert_eq!(store.topics[0].id, "hiring");
+        assert_eq!(store.topics[0].page_ids, vec!["https://x/a".to_string()]);
+        assert_eq!(store.topics[1].id, "ats");
+        let page = &store.pages[0];
+        assert_eq!(page.topic_ids, vec!["hiring".to_string(), "ats".to_string()]);
+        let pt: Vec<_> = store.relations.iter().filter(|r| r.kind == "page_topic").collect();
+        assert_eq!(pt.len(), 2);
+    }
+
+    #[test]
+    fn merge_is_idempotent() {
+        let mut store = store_with_page("https://x/a");
+        let extract = TopicExtract {
+            topics: vec![TopicSpec { label: "Hiring".into(), aliases: vec![] }],
+            relations: vec![],
+        };
+        merge_page_topics(&mut store, "https://x/a", &extract);
+        merge_page_topics(&mut store, "https://x/a", &extract); // re-merge same
+        assert_eq!(store.topics.len(), 1, "no duplicate topic");
+        assert_eq!(store.topics[0].page_ids.len(), 1, "page not double-added");
+        assert_eq!(store.relations.len(), 1, "no duplicate edge");
+    }
+
+    #[test]
+    fn merge_dedupes_case_insensitive_label() {
+        let mut store = store_with_page("https://x/a");
+        let ex1 = TopicExtract {
+            topics: vec![TopicSpec { label: "Hiring".into(), aliases: vec![] }],
+            relations: vec![],
+        };
+        let ex2 = TopicExtract {
+            topics: vec![TopicSpec { label: "hiring".into(), aliases: vec![] }],
+            relations: vec![],
+        };
+        merge_page_topics(&mut store, "https://x/a", &ex1);
+        merge_page_topics(&mut store, "https://x/a", &ex2);
+        assert_eq!(store.topics.len(), 1, "Hiring/hiring same topic");
+    }
+
+    #[test]
+    fn merge_inter_topic_relation() {
+        let mut store = store_with_page("https://x/a");
+        let extract = TopicExtract {
+            topics: vec![
+                TopicSpec { label: "Hiring".into(), aliases: vec![] },
+                TopicSpec { label: "ATS".into(), aliases: vec![] },
+            ],
+            relations: vec![super::super::openrouter::TopicRelationSpec {
+                from_label: "Hiring".into(),
+                to_label: "ATS".into(),
+                kind: "related".into(),
+            }],
+        };
+        merge_page_topics(&mut store, "https://x/a", &extract);
+        let tt: Vec<_> = store.relations.iter().filter(|r| r.kind == "topic_topic").collect();
+        assert_eq!(tt.len(), 1);
+        assert_eq!(tt[0].from, "hiring");
+        assert_eq!(tt[0].to, "ats");
+    }
+
+    /// Mock that returns a fixed extract regardless of input.
+    struct FixedClient;
+    impl TopicClient for FixedClient {
+        fn extract(&self, _input: &TopicInput, _key: &str) -> Result {
+            Ok(TopicExtract {
+                topics: vec![TopicSpec { label: "Mocked".into(), aliases: vec![] }],
+                relations: vec![],
+            })
+        }
+    }
+
+    #[test]
+    fn enrich_one_merges_and_reports_called() {
+        let mut store = store_with_page("https://x/a");
+        let input = TopicInput {
+            title: "T".into(),
+            description: String::new(),
+            body: "body".into(),
+        };
+        let called =
+            enrich_one(&mut store, "https://x/a", &input, &FixedClient, "k", false).unwrap();
+        assert!(called);
+        assert_eq!(store.topics.len(), 1);
+        assert_eq!(store.topics[0].label, "Mocked");
+    }
+
+    #[test]
+    fn enrich_one_dry_run_does_not_mutate() {
+        let mut store = store_with_page("https://x/a");
+        let input = TopicInput {
+            title: "T".into(),
+            description: String::new(),
+            body: "body".into(),
+        };
+        let called =
+            enrich_one(&mut store, "https://x/a", &input, &FixedClient, "k", true).unwrap();
+        assert!(!called);
+        assert!(store.topics.is_empty());
+    }
+}
diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs
index cdaf076fd..59ffd26b2 100644
--- a/src/cmd/mod.rs
+++ b/src/cmd/mod.rs
@@ -1,5 +1,6 @@
 mod build;
 mod check;
+pub mod graph;
 mod init;
 mod serve;
 mod translate;
diff --git a/src/main.rs b/src/main.rs
index b1af6f04e..92bb9401a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -214,5 +214,13 @@ fn main() {
                 std::process::exit(1);
             }
         }
+        Command::Graph { command } => {
+            log::info!("Graph...");
+            let (root_dir, config_file) = get_config_file_path(&cli_dir, cli.config.as_deref());
+            if let Err(e) = cmd::graph::run(&root_dir, &config_file, command) {
+                messages::unravel_errors("Failed to graph", &e);
+                std::process::exit(1);
+            }
+        }
     }
 }