1use std::collections::{BTreeSet, VecDeque};
4
5use anyhow::{Result, ensure};
6use pagesift_html::Html;
7use pagesift_net::FetchRequest;
8use pagesift_policy::{Policy, RobotsMode};
9use serde::Serialize;
10use url::Url;
11
12#[derive(Clone, Copy, Debug)]
13pub struct ScriptBudget {
14 pub max_files: usize,
15 pub max_depth: usize,
16 pub max_bytes: usize,
17}
18impl Default for ScriptBudget {
19 fn default() -> Self {
20 Self {
22 max_files: 32,
23 max_depth: 3,
24 max_bytes: 4 * 1024 * 1024,
25 }
26 }
27}
28#[derive(Clone, Debug, Serialize)]
29pub struct ScriptEdge {
30 pub source: String,
31 pub url: String,
32 pub kind: &'static str,
33}
34#[derive(Debug, Default, Serialize)]
35pub struct ScriptGraph {
36 pub attempted_files: usize,
37 pub fetched_files: usize,
38 pub fetched_bytes: usize,
39 pub edges: Vec<ScriptEdge>,
40 pub failures: Vec<String>,
41 pub incomplete: bool,
42}
43
44pub async fn discover_script_graph(
47 policy: &Policy,
48 html: &str,
49 page: &Url,
50 budget: ScriptBudget,
51) -> Result<ScriptGraph> {
52 ensure!(html.len() <= 4 * 1024 * 1024, "HTML exceeds 4 MiB");
53 ensure!(
54 (1..=128).contains(&budget.max_files)
55 && budget.max_depth <= 8
56 && (1..=16 * 1024 * 1024).contains(&budget.max_bytes),
57 "invalid script budget"
58 );
59 let doc = Html::parse(html);
60 let base = doc
61 .root()
62 .descendants()
63 .find_map(|n| {
64 n.as_element()
65 .filter(|e| e.name() == "base")
66 .and_then(|e| e.attr("href"))
67 })
68 .and_then(|s| page.join(s).ok())
69 .unwrap_or_else(|| page.clone());
70 let mut queue = VecDeque::new();
71 let mut seen = BTreeSet::new();
72 let mut out = ScriptGraph::default();
73 let mut inline_bytes = 0;
74 for node in doc.root().descendants() {
75 let Some(e) = node.as_element() else { continue };
76 if e.name() != "script"
77 || e.attr("type").is_some_and(|s| {
78 !matches!(
79 s.trim(),
80 "" | "module" | "text/javascript" | "application/javascript"
81 )
82 })
83 {
84 continue;
85 }
86 if let Some(src) = e.attr("src") {
87 if let Some(u) = scoped(&base, src, page) {
88 enqueue(&mut queue, &mut seen, u, 0, &mut out);
89 }
90 } else {
91 let source = node.text();
92 inline_bytes += source.len();
93 if inline_bytes > 2 * 1024 * 1024 {
94 out.incomplete = true;
95 break;
96 }
97 let scan = pagesift_content::scan_script(&source, &base);
98 out.incomplete |= scan.budget_exceeded;
99 for url in scan.navigation {
100 if scoped(page, &url, page).is_some() {
101 edge(&mut out, page, url, "static-navigation");
102 }
103 }
104 for u in scan.dependencies {
105 if let Some(u) = scoped(page, &u, page) {
106 enqueue(&mut queue, &mut seen, u, 0, &mut out);
107 }
108 }
109 }
110 }
111 while let Some((url, depth)) = queue.pop_front() {
112 if out.attempted_files >= budget.max_files || out.fetched_bytes >= budget.max_bytes {
113 out.incomplete = true;
114 break;
115 }
116 let mut req = FetchRequest::new(url.as_str());
117 req.same_origin_redirects = true;
118 req.max_body_bytes = Some((budget.max_bytes - out.fetched_bytes).min(1024 * 1024));
119 out.attempted_files += 1;
120 let response = match policy.fetch(req, RobotsMode::Respect).await {
121 Ok(r) => r,
122 Err(e) => {
123 out.failures
124 .push(format!("{}: {e}", pagesift_net::redact_url(url.as_str())));
125 out.incomplete = true;
126 continue;
127 }
128 };
129 out.fetched_files += 1;
130 out.fetched_bytes += response.body.len();
131 if response.truncated
132 || response.status != 200
133 || response.final_url.origin() != page.origin()
134 {
135 out.failures.push(format!(
136 "{}: incomplete or unsuccessful script",
137 pagesift_net::redact_url(url.as_str())
138 ));
139 out.incomplete = true;
140 continue;
141 }
142 let mime = response
143 .content_type()
144 .unwrap_or("")
145 .split(';')
146 .next()
147 .unwrap_or("")
148 .trim();
149 if !matches!(
150 mime,
151 "text/javascript"
152 | "application/javascript"
153 | "application/ecmascript"
154 | "text/ecmascript"
155 ) {
156 out.failures.push(format!(
157 "{}: not JavaScript MIME",
158 pagesift_net::redact_url(url.as_str())
159 ));
160 out.incomplete = true;
161 continue;
162 }
163 let source = response.text().text;
164 let navigation = pagesift_content::scan_script(&source, &base);
165 let modules = pagesift_content::scan_script(&source, &response.final_url);
166 out.incomplete |= navigation.budget_exceeded || modules.budget_exceeded;
167 for u in navigation.navigation {
168 if scoped(page, &u, page).is_some() {
169 edge(&mut out, &response.final_url, u, "static-navigation");
170 }
171 }
172 for u in modules.dependencies {
173 if let Some(u) = scoped(page, &u, page) {
174 edge(&mut out, &response.final_url, u.to_string(), "module");
175 if depth < budget.max_depth {
176 enqueue(&mut queue, &mut seen, u, depth + 1, &mut out);
177 } else if !seen.contains(u.as_str()) {
178 out.incomplete = true;
179 }
180 }
181 }
182 }
183 Ok(out)
184}
185fn scoped(base: &Url, value: &str, page: &Url) -> Option<Url> {
186 let mut u = base.join(value).ok()?;
187 if !matches!(u.scheme(), "http" | "https")
188 || u.origin() != page.origin()
189 || !u.username().is_empty()
190 || u.password().is_some()
191 {
192 return None;
193 }
194 u.set_fragment(None);
195 Some(u)
196}
197fn enqueue(
198 queue: &mut VecDeque<(Url, usize)>,
199 seen: &mut BTreeSet<String>,
200 url: Url,
201 depth: usize,
202 out: &mut ScriptGraph,
203) {
204 if seen.len() >= 1024 {
205 out.incomplete = true;
206 return;
207 }
208 if seen.insert(url.to_string()) {
209 queue.push_back((url, depth));
210 }
211}
212fn edge(out: &mut ScriptGraph, source: &Url, url: String, kind: &'static str) {
213 if out.edges.len() >= 4096 {
214 out.incomplete = true;
215 return;
216 }
217 if !out
218 .edges
219 .iter()
220 .any(|e| e.source == source.as_str() && e.url == url && e.kind == kind)
221 {
222 out.edges.push(ScriptEdge {
223 source: source.to_string(),
224 url,
225 kind,
226 });
227 }
228}