1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
use std::fs;
use std::path::{Path, PathBuf};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use crate::errors::{Result, SiteforgeError};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
/// Siteforge runtime configuration.
///
/// Values can be loaded from YAML with [`Config::load`] or constructed
/// directly for library use. Relative paths resolve from the current working
/// directory when operations run.
pub struct Config {
/// Root directory where archive directories are created.
pub archive_root: PathBuf,
/// Artifact volume/profile for newly created archives.
pub archive_profile: ArchiveOutputProfile,
/// Default concurrent page fetch count.
pub default_concurrency: usize,
/// Default politeness delay between requests to the same origin, in milliseconds.
pub default_delay_ms: u64,
/// Default maximum link depth for full-site crawls.
pub max_depth: usize,
/// Default maximum number of pages to fetch per archive.
pub max_pages: usize,
/// Maximum bytes to read for a page response body. `0` means no body cap.
pub max_page_size_bytes: u64,
/// Maximum bytes to read for an asset response body. `0` means no body cap.
pub max_asset_size_bytes: u64,
/// Maximum total archive bytes. `0` disables the total archive cap.
pub max_total_archive_size_bytes: u64,
/// Optional URL glob allow-list. Empty means all in-scope URLs are eligible.
pub include_url_patterns: Vec<String>,
/// URL glob deny-list applied after scope checks.
pub exclude_url_patterns: Vec<String>,
/// Enable OCR extraction for likely code/image assets.
pub ocr_enabled: bool,
/// Shell command template for OCR. `{input}` is replaced with the asset path.
pub ocr_backend_command: String,
/// User-Agent sent by the HTTP fetcher and browser renderer.
pub user_agent: String,
/// Per-request timeout in seconds.
pub timeout_secs: u64,
/// Number of retry attempts for retryable fetch failures.
pub retry_count: usize,
/// Default token budget for generated AI context packs.
pub ai_pack_token_budget: usize,
/// Render pages with a browser before parsing when enabled.
pub render_js: bool,
/// With `render_js`, only render thin SPA-like pages instead of every page.
pub render_js_auto: bool,
/// Optional browser command or executable for JavaScript rendering.
pub browser_command: Option<String>,
/// Optional browser user-data directory/profile for authorized content.
pub browser_profile: Option<PathBuf>,
/// Browser virtual-time wait budget in milliseconds.
pub render_wait_ms: u64,
}
#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
/// Controls how much artifact data each archived page writes.
pub enum ArchiveOutputProfile {
/// Preserve all page artifacts: raw payloads, assets, Markdown variants, JSON, text, and chunks.
#[default]
Full,
/// Keep raw evidence and agent-readable files, but skip duplicate plain text and YAML Markdown.
Agent,
/// Keep compact agent-readable Markdown, JSON, chunks, metadata, and checksums only.
Lean,
}
impl ArchiveOutputProfile {
/// Whether raw fetched HTML pages are stored.
pub fn stores_raw_pages(self) -> bool {
matches!(self, Self::Full | Self::Agent)
}
/// Whether rendered DOM captures are stored when JS rendering succeeds.
pub fn stores_rendered_pages(self) -> bool {
matches!(self, Self::Full | Self::Agent)
}
/// Whether page assets are downloaded into `raw/assets/`.
pub fn downloads_assets(self) -> bool {
matches!(self, Self::Full | Self::Agent)
}
/// Whether YAML-frontmatter Markdown is stored in `readable/markdown/`.
pub fn stores_markdown(self) -> bool {
matches!(self, Self::Full)
}
/// Whether no-frontmatter basic Markdown is stored in `readable/basic_markdown/`.
pub fn stores_basic_markdown(self) -> bool {
true
}
/// Whether structured page JSON is stored in `readable/json/`.
pub fn stores_json(self) -> bool {
true
}
/// Whether plain text duplicates are stored in `readable/text/`.
pub fn stores_text(self) -> bool {
matches!(self, Self::Full)
}
/// Whether semantic JSONL chunks are appended to `chunks/chunks.jsonl`.
pub fn stores_chunks(self) -> bool {
true
}
}
impl Default for Config {
fn default() -> Self {
Self {
archive_root: PathBuf::from("archives"),
archive_profile: ArchiveOutputProfile::Full,
default_concurrency: 4,
default_delay_ms: 250,
max_depth: 4,
max_pages: 500,
max_page_size_bytes: 50 * 1024 * 1024,
max_asset_size_bytes: 25 * 1024 * 1024,
max_total_archive_size_bytes: 5 * 1024 * 1024 * 1024,
include_url_patterns: Vec::new(),
exclude_url_patterns: vec![
"**/login**".to_string(),
"**/logout**".to_string(),
"**/signup**".to_string(),
"**/cart**".to_string(),
],
ocr_enabled: false,
ocr_backend_command: "tesseract {input} stdout".to_string(),
user_agent: "siteforge/0.1 (+https://github.com/Tknott95/siteforge)".to_string(),
timeout_secs: 30,
retry_count: 2,
ai_pack_token_budget: 120_000,
render_js: false,
render_js_auto: false,
browser_command: None,
browser_profile: None,
render_wait_ms: 5_000,
}
}
}
impl Config {
/// Load YAML configuration from `path`, or the platform default path when `None`.
///
/// Missing files return [`Config::default`]. A configured concurrency of zero
/// is normalized to one.
pub fn load(path: Option<&Path>) -> Result<Self> {
let path = match path {
Some(path) => path.to_path_buf(),
None => Self::default_path()?,
};
if !path.exists() {
return Ok(Self::default());
}
let raw = fs::read_to_string(&path)?;
let mut config: Config = serde_yaml::from_str(&raw)?;
if config.default_concurrency == 0 {
config.default_concurrency = 1;
}
Ok(config)
}
/// Return the platform default configuration path.
pub fn default_path() -> Result<PathBuf> {
let dirs = ProjectDirs::from("com", "siteforge", "siteforge")
.ok_or(SiteforgeError::ConfigDirUnavailable)?;
Ok(dirs.config_dir().join("config.yaml"))
}
/// Create the platform default configuration file when missing and return its path.
pub fn ensure_default_file() -> Result<PathBuf> {
let path = Self::default_path()?;
if !path.exists() {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, serde_yaml::to_string(&Self::default())?)?;
}
Ok(path)
}
/// Resolve [`Config::archive_root`] to an absolute path.
pub fn resolved_archive_root(&self) -> Result<PathBuf> {
if self.archive_root.is_absolute() {
Ok(self.archive_root.clone())
} else {
Ok(std::env::current_dir()?.join(&self.archive_root))
}
}
}