Skip to main content

microclaw_engine/internal/tool_runtime/
web_html.rs

1use microclaw_core::text::floor_char_boundary;
2use std::borrow::Cow;
3
4#[derive(Debug, Clone)]
5pub struct SearchItem {
6    pub title: String,
7    pub url: String,
8    pub snippet: String,
9}
10
11fn strip_block(mut html: String, tag: &str) -> String {
12    let open = format!("<{}", tag);
13    let close = format!("</{}>", tag);
14    while let Some(start) = find_case_insensitive(&html, &open, 0) {
15        let Some(end) = find_case_insensitive(&html, &close, start) else {
16            html.truncate(start);
17            break;
18        };
19        let remove_to = end + close.len();
20        html.replace_range(start..remove_to, " ");
21    }
22    html
23}
24
25fn find_case_insensitive(haystack: &str, needle: &str, from: usize) -> Option<usize> {
26    if from >= haystack.len() {
27        return None;
28    }
29
30    // Callers may pass byte offsets derived from external data; coerce to a valid
31    // UTF-8 boundary to avoid panics when slicing.
32    let from = microclaw_core::text::floor_char_boundary(haystack, from);
33    let h = haystack[from..].to_ascii_lowercase();
34    let n = needle.to_ascii_lowercase();
35    h.find(&n).map(|idx| from + idx)
36}
37
38pub fn decode_html_entities(input: &str) -> Cow<'_, str> {
39    if !input.contains('&') {
40        return Cow::Borrowed(input);
41    }
42
43    let mut out = input.to_string();
44    let replacements = [
45        ("&nbsp;", " "),
46        ("&amp;", "&"),
47        ("&lt;", "<"),
48        ("&gt;", ">"),
49        ("&quot;", "\""),
50        ("&#39;", "'"),
51    ];
52    for (from, to) in replacements {
53        out = out.replace(from, to);
54    }
55    Cow::Owned(out)
56}
57
58pub fn html_to_text(html: &str) -> String {
59    let html = strip_block(strip_block(html.to_string(), "script"), "style");
60
61    let mut text = String::with_capacity(html.len());
62    let mut in_tag = false;
63    for ch in html.chars() {
64        match ch {
65            '<' => {
66                in_tag = true;
67                text.push(' ');
68            }
69            '>' => {
70                in_tag = false;
71            }
72            _ if !in_tag => text.push(ch),
73            _ => {}
74        }
75    }
76
77    collapse_whitespace(&decode_html_entities(&text))
78}
79
80pub fn collapse_whitespace(input: &str) -> String {
81    let mut out = String::with_capacity(input.len());
82    let mut last_ws = false;
83    for ch in input.chars() {
84        if ch.is_whitespace() {
85            if !last_ws {
86                out.push(' ');
87                last_ws = true;
88            }
89        } else {
90            out.push(ch);
91            last_ws = false;
92        }
93    }
94
95    let mut compact = String::with_capacity(out.len());
96    let punctuation = ['.', ',', ':', ';', '!', '?'];
97    for ch in out.trim().chars() {
98        if punctuation.contains(&ch) && compact.ends_with(' ') {
99            compact.pop();
100        }
101        compact.push(ch);
102    }
103
104    compact
105}
106
107fn extract_attr(tag: &str, attr: &str) -> Option<String> {
108    let lower_tag = tag.to_ascii_lowercase();
109    let needle = format!("{}=", attr.to_ascii_lowercase());
110    let idx = lower_tag.find(&needle)?;
111    let raw = &tag[idx + needle.len()..];
112    let raw = raw.trim_start();
113    if raw.is_empty() {
114        return None;
115    }
116
117    let first = raw.chars().next()?;
118    if first == '"' || first == '\'' {
119        let quote = first;
120        let rest = &raw[1..];
121        let end = rest.find(quote)?;
122        return Some(rest[..end].to_string());
123    }
124
125    let end = raw
126        .find(|c: char| c.is_whitespace() || c == '>')
127        .unwrap_or(raw.len());
128    Some(raw[..end].to_string())
129}
130
131fn extract_snippet_near(html: &str, from: usize) -> String {
132    let from = floor_char_boundary(html, from.min(html.len()));
133    let window_end = floor_char_boundary(html, from.saturating_add(4000).min(html.len()));
134    let segment = &html[from..window_end];
135    let Some(class_idx) = find_case_insensitive(segment, "result__snippet", 0) else {
136        return String::new();
137    };
138
139    let tag_start = segment[..class_idx].rfind('<').unwrap_or(0);
140    let Some(tag_end_rel) = segment[tag_start..].find('>') else {
141        return String::new();
142    };
143    let tag_end = tag_start + tag_end_rel;
144
145    let closing = if segment[tag_start..].starts_with("<a") {
146        "</a>"
147    } else {
148        "</div>"
149    };
150
151    let Some(close_rel) = find_case_insensitive(segment, closing, tag_end) else {
152        return String::new();
153    };
154
155    let inner = &segment[tag_end + 1..close_rel];
156    html_to_text(inner)
157}
158
159pub fn extract_ddg_results(html: &str, max_results: usize) -> Vec<SearchItem> {
160    let mut results = Vec::new();
161    let mut pos = 0usize;
162
163    while results.len() < max_results {
164        let Some(a_start) = find_case_insensitive(html, "<a", pos) else {
165            break;
166        };
167        let Some(a_tag_end_rel) = html[a_start..].find('>') else {
168            break;
169        };
170        let a_tag_end = a_start + a_tag_end_rel;
171        let a_tag = &html[a_start..=a_tag_end];
172
173        let class = extract_attr(a_tag, "class").unwrap_or_default();
174        if !class.split_whitespace().any(|c| c == "result__a") {
175            pos = a_tag_end + 1;
176            continue;
177        }
178
179        let Some(close_rel) = find_case_insensitive(html, "</a>", a_tag_end + 1) else {
180            break;
181        };
182
183        let href = extract_attr(a_tag, "href")
184            .map(|h| decode_html_entities(&h).into_owned())
185            .unwrap_or_default();
186        let title_html = &html[a_tag_end + 1..close_rel];
187        let title = html_to_text(title_html);
188        let snippet = extract_snippet_near(html, close_rel + 4);
189
190        if !href.is_empty() && !title.is_empty() {
191            results.push(SearchItem {
192                title,
193                url: href,
194                snippet,
195            });
196        }
197
198        pos = close_rel + 4;
199    }
200
201    results
202}
203
204pub fn extract_primary_html(html: &str) -> &str {
205    let candidates = ["main", "article", "body"];
206    for tag in candidates {
207        let open = format!("<{tag}");
208        let close = format!("</{tag}>");
209        if let Some(start) = find_case_insensitive(html, &open, 0) {
210            if let Some(open_end_rel) = html[start..].find('>') {
211                let content_start = start + open_end_rel + 1;
212                if let Some(end) = find_case_insensitive(html, &close, content_start) {
213                    return &html[content_start..end];
214                }
215            }
216        }
217    }
218    html
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn test_html_to_text() {
227        let html = "<html><body><h1>Hello&nbsp;World</h1><script>x=1;</script></body></html>";
228        assert_eq!(html_to_text(html), "Hello World");
229    }
230
231    #[test]
232    fn test_extract_ddg_results() {
233        let html = r#"
234<div>
235  <a class="result__a" href="https://example.com">Example <b>Title</b></a>
236  <a class="result__snippet">This is <b>snippet</b>.</a>
237</div>
238"#;
239        let items = extract_ddg_results(html, 8);
240        assert_eq!(items.len(), 1);
241        assert_eq!(items[0].title, "Example Title");
242        assert_eq!(items[0].url, "https://example.com");
243        assert_eq!(items[0].snippet, "This is snippet.");
244    }
245
246    #[test]
247    fn test_extract_primary_html_prefers_main() {
248        let html = "<body>body</body><main>main section</main>";
249        assert_eq!(extract_primary_html(html), "main section");
250    }
251
252    #[test]
253    fn test_find_case_insensitive_non_char_boundary_input() {
254        let s = "abc只def";
255        // byte 4 is inside the multi-byte '只'
256        assert_eq!(find_case_insensitive(s, "def", 4), Some(6));
257    }
258}