kowalski_core/tools/internal/
web.rs1use regex::Regex;
8use reqwest::blocking::Client;
9use std::time::Duration;
10
11const FETCH_TIMEOUT_SECS: u64 = 90;
12
13#[derive(Debug, Clone, Copy, Default)]
15pub struct WebInternalModule;
16
17fn http_client() -> Result<Client, String> {
18 Client::builder()
19 .timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
20 .user_agent(concat!("Kowalski/", env!("CARGO_PKG_VERSION")))
21 .build()
22 .map_err(|e| e.to_string())
23}
24
25pub fn looks_like_html(s: &str) -> bool {
27 let t = s.trim_start();
28 t.starts_with("<!DOCTYPE")
29 || t.starts_with("<!doctype")
30 || t.starts_with("<html")
31 || t.starts_with("<HTML")
32 || (t.contains('<') && t.contains('>') && t[..t.len().min(512)].contains("</"))
33}
34
35fn flatten_inline_tags(html: &str) -> String {
36 let re_tags = Regex::new(r"<[^>]+>").expect("valid regex");
37 let t = re_tags.replace_all(html, " ");
38 html_entities::decode_html_entities(t.trim())
39}
40
41fn decode_href_entities(url: &str) -> String {
42 html_entities::decode_html_entities(url.trim())
43}
44
45pub fn html_body_to_markdown(html: &str) -> String {
49 html_to_markdown_at(html, None)
50}
51
52fn main_content(html: &str) -> std::borrow::Cow<'_, str> {
55 let re_main = Regex::new(r"(?is)<main\b[^>]*>(.*?)(?:</main\s*>|\z)").expect("valid regex");
57 let re_article = Regex::new(r"(?is)<article\b[^>]*>(.*?)</article\s*>").expect("valid regex");
58 let single_article = || {
60 let mut all = re_article.captures_iter(html);
61 match (all.next(), all.next()) {
62 (Some(c), None) => Some(c),
63 _ => None,
64 }
65 };
66 let picked = re_main
67 .captures(html)
68 .or_else(single_article)
69 .and_then(|c| c.get(1))
70 .map(|m| m.as_str())
71 .filter(|m| m.len() > 200);
72 let mut out = picked.unwrap_or(html).to_string();
73 for tag in ["nav", "header", "footer", "aside", "svg", "noscript", "template", "form", "details", "select", "dialog"] {
74 let re = Regex::new(&format!(r"(?is)<{tag}\b[^>]*>.*?</{tag}\s*>")).expect("valid regex");
75 out = re.replace_all(&out, " ").into_owned();
76 }
77 std::borrow::Cow::Owned(out)
78}
79
80pub fn html_to_markdown_at(html: &str, base: Option<&reqwest::Url>) -> String {
82 let title = Regex::new(r"(?is)<title[^>]*>(.*?)</title>")
83 .expect("valid regex")
84 .captures(html)
85 .and_then(|c| c.get(1))
86 .map(|m| flatten_inline_tags(m.as_str()))
87 .filter(|t| !t.is_empty());
88 let content = main_content(html);
89 let html = content.as_ref();
90 let re_script = Regex::new(r"(?is)<script[^>]*>.*?</script>").expect("valid regex");
91 let re_style = Regex::new(r"(?is)<style[^>]*>.*?</style>").expect("valid regex");
92 let re_anchor = Regex::new(
93 r#"(?is)<a\s[^>]*?\bhref\s*=\s*(?:"(?P<dq>[^"]*)"|'(?P<sq>[^']*)')[^>]*>(?P<inner>.*?)</a>"#,
94 )
95 .expect("valid regex");
96 let re_tags = Regex::new(r"<[^>]+>").expect("valid regex");
97 let re_ws = Regex::new(r"[ \t\r\f\v]+").expect("valid regex");
98 let re_nl = Regex::new(r"\n{3,}").expect("valid regex");
99
100 let s = re_script.replace_all(html, "");
101 let s = re_style.replace_all(&s, "");
102 let s = re_anchor.replace_all(&s, |caps: ®ex::Captures| {
103 let url = caps
104 .name("dq")
105 .or_else(|| caps.name("sq"))
106 .map(|m| decode_href_entities(m.as_str()))
107 .unwrap_or_default();
108 let url = match base {
109 Some(b) if !url.is_empty() && !url.starts_with('#') && !url.contains("://") => {
110 b.join(&url).map(|u| u.to_string()).unwrap_or(url)
111 }
112 _ => url,
113 };
114 let inner = caps.name("inner").map(|m| m.as_str()).unwrap_or("");
115 let text = flatten_inline_tags(inner);
116 if url.is_empty() {
117 return text;
118 }
119 let low = url.to_ascii_lowercase();
120 if low.starts_with("javascript:") || low.starts_with("data:") {
121 return text;
122 }
123 if text.is_empty() {
124 return format!("<{url}>");
125 }
126 if url.contains(' ') && !url.starts_with('<') {
127 return format!("[{text}](<{url}>)");
128 }
129 if url.contains(')') {
130 return format!("[{text}](<{url}>)");
131 }
132 format!("[{text}]({url})")
133 });
134 let s = re_tags.replace_all(&s, " ");
135 let s: String = html_entities::decode_html_entities(s.as_ref());
136 let s = re_ws.replace_all(&s, " ");
137 let lines: Vec<&str> = s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()).collect();
138 let body = lines.join("\n\n");
139 let body = re_nl.replace_all(&body, "\n\n");
140 let heading = title.map(|t| format!("# {t}\n\n")).unwrap_or_default();
141 format!(
142 "<!-- converted from HTML (internal web tool; heuristic strip) -->\n\n{heading}{}\n",
143 body.trim()
144 )
145}
146
147mod html_entities {
149 pub fn decode_html_entities(s: &str) -> String {
150 s.replace(" ", " ")
151 .replace("<", "<")
152 .replace(">", ">")
153 .replace("&", "&")
154 .replace(""", "\"")
155 .replace("'", "'")
156 }
157}
158
159pub fn fetch_http_body(url: &str) -> Result<String, String> {
161 let client = http_client()?;
162 let resp = client.get(url).send().map_err(|e| e.to_string())?;
163 if !resp.status().is_success() {
164 return Err(format!(
165 "HTTP {} {}",
166 resp.status().as_u16(),
167 resp.status().canonical_reason().unwrap_or("")
168 ));
169 }
170 resp.text().map_err(|e| e.to_string())
171}
172
173pub fn fetch_url_as_markdown(url: &str) -> Result<String, String> {
175 let text = fetch_http_body(url)?;
176 if looks_like_html(&text) {
177 Ok(html_to_markdown_at(&text, reqwest::Url::parse(url).ok().as_ref()))
178 } else {
179 Ok(text)
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn detects_html() {
189 assert!(looks_like_html("<html><body>Hi</body></html>"));
190 assert!(!looks_like_html("# Just markdown"));
191 }
192
193 #[test]
194 fn strips_script_and_tags() {
195 let html = r#"<html><head><script>evil()</script><style>.x{}</style></head>
196 <body><h1>Title</h1><p>Hello <b>world</b></p></body></html>"#;
197 let md = html_body_to_markdown(html);
198 assert!(!md.contains("evil"));
199 assert!(!md.contains("<script"));
200 assert!(md.contains("Title"));
201 assert!(md.contains("world"));
202 }
203
204 #[test]
205 fn keeps_main_content_and_resolves_relative_links() {
206 let html = r#"<html><head><title>Trending Rust</title></head><body>
207 <header><a href="/login">Sign in</a></header><nav>Platform Copilot Actions</nav>
208 <main><h2><a href="/tokio-rs/tokio">tokio-rs / tokio</a></h2>
209 <p>A runtime for writing reliable asynchronous applications with Rust. Stars today: 120.
210 More text so the main element is clearly the page's own content, not a stub.</p></main>
211 <footer>Terms Privacy</footer></body></html>"#;
212 let base = reqwest::Url::parse("https://github.com/trending/rust").unwrap();
213 let md = html_to_markdown_at(html, Some(&base));
214 assert!(md.contains("# Trending Rust"), "{md}");
215 assert!(md.contains("[tokio-rs / tokio](https://github.com/tokio-rs/tokio)"), "{md}");
216 assert!(!md.contains("Sign in") && !md.contains("Copilot") && !md.contains("Privacy"), "{md}");
217 }
218
219 #[test]
220 fn a_main_cut_off_by_the_fetch_cap_still_counts() {
221 let html = format!(
222 "<html><body><nav>Menu Menu</nav><main><p>{}</p><a href=\"/a/b\">a / b</a>",
223 "Repository description text. ".repeat(20)
224 );
225 let md = html_to_markdown_at(&html, Some(&reqwest::Url::parse("https://github.com/x").unwrap()));
226 assert!(md.contains("[a / b](https://github.com/a/b)") && !md.contains("Menu"), "{md}");
227 }
228
229 #[test]
230 fn preserves_anchors_as_markdown_links() {
231 let html = r#"<html><body><p>See <a href="https://example.com/path?q=1&r=2">Example</a> now.</p></body></html>"#;
232 let md = html_body_to_markdown(html);
233 assert!(md.contains("[Example](https://example.com/path?q=1&r=2)"));
234 }
235}