Skip to main content

pagesift_engine/
scripts.rs

1//! Opt-in, origin-scoped external module traversal. Candidates carry provenance
2//! and remain separate from observed browser traffic or confirmed GET requests.
3use 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        // [策略] 用户可配的预算(`ScriptBudget` 的字段,带校验范围);默认值:先验包 v0:0.81 的现值
21        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
44/// The root HTML is supplied by the caller, with its final URL. Same-origin
45/// checks also apply before redirect sends; all fetches use the normal policy.
46pub 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}