1pub mod animation;
37pub mod archive;
38pub mod batch;
39pub mod browser;
40pub mod extract_images;
41pub mod figures;
42pub mod gdocs;
43pub mod github;
44pub mod html;
45pub mod kreuzberg;
46pub mod latex;
47pub mod localize_images;
48pub mod markdown;
49pub mod metadata;
50pub mod postprocess;
51pub mod search;
52pub mod shared_dialog;
53pub mod stackoverflow;
54pub mod themed_image;
55pub mod verify;
56pub mod xpaste;
57
58use thiserror::Error;
59
60pub const VERSION: &str = env!("CARGO_PKG_VERSION");
62
63#[derive(Error, Debug)]
65pub enum WebCaptureError {
66 #[error("Failed to fetch URL: {0}")]
67 FetchError(String),
68
69 #[error("Failed to parse HTML: {0}")]
70 ParseError(String),
71
72 #[error("Failed to convert to Markdown: {0}")]
73 MarkdownError(String),
74
75 #[error("Failed to capture screenshot: {0}")]
76 ScreenshotError(String),
77
78 #[error("Browser error: {0}")]
79 BrowserError(String),
80
81 #[error("Invalid URL: {0}")]
82 InvalidUrl(String),
83
84 #[error("IO error: {0}")]
85 IoError(#[from] std::io::Error),
86
87 #[error("Request error: {0}")]
88 RequestError(#[from] reqwest::Error),
89}
90
91pub type Result<T> = std::result::Result<T, WebCaptureError>;
93
94pub async fn fetch_html(url: &str) -> Result<String> {
111 html::fetch_html(url).await
112}
113
114pub async fn render_html(url: &str) -> Result<String> {
131 browser::render_html(url).await
132}
133
134pub fn convert_html_to_markdown(html: &str, base_url: Option<&str>) -> Result<String> {
149 markdown::convert_html_to_markdown(html, base_url)
150}
151
152pub async fn capture_screenshot(url: &str) -> Result<Vec<u8>> {
169 browser::capture_screenshot(url).await
170}
171
172#[must_use]
183pub fn convert_relative_urls(html: &str, base_url: &str) -> String {
184 html::convert_relative_urls(html, base_url)
185}
186
187#[must_use]
199pub fn convert_to_utf8(html: &str) -> String {
200 html::convert_to_utf8(html)
201}
202
203#[allow(clippy::struct_excessive_bools)]
205#[derive(Debug, Clone)]
206pub struct EnhancedOptions {
207 pub extract_latex: bool,
209 pub extract_metadata: bool,
211 pub post_process: bool,
213 pub detect_code_language: bool,
215 pub content_selector: Option<String>,
217 pub body_selector: Option<String>,
219}
220
221impl Default for EnhancedOptions {
222 fn default() -> Self {
223 Self {
224 extract_latex: true,
225 extract_metadata: true,
226 post_process: true,
227 detect_code_language: true,
228 content_selector: None,
229 body_selector: None,
230 }
231 }
232}
233
234#[derive(Debug, Clone)]
236pub struct EnhancedMarkdownResult {
237 pub markdown: String,
238 pub metadata: Option<metadata::ArticleMetadata>,
239}
240
241pub fn convert_html_to_markdown_enhanced(
260 html: &str,
261 base_url: Option<&str>,
262 options: &EnhancedOptions,
263) -> Result<EnhancedMarkdownResult> {
264 let mut html_for_markdown = scope_html_with_selectors(html, options);
265
266 if options.extract_latex {
267 html_for_markdown = replace_latex_formula_elements(&html_for_markdown);
268 }
269
270 if options.detect_code_language {
271 html_for_markdown = correct_code_languages(&html_for_markdown);
272 }
273
274 let mut md = markdown::convert_html_to_markdown(&html_for_markdown, base_url)?;
276
277 let extracted_metadata = if options.extract_metadata {
279 let meta = metadata::extract_metadata(html);
280 let header_lines = metadata::format_metadata_block(&meta);
282 if !header_lines.is_empty() {
283 let header = header_lines.join("\n");
284 if let Some(pos) = md.find("\n\n") {
286 md = format!("{}\n\n{}\n{}", &md[..pos], header, &md[pos + 2..]);
287 } else {
288 md = format!("{header}\n\n{md}");
289 }
290 }
291 let footer_lines = metadata::format_footer_block(&meta);
293 if !footer_lines.is_empty() {
294 md.push_str("\n\n");
295 md.push_str(&footer_lines.join("\n"));
296 }
297 Some(meta)
298 } else {
299 None
300 };
301
302 if options.post_process {
304 md = postprocess::post_process_markdown(&md, &postprocess::PostProcessOptions::default());
305 }
306
307 if options.extract_latex {
308 md = normalize_extracted_latex_markdown(&md);
309 }
310
311 Ok(EnhancedMarkdownResult {
312 markdown: md,
313 metadata: extracted_metadata,
314 })
315}
316
317pub fn convert_with_kreuzberg(
336 html: &str,
337 base_url: Option<&str>,
338) -> Result<kreuzberg::KreuzbergResult> {
339 kreuzberg::convert_with_kreuzberg(html, base_url)
340}
341
342pub fn convert_with_kreuzberg_enhanced(
351 html: &str,
352 base_url: Option<&str>,
353 options: &EnhancedOptions,
354) -> Result<kreuzberg::KreuzbergResult> {
355 let scoped_html = scope_html_with_selectors(html, options);
356 kreuzberg::convert_with_kreuzberg(&scoped_html, base_url)
357}
358
359fn normalize_extracted_latex_markdown(markdown: &str) -> String {
360 let re = regex::Regex::new(r"\$([^$\n]+)\$").expect("valid regex");
361 re.replace_all(markdown, |caps: ®ex::Captures<'_>| {
362 let formula = caps.get(1).map_or("", |m| m.as_str()).replace(r"\\", r"\");
363 format!("${formula}$")
364 })
365 .into_owned()
366}
367
368fn scope_html_with_selectors(html: &str, options: &EnhancedOptions) -> String {
369 if let Some(body_selector) = options.body_selector.as_deref() {
370 let body_html = markdown::select_html(html, body_selector);
371 let title_selector = options
372 .content_selector
373 .as_deref()
374 .map_or_else(|| "h1".to_string(), |selector| format!("{selector} h1, h1"));
375 let title_html = markdown::select_html(html, &title_selector);
376 return match (title_html, body_html) {
377 (Some(title), Some(body)) => format!("{title}\n{body}"),
378 (None, Some(body)) => body,
379 _ => html.to_string(),
380 };
381 }
382
383 options
384 .content_selector
385 .as_deref()
386 .and_then(|selector| markdown::select_html(html, selector))
387 .unwrap_or_else(|| html.to_string())
388}
389
390fn replace_latex_formula_elements(html: &str) -> String {
391 let mut result = html.to_string();
392
393 let img_formula_re = regex::Regex::new(r"(?is)<img\b[^>]*>").expect("valid regex");
394 result = img_formula_re
395 .replace_all(&result, |caps: ®ex::Captures<'_>| {
396 let tag = caps.get(0).map_or("", |m| m.as_str());
397 if is_formula_img_tag(tag) {
398 extract_attr(tag, "source")
399 .or_else(|| extract_attr(tag, "alt"))
400 .map_or_else(
401 || tag.to_string(),
402 |latex| format!("${}$", normalize_latex_for_html(&latex)),
403 )
404 } else {
405 tag.to_string()
406 }
407 })
408 .into_owned();
409
410 let math_attr_re = regex::Regex::new(
411 r"(?is)<(?P<tag>mjx-container|span|div)\b(?P<attrs>[^>]*)>.*?</(?P<tag_close>mjx-container|span|div)>",
412 )
413 .expect("valid regex");
414 math_attr_re
415 .replace_all(&result, |caps: ®ex::Captures<'_>| {
416 let full = caps.get(0).map_or("", |m| m.as_str());
417 let attrs = caps.name("attrs").map_or("", |m| m.as_str());
418 let tag = caps
419 .name("tag")
420 .map_or("", |m| m.as_str())
421 .to_ascii_lowercase();
422 let tag_close = caps
423 .name("tag_close")
424 .map_or("", |m| m.as_str())
425 .to_ascii_lowercase();
426
427 if tag != tag_close || !is_math_attrs(&tag, attrs) {
428 return full.to_string();
429 }
430
431 extract_attr(attrs, "data-tex")
432 .or_else(|| extract_attr(attrs, "data-latex"))
433 .or_else(|| extract_annotation_tex(full))
434 .map_or_else(
435 || full.to_string(),
436 |latex| format!("${}$", normalize_latex_for_html(&latex)),
437 )
438 })
439 .into_owned()
440}
441
442fn correct_code_languages(html: &str) -> String {
443 let code_re = regex::Regex::new(r"(?is)<code\b(?P<attrs>[^>]*)>(?P<body>.*?)</code>")
444 .expect("valid regex");
445
446 code_re
447 .replace_all(html, |caps: ®ex::Captures<'_>| {
448 let full = caps.get(0).map_or("", |m| m.as_str());
449 let attrs = caps.name("attrs").map_or("", |m| m.as_str());
450 let body = caps.name("body").map_or("", |m| m.as_str());
451
452 if !has_matlab_language(attrs) || !looks_like_coq(body) {
453 return full.to_string();
454 }
455
456 let updated_attrs = attrs
457 .replace("language-matlab", "language-coq")
458 .replace(r#"class="matlab""#, r#"class="coq""#)
459 .replace("class='matlab'", "class='coq'");
460
461 format!("<code{updated_attrs}>{body}</code>")
462 })
463 .into_owned()
464}
465
466fn is_formula_img_tag(tag: &str) -> bool {
467 extract_attr(tag, "source").is_some()
468 || extract_attr(tag, "class").is_some_and(|classes| classes.contains("formula"))
469}
470
471fn is_math_attrs(tag: &str, attrs: &str) -> bool {
472 tag == "mjx-container"
473 || extract_attr(attrs, "class").is_some_and(|classes| {
474 classes.contains("katex") || classes.contains("math") || classes.contains("MathJax")
475 })
476}
477
478fn has_matlab_language(attrs: &str) -> bool {
479 extract_attr(attrs, "class").is_some_and(|classes| {
480 classes
481 .split_whitespace()
482 .any(|class| class == "language-matlab" || class == "matlab")
483 })
484}
485
486fn looks_like_coq(text: &str) -> bool {
487 let decoded = crate::html::decode_html_entities(text);
488 [
489 "Require Import",
490 "Definition",
491 "Fixpoint",
492 "Lemma",
493 "Theorem",
494 "Proof",
495 "Qed",
496 "Notation",
497 "Inductive",
498 ]
499 .iter()
500 .any(|needle| decoded.contains(needle))
501}
502
503fn normalize_latex_for_html(latex: &str) -> String {
504 latex.trim().replace('\\', "\")
505}
506
507fn extract_annotation_tex(html: &str) -> Option<String> {
508 let re = regex::Regex::new(
509 r#"(?is)<annotation\b[^>]*encoding\s*=\s*["']application/x-tex["'][^>]*>(.*?)</annotation>"#,
510 )
511 .ok()?;
512
513 re.captures(html).and_then(|caps| {
514 let text = caps.get(1)?.as_str().trim();
515 (!text.is_empty()).then(|| crate::html::decode_html_entities(text))
516 })
517}
518
519fn extract_attr(tag: &str, attr: &str) -> Option<String> {
520 let re = regex::Regex::new(&format!(
521 r#"(?is)\b{}\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))"#,
522 regex::escape(attr)
523 ))
524 .ok()?;
525
526 re.captures(tag).and_then(|caps| {
527 let value = caps
528 .get(1)
529 .or_else(|| caps.get(2))
530 .or_else(|| caps.get(3))?
531 .as_str()
532 .trim();
533 (!value.is_empty()).then(|| crate::html::decode_html_entities(value))
534 })
535}
536
537pub use browser::BrowserEngine;
539pub use search::{
540 search, SearchDiagnostics, SearchResult, SearchResultItem, DEFAULT_LIMIT, DEFAULT_PROVIDER,
541 SEARCH_PROVIDERS,
542};