Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
42 changes: 42 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize>,

/// 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<usize>,

/// Report stale/new pages without calling the LLM (no key needed).
#[clap(long)]
dry_run: bool,
},
}
162 changes: 162 additions & 0 deletions src/cmd/graph/firecrawl.rs
Original file line number Diff line number Diff line change
@@ -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<FetchedPage>;
}

/// 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<Self> {
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<FetchedPage> {
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);
}
Comment on lines +49 to +84

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Firecrawl v1 scrape endpoint formats parameter markdown html response data fields

💡 Result:

In the Firecrawl v1 /scrape endpoint, the formats parameter is an array used to specify the desired output content types [1][2]. If not provided, it defaults to ["markdown"] [3]. When you request specific formats, the response object returns data in fields corresponding to those formats [2][4]. Below is a summary of the common format parameters and their associated response data fields: Supported Formats and Response Fields: - Markdown: Use markdown in the formats array; the result is returned in the markdown field [1][2][4]. - HTML: Use html in the formats array to receive cleaned HTML (scripts/styles removed) in the html field [2][4]. - Raw HTML: Use rawHtml in the formats array to receive the unmodified page source in the raw_html field [2][4]. - Links: Use links to receive a list of links found on the page in the links field [2][4]. - Screenshot: Use screenshot (or screenshot@fullPage) to receive a signed URL in the screenshot field [1][5][3]. - JSON: Use json (often with an object containing schema and prompt) to receive structured data in the json field [2][3][4]. - Summary: Use summary to receive an AI-generated summary in the summary field [2][3][4]. - Additional formats: Supported formats also include images, branding, product, audio, video, and changeTracking (which requires markdown to be enabled) [5][3][4]. Important Notes: - The response object also consistently includes metadata fields such as metadata, metadata_dict, and metadata_typed [4]. - Fields corresponding to formats not requested in the formats parameter will typically be returned as null [4]. - For object-based formats like json, screenshot, or changeTracking, you pass an object in the formats array containing the relevant configuration (e.g., schema, prompt, fullPage) instead of just a string [3].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate files ---'
fd -t f 'firecrawl|graph|html_to_md' . | head -80

printf '%s\n' '--- firecrawl symbols and references ---'
rg -n -C 4 'FirecrawlFetcher|FIRECRAWL_URL|html_to_markdown|extract_title|empty body|migrate_with' . \
  -g '*.rs' -g '*.toml' -g '*.lock' | head -240

Repository: curriculo-tech/zola

Length of output: 13688


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- src/cmd/graph/firecrawl.rs ---'
cat -n src/cmd/graph/firecrawl.rs

printf '%s\n' '--- migration fetch/write path ---'
sed -n '80,230p' src/cmd/graph/migrate.rs

printf '%s\n' '--- html conversion behavior ---'
sed -n '1,70p' src/cmd/graph/html_to_md.rs
sed -n '145,185p' src/cmd/graph/html_to_md.rs

printf '%s\n' '--- format declarations and response-field assumptions ---'
rg -n -C 3 '"formats"|inner\["html"\]|inner\["markdown"\]|title' src/cmd/graph -g '*.rs'

Repository: curriculo-tech/zola

Length of output: 30698


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
import json

payload = {
    "url": "https://example.test",
    "formats": ["markdown"],
    "onlyMainContent": True,
}
response = {"data": {"markdown": None, "metadata": {"title": None}}}
inner = response["data"]

md = inner.get("markdown") or ""
title = inner.get("metadata", {}).get("title") or ""
html = inner.get("html") or ""
markdown = html  # html_to_markdown("") is tested to return ""
if not md.strip():
    markdown = html

print("requested_formats:", payload["formats"])
print("html_field_present:", "html" in inner)
print("html_value:", repr(html))
print("markdown_after_fallback:", repr(markdown))
print("would_bail_empty_body:", not markdown.strip())
print("title_after_fallback:", repr(title if not title and not html else title))
PY

Repository: curriculo-tech/zola

Length of output: 311


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
import json

payload = {
    "url": "https://example.test",
    "formats": ["markdown"],
    "onlyMainContent": True,
}
response = {"data": {"markdown": None, "metadata": {"title": None}}}
inner = response["data"]

md = inner.get("markdown") or ""
title = inner.get("metadata", {}).get("title") or ""
html = inner.get("html") or ""
markdown = html
if not md.strip():
    markdown = html

print("requested_formats:", payload["formats"])
print("html_field_present:", "html" in inner)
print("html_value:", repr(html))
print("markdown_after_fallback:", repr(markdown))
print("would_bail_empty_body:", not markdown.strip())
print("title_after_fallback:", repr(title if not title and not html else title))
PY

Repository: curriculo-tech/zola

Length of output: 311


Request both markdown and html formats, and use the HTML for title fallback.

With formats: ["markdown"], Firecrawl does not provide a usable data.html. When data.markdown is empty, the fallback produces an empty body and fetch returns Firecrawl: empty body. Also, extract_title is skipped whenever markdown is present because the HTML value is discarded. A missing metadata.title can therefore still write an empty title to front matter.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cmd/graph/firecrawl.rs` around lines 49 - 84, Update the payload in the
Firecrawl request to ask for both markdown and html formats. In the response
handling around `md`, `title`, and the `(markdown, html)` fallback, retain the
returned HTML regardless of whether markdown is present, use it for markdown
conversion when needed, and run `html_to_md::extract_title` whenever
metadata.title is empty so title fallback remains available.

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<String, FetchedPage>,
}

#[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<FetchedPage> {
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());
}
}
Loading