1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

feat(singlefile): add SaveResult, favicon extraction, wait for networkidle2

This commit is contained in:
TheGeneralist 2026-06-24 19:16:21 +02:00
parent ad1c4f8ee5
commit ee697625fb
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
4 changed files with 107 additions and 4 deletions

1
Cargo.lock generated
View file

@ -106,6 +106,7 @@ name = "archivr-core"
version = "0.1.0"
dependencies = [
"anyhow",
"base64",
"chrono",
"hex",
"regex",

View file

@ -28,3 +28,4 @@ tower = "0.5.1"
tower-http = { version = "0.6.2", features = ["fs", "trace"] }
uuid = { version = "1.18.1", features = ["v4"] }
reqwest = { version = "0.12", features = ["blocking"] }
base64 = "0.22"

View file

@ -14,6 +14,7 @@ serde_json.workspace = true
sha3.workspace = true
uuid.workspace = true
reqwest = { workspace = true }
base64.workspace = true
[dev-dependencies]
tempfile = "3"

View file

@ -1,8 +1,23 @@
use anyhow::{Context, Result, bail};
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine as _;
use std::{env, io::Read, path::Path, process::Command};
use crate::hash::hash_file;
/// Result of archiving a web page with single-file.
#[derive(Debug)]
pub struct SaveResult {
/// SHA-256 hex of the archived `.html` file.
pub html_hash: String,
/// Page title from `<title>` tag, if present.
pub title: Option<String>,
/// SHA-256 hex of the extracted favicon, if present.
pub favicon_hash: Option<String>,
/// File extension for the favicon (e.g. `".ico"`, `".png"`), if present.
pub favicon_ext: Option<String>,
}
/// Archives `url` as a self-contained HTML snapshot.
///
/// Returns `(sha256_hex, title_hint)` on success.
@ -12,7 +27,7 @@ use crate::hash::hash_file;
/// Reads two env vars:
/// - `ARCHIVR_SINGLE_FILE`: path to the `single-file` binary (default: `"single-file"`).
/// - `ARCHIVR_CHROME`: path to the Chromium/Chrome binary (default: `"chromium"`).
pub fn save(url: &str, store_path: &Path, timestamp: &str) -> Result<(String, Option<String>)> {
pub fn save(url: &str, store_path: &Path, timestamp: &str) -> Result<SaveResult> {
let single_file =
env::var("ARCHIVR_SINGLE_FILE").unwrap_or_else(|_| "single-file".to_string());
let chrome = env::var("ARCHIVR_CHROME").unwrap_or_else(|_| "chromium".to_string());
@ -27,7 +42,7 @@ fn save_with(
timestamp: &str,
single_file: &str,
chrome: &str,
) -> Result<(String, Option<String>)> {
) -> Result<SaveResult> {
let temp_dir = store_path.join("temp").join(timestamp);
std::fs::create_dir_all(&temp_dir).context("failed to create temp dir")?;
@ -38,6 +53,7 @@ fn save_with(
.arg(&out_file)
.arg(format!("--browser-executable-path={chrome}"))
.arg("--browser-headless")
.arg("--browser-wait-until=networkidle2")
.output()
.with_context(|| format!("failed to spawn {single_file} process"))?;
@ -54,8 +70,11 @@ fn save_with(
}
let title = extract_html_title(&out_file);
let hash = hash_file(&out_file)?;
Ok((hash, title))
let html_hash = hash_file(&out_file)?;
let (favicon_hash, favicon_ext) = extract_and_save_favicon(&out_file, &temp_dir, timestamp)
.map(|(h, e)| (Some(h), Some(e)))
.unwrap_or((None, None));
Ok(SaveResult { html_hash, title, favicon_hash, favicon_ext })
}
/// Reads the first 8 KiB of `path` and extracts the content of the first
@ -82,6 +101,87 @@ fn extract_html_title(path: &Path) -> Option<String> {
if title.is_empty() { None } else { Some(title.to_string()) }
}
/// Extracts the favicon embedded in a single-file HTML archive.
///
/// Scans for a `<link rel="…icon…">` tag whose `href` is a `data:image/…;base64,…` URL.
/// Decodes the base64 payload, writes it to `{temp_dir}/{timestamp}.favicon.{ext}`,
/// hashes the file, and returns `(sha256_hex, ".ext")`.
/// All failures are silent (returns `None`) — a missing favicon is non-fatal.
fn extract_and_save_favicon(
html_path: &Path,
temp_dir: &Path,
timestamp: &str,
) -> Option<(String, String)> {
let html = std::fs::read_to_string(html_path).ok()?;
let lower = html.to_ascii_lowercase();
// Find a <link> tag that has rel="...icon..." AND href="data:image/..."
let link_start = {
let mut found = None;
let mut search = 0;
while search < lower.len() {
let off = lower[search..].find("<link")?;
let abs = search + off;
// Find end of this tag, respecting quoted attribute values so that
// a '>' inside a data URL does not terminate the tag prematurely.
let tag_slice = &lower[abs..];
let mut in_q = false;
let mut tag_end = None;
for (i, c) in tag_slice.char_indices() {
match c {
'"' => in_q = !in_q,
'>' if !in_q => { tag_end = Some(i); break; }
_ => {}
}
}
let tag_end = match tag_end { Some(e) => e, None => break };
let tag_s = &lower[abs..abs + tag_end];
if tag_s.contains("rel=") && tag_s.contains("icon") && tag_s.contains("href=\"data:image") {
found = Some(abs);
break;
}
search = abs + tag_end + 1;
}
found?
};
// Extract href value from the original HTML (byte positions match because
// to_ascii_lowercase is byte-length-preserving).
let tag_lower = &lower[link_start..];
let href_off = tag_lower.find("href=\"")?;
let value_start = link_start + href_off + 6; // past href="
let value_end = html[value_start..].find('"')?;
let data_url = &html[value_start..value_start + value_end];
// Parse data:<mime>;base64,<payload>
let rest = data_url.strip_prefix("data:")?;
let comma = rest.find(',')?;
let meta = &rest[..comma];
let b64 = &rest[comma + 1..];
if !meta.to_ascii_lowercase().contains("base64") {
return None;
}
let mime = meta.split(';').next()?.trim().to_ascii_lowercase();
let ext = mime_to_favicon_ext(&mime)?;
let bytes = B64.decode(b64.trim()).ok()?;
let out = temp_dir.join(format!("{timestamp}.favicon{ext}"));
std::fs::write(&out, &bytes).ok()?;
hash_file(&out).ok().map(|h| (h, ext.to_string()))
}
fn mime_to_favicon_ext(mime: &str) -> Option<&'static str> {
match mime {
"image/x-icon" | "image/vnd.microsoft.icon" => Some(".ico"),
"image/png" => Some(".png"),
"image/svg+xml" => Some(".svg"),
"image/jpeg" => Some(".jpg"),
"image/gif" => Some(".gif"),
"image/webp" => Some(".webp"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;