microclaw_engine/internal/tool_runtime/
web_html.rs1use 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 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 (" ", " "),
46 ("&", "&"),
47 ("<", "<"),
48 (">", ">"),
49 (""", "\""),
50 ("'", "'"),
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 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 assert_eq!(find_case_insensitive(s, "def", 4), Some(6));
257 }
258}