1#![allow(clippy::too_many_lines, clippy::collapsible_if)]
2use std::collections::VecDeque;
14
15pub const KEEP_DATA_DIVISOR: usize = 2;
16const MIN_HTML_BYTES: usize = 5000;
17const MAX_EXTRACTED_TOKENS: usize = 8000;
18const CHARS_PER_TOKEN_ESTIMATE: usize = 4;
19const TRACKING_QUERY_KEYS: &[&str] = &["fbclid", "gclid", "mc_cid", "mc_eid", "_ga", "ref_src"];
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct CodeBlock {
24 pub language: String,
26 pub content: String,
28}
29
30#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct ArticleMeta {
33 pub author: Option<String>,
34 pub date: Option<String>,
35 pub url: Option<String>,
36}
37
38#[derive(Debug, Clone, PartialEq)]
40pub struct ExtractionResult {
41 pub title: Option<String>,
42 pub content: String,
43 pub code_blocks: Vec<CodeBlock>,
44 pub metadata: ArticleMeta,
45 pub token_reduction: f64,
47}
48
49impl ExtractionResult {
50 #[must_use]
52 pub fn metadata_line(&self) -> Option<String> {
53 let mut fields = Vec::new();
54 if let Some(url) = self.metadata.url.as_deref() {
55 fields.push(url);
56 }
57 if let Some(title) = self.title.as_deref() {
58 fields.push(title);
59 }
60 if let Some(date) = self.metadata.date.as_deref() {
61 fields.push(date);
62 }
63 (!fields.is_empty()).then(|| format!("Source: {}", fields.join(" | ")))
64 }
65}
66
67#[derive(Debug, Clone)]
68pub struct CrushResult {
69 pub text: String,
70 pub lossless: bool,
71 pub original_bytes: usize,
72 pub extracted_tokens: usize,
73}
74
75pub fn is_html_content(content: &str) -> bool {
76 let trimmed = content.trim_start().to_ascii_lowercase();
77 trimmed.starts_with("<!doctype")
78 || trimmed.starts_with("<html")
79 || trimmed.starts_with("<?xml")
80 || (trimmed.contains("<head") && trimmed.contains("<body"))
81}
82
83pub fn crush_if_beneficial(html: &str) -> Option<CrushResult> {
84 if html.len() < MIN_HTML_BYTES {
85 return None;
86 }
87 if !is_html_content(html) {
88 return None;
89 }
90
91 let extraction = extract_article_content(html);
92 if extraction.content.is_empty() {
93 return None;
94 }
95 let extracted = match extraction.metadata_line() {
96 Some(line) => format!("{line}\n\n{}", extraction.content),
97 None => extraction.content,
98 };
99
100 let original_tokens = html.len() / CHARS_PER_TOKEN_ESTIMATE;
101 let extracted_tokens = extracted.len() / CHARS_PER_TOKEN_ESTIMATE;
102
103 if extracted_tokens * KEEP_DATA_DIVISOR >= original_tokens {
104 return None;
105 }
106 if extracted_tokens > MAX_EXTRACTED_TOKENS {
107 let char_budget = MAX_EXTRACTED_TOKENS * CHARS_PER_TOKEN_ESTIMATE;
108 let mut end = char_budget.min(extracted.len());
109 while !extracted.is_char_boundary(end) && end > 0 {
110 end -= 1;
111 }
112 let truncated = format!(
113 "{}\n\n[… truncated, {} more tokens in original]",
114 &extracted[..end],
115 original_tokens - MAX_EXTRACTED_TOKENS
116 );
117 return Some(CrushResult {
118 text: truncated,
119 lossless: false,
120 original_bytes: html.len(),
121 extracted_tokens: MAX_EXTRACTED_TOKENS,
122 });
123 }
124
125 Some(CrushResult {
126 text: extracted,
127 lossless: false,
128 original_bytes: html.len(),
129 extracted_tokens,
130 })
131}
132
133pub fn extract_article(html: &str) -> String {
134 extract_article_content(html).content
135}
136
137pub fn extract_article_content(html: &str) -> ExtractionResult {
139 let tokens = tokenize(html);
140 let nodes = build_tree(&tokens);
141 let article = select_main_content(&nodes);
142 let content = nodes_to_markdown(&article);
143 let (title, metadata) = extract_metadata(&nodes, &article);
144 let code_blocks = collect_code_blocks(&article);
145 let token_reduction = if html.is_empty() {
146 0.0
147 } else {
148 (1.0 - content.len() as f64 / html.len() as f64).clamp(0.0, 1.0)
149 };
150 ExtractionResult {
151 title,
152 content,
153 code_blocks,
154 metadata,
155 token_reduction,
156 }
157}
158
159#[derive(Debug, Clone, PartialEq)]
162enum HtmlToken {
163 OpenTag {
164 name: String,
165 attrs: Vec<(String, String)>,
166 self_closing: bool,
167 },
168 CloseTag {
169 name: String,
170 },
171 Text(String),
172}
173
174fn tokenize(html: &str) -> Vec<HtmlToken> {
175 let mut tokens = Vec::new();
176 let mut chars = html.chars().peekable();
177 let mut text_buf = String::new();
178
179 while let Some(&ch) = chars.peek() {
180 if ch == '<' {
181 if !text_buf.is_empty() {
182 let t = std::mem::take(&mut text_buf);
183 tokens.push(HtmlToken::Text(decode_entities(&t)));
184 }
185 chars.next();
186 if chars.peek() == Some(&'!') {
187 skip_comment_or_doctype(&mut chars);
188 continue;
189 }
190 let is_close = chars.peek() == Some(&'/');
191 if is_close {
192 chars.next();
193 }
194 let tag_name = consume_tag_name(&mut chars);
195 if tag_name.is_empty() {
196 text_buf.push('<');
197 if is_close {
198 text_buf.push('/');
199 }
200 continue;
201 }
202 if is_close {
203 skip_until_gt(&mut chars);
204 tokens.push(HtmlToken::CloseTag {
205 name: tag_name.to_ascii_lowercase(),
206 });
207 } else {
208 let (attrs, self_closing) = parse_attrs(&mut chars);
209 let name = tag_name.to_ascii_lowercase();
210 if is_raw_text_element(&name) {
211 skip_raw_content(&mut chars, &name);
212 }
213 tokens.push(HtmlToken::OpenTag {
214 name,
215 attrs,
216 self_closing,
217 });
218 }
219 } else {
220 text_buf.push(ch);
221 chars.next();
222 }
223 }
224 if !text_buf.is_empty() {
225 tokens.push(HtmlToken::Text(decode_entities(&text_buf)));
226 }
227 tokens
228}
229
230fn is_raw_text_element(name: &str) -> bool {
231 matches!(name, "script" | "style" | "noscript" | "template")
232}
233
234fn skip_raw_content(chars: &mut std::iter::Peekable<std::str::Chars>, tag: &str) {
235 let close_tag = format!("</{tag}");
236 let mut buf = String::new();
237 for ch in chars.by_ref() {
238 buf.push(ch);
239 if buf.ends_with(&close_tag) {
240 skip_until_gt(chars);
241 return;
242 }
243 if buf.len() > close_tag.len() + 100 {
244 buf.drain(..buf.len() - close_tag.len());
245 }
246 }
247}
248
249fn skip_comment_or_doctype(chars: &mut std::iter::Peekable<std::str::Chars>) {
250 for ch in chars.by_ref() {
251 if ch == '>' {
252 return;
253 }
254 }
255}
256
257fn consume_tag_name(chars: &mut std::iter::Peekable<std::str::Chars>) -> String {
258 let mut name = String::new();
259 while let Some(&ch) = chars.peek() {
260 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
261 name.push(ch);
262 chars.next();
263 } else {
264 break;
265 }
266 }
267 name
268}
269
270fn parse_attrs(chars: &mut std::iter::Peekable<std::str::Chars>) -> (Vec<(String, String)>, bool) {
271 let mut attrs = Vec::new();
272 let mut self_closing = false;
273
274 loop {
275 skip_whitespace(chars);
276 match chars.peek() {
277 None => break,
278 Some(&'>') => {
279 chars.next();
280 break;
281 }
282 Some(&'/') => {
283 chars.next();
284 if chars.peek() == Some(&'>') {
285 chars.next();
286 self_closing = true;
287 }
288 break;
289 }
290 _ => {}
291 }
292 let key = consume_attr_name(chars);
293 if key.is_empty() {
294 chars.next();
295 continue;
296 }
297 skip_whitespace(chars);
298 let value = if chars.peek() == Some(&'=') {
299 chars.next();
300 skip_whitespace(chars);
301 consume_attr_value(chars)
302 } else {
303 String::new()
304 };
305 attrs.push((key.to_ascii_lowercase(), value));
306 }
307 (attrs, self_closing)
308}
309
310fn consume_attr_name(chars: &mut std::iter::Peekable<std::str::Chars>) -> String {
311 let mut name = String::new();
312 while let Some(&ch) = chars.peek() {
313 if ch == '=' || ch == '>' || ch == '/' || ch.is_ascii_whitespace() {
314 break;
315 }
316 name.push(ch);
317 chars.next();
318 }
319 name
320}
321
322fn consume_attr_value(chars: &mut std::iter::Peekable<std::str::Chars>) -> String {
323 let mut value = String::new();
324 match chars.peek() {
325 Some(&'"') => {
326 chars.next();
327 for ch in chars.by_ref() {
328 if ch == '"' {
329 break;
330 }
331 value.push(ch);
332 }
333 }
334 Some(&'\'') => {
335 chars.next();
336 for ch in chars.by_ref() {
337 if ch == '\'' {
338 break;
339 }
340 value.push(ch);
341 }
342 }
343 _ => {
344 while let Some(&ch) = chars.peek() {
345 if ch.is_ascii_whitespace() || ch == '>' {
346 break;
347 }
348 value.push(ch);
349 chars.next();
350 }
351 }
352 }
353 value
354}
355
356fn skip_whitespace(chars: &mut std::iter::Peekable<std::str::Chars>) {
357 while chars.peek().is_some_and(char::is_ascii_whitespace) {
358 chars.next();
359 }
360}
361
362fn skip_until_gt(chars: &mut std::iter::Peekable<std::str::Chars>) {
363 for ch in chars.by_ref() {
364 if ch == '>' {
365 return;
366 }
367 }
368}
369
370fn decode_entities(text: &str) -> String {
371 text.replace("&", "&")
372 .replace("<", "<")
373 .replace(">", ">")
374 .replace(""", "\"")
375 .replace("'", "'")
376 .replace("'", "'")
377 .replace(" ", " ")
378 .replace("'", "'")
379 .replace("/", "/")
380}
381
382#[derive(Debug, Clone)]
385struct HtmlNode {
386 tag: String,
387 attrs: Vec<(String, String)>,
388 children: Vec<HtmlNodeChild>,
389}
390
391#[derive(Debug, Clone)]
392enum HtmlNodeChild {
393 Element(HtmlNode),
394 Text(String),
395}
396
397const VOID_ELEMENTS: &[&str] = &[
398 "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
399 "track", "wbr",
400];
401
402fn build_tree(tokens: &[HtmlToken]) -> Vec<HtmlNodeChild> {
403 let mut stack: VecDeque<HtmlNode> = VecDeque::new();
404 stack.push_back(HtmlNode {
405 tag: "root".into(),
406 attrs: vec![],
407 children: vec![],
408 });
409
410 for token in tokens {
411 match token {
412 HtmlToken::OpenTag {
413 name,
414 attrs,
415 self_closing,
416 } => {
417 if *self_closing || VOID_ELEMENTS.contains(&name.as_str()) {
418 let node = HtmlNode {
419 tag: name.clone(),
420 attrs: attrs.clone(),
421 children: vec![],
422 };
423 if let Some(parent) = stack.back_mut() {
424 parent.children.push(HtmlNodeChild::Element(node));
425 }
426 } else {
427 stack.push_back(HtmlNode {
428 tag: name.clone(),
429 attrs: attrs.clone(),
430 children: vec![],
431 });
432 }
433 }
434 HtmlToken::CloseTag { name } => {
435 if let Some(pos) = stack.iter().rposition(|n| n.tag == *name) {
436 while stack.len() > pos + 1 {
437 let child = stack.pop_back().unwrap();
438 if let Some(parent) = stack.back_mut() {
439 parent.children.push(HtmlNodeChild::Element(child));
440 }
441 }
442 let child = stack.pop_back().unwrap();
443 if let Some(parent) = stack.back_mut() {
444 parent.children.push(HtmlNodeChild::Element(child));
445 }
446 }
447 }
448 HtmlToken::Text(text) => {
449 let trimmed = text.trim();
450 if !trimmed.is_empty()
451 && let Some(parent) = stack.back_mut()
452 {
453 parent.children.push(HtmlNodeChild::Text(text.clone()));
454 }
455 }
456 }
457 }
458
459 while stack.len() > 1 {
460 let child = stack.pop_back().unwrap();
461 if let Some(parent) = stack.back_mut() {
462 parent.children.push(HtmlNodeChild::Element(child));
463 }
464 }
465
466 stack.pop_back().map(|r| r.children).unwrap_or_default()
467}
468
469const BOILERPLATE_TAGS: &[&str] = &["nav", "footer", "header", "aside", "menu", "menuitem"];
472
473const BOILERPLATE_ROLES: &[&str] = &[
474 "navigation",
475 "banner",
476 "contentinfo",
477 "complementary",
478 "menu",
479];
480
481const BOILERPLATE_CLASSES: &[&str] = &[
482 "nav",
483 "navbar",
484 "footer",
485 "sidebar",
486 "menu",
487 "cookie",
488 "banner",
489 "advertisement",
490 "ad",
491 "social",
492 "share",
493 "comment",
494 "related",
495];
496
497fn select_main_content(nodes: &[HtmlNodeChild]) -> Vec<HtmlNodeChild> {
498 if let Some(main) = find_element_by_tag_or_role(nodes, "main", "main") {
499 return main.children.clone();
500 }
501 if let Some(article) = find_element_by_tag_or_role(nodes, "article", "") {
502 return article.children.clone();
503 }
504 if let Some(content) = find_element_by_id_class(
505 nodes,
506 &[
507 "content",
508 "main-content",
509 "post-content",
510 "entry-content",
511 "article-body",
512 ],
513 ) {
514 return content.children.clone();
515 }
516 if let Some(body) = find_element_by_tag(nodes, "body") {
517 return filter_boilerplate(&body.children);
518 }
519 filter_boilerplate(nodes)
520}
521
522fn find_element_by_tag_or_role<'a>(
523 nodes: &'a [HtmlNodeChild],
524 tag: &str,
525 role: &str,
526) -> Option<&'a HtmlNode> {
527 for child in nodes {
528 if let HtmlNodeChild::Element(el) = child {
529 if el.tag == tag {
530 return Some(el);
531 }
532 if !role.is_empty() && el.attrs.iter().any(|(k, v)| k == "role" && v == role) {
533 return Some(el);
534 }
535 if let Some(found) = find_element_by_tag_or_role(&el.children, tag, role) {
536 return Some(found);
537 }
538 }
539 }
540 None
541}
542
543fn find_element_by_tag<'a>(nodes: &'a [HtmlNodeChild], tag: &str) -> Option<&'a HtmlNode> {
544 for child in nodes {
545 if let HtmlNodeChild::Element(el) = child {
546 if el.tag == tag {
547 return Some(el);
548 }
549 if let Some(found) = find_element_by_tag(&el.children, tag) {
550 return Some(found);
551 }
552 }
553 }
554 None
555}
556
557fn find_element_by_id_class<'a>(
558 nodes: &'a [HtmlNodeChild],
559 candidates: &[&str],
560) -> Option<&'a HtmlNode> {
561 for child in nodes {
562 if let HtmlNodeChild::Element(el) = child {
563 let id = el
564 .attrs
565 .iter()
566 .find(|(k, _)| k == "id")
567 .map_or("", |(_, v)| v.as_str());
568 let class = el
569 .attrs
570 .iter()
571 .find(|(k, _)| k == "class")
572 .map_or("", |(_, v)| v.as_str());
573 if candidates
574 .iter()
575 .any(|c| id == *c || class.split_whitespace().any(|cls| cls == *c))
576 {
577 return Some(el);
578 }
579 if let Some(found) = find_element_by_id_class(&el.children, candidates) {
580 return Some(found);
581 }
582 }
583 }
584 None
585}
586
587fn attribute<'a>(el: &'a HtmlNode, name: &str) -> Option<&'a str> {
588 el.attrs
589 .iter()
590 .find(|(key, _)| key.eq_ignore_ascii_case(name))
591 .map(|(_, value)| value.as_str())
592}
593
594fn node_text(nodes: &[HtmlNodeChild]) -> String {
595 let mut text = String::new();
596 raw_text(nodes, &mut text);
597 normalize_whitespace(&text).trim().to_string()
598}
599
600fn raw_text(nodes: &[HtmlNodeChild], out: &mut String) {
601 for child in nodes {
602 match child {
603 HtmlNodeChild::Text(text) => out.push_str(text),
604 HtmlNodeChild::Element(el) => raw_text(&el.children, out),
605 }
606 }
607}
608
609fn find_meta_value(nodes: &[HtmlNodeChild], names: &[&str]) -> Option<String> {
610 for child in nodes {
611 let HtmlNodeChild::Element(el) = child else {
612 continue;
613 };
614 if el.tag == "meta"
615 && let Some(value) = attribute(el, "content")
616 && !value.trim().is_empty()
617 && names.iter().any(|name| {
618 attribute(el, "name")
619 .or_else(|| attribute(el, "property"))
620 .is_some_and(|actual| actual.eq_ignore_ascii_case(name))
621 })
622 {
623 return Some(value.trim().to_string());
624 }
625 if let Some(value) = find_meta_value(&el.children, names) {
626 return Some(value);
627 }
628 }
629 None
630}
631
632fn find_canonical_url(nodes: &[HtmlNodeChild]) -> Option<String> {
633 for child in nodes {
634 let HtmlNodeChild::Element(el) = child else {
635 continue;
636 };
637 if el.tag == "link"
638 && attribute(el, "rel").is_some_and(|rel| {
639 rel.split_whitespace()
640 .any(|v| v.eq_ignore_ascii_case("canonical"))
641 })
642 && let Some(href) = attribute(el, "href")
643 && !href.trim().is_empty()
644 {
645 return Some(href.trim().to_string());
646 }
647 if let Some(value) = find_canonical_url(&el.children) {
648 return Some(value);
649 }
650 }
651 None
652}
653
654fn extract_metadata(
655 nodes: &[HtmlNodeChild],
656 article: &[HtmlNodeChild],
657) -> (Option<String>, ArticleMeta) {
658 let title = find_element_by_tag(nodes, "title")
659 .map(|el| node_text(&el.children))
660 .filter(|value| !value.is_empty())
661 .or_else(|| {
662 find_element_by_tag(article, "h1")
663 .map(|el| node_text(&el.children))
664 .filter(|value| !value.is_empty())
665 });
666 let author = find_meta_value(nodes, &["author", "article:author"]);
667 let date = find_meta_value(
668 nodes,
669 &[
670 "date",
671 "article:published_time",
672 "article:modified_time",
673 "pubdate",
674 ],
675 )
676 .or_else(|| {
677 find_element_by_tag(nodes, "time").and_then(|el| {
678 attribute(el, "datetime").map(str::to_string).or_else(|| {
679 let text = node_text(&el.children);
680 (!text.is_empty()).then_some(text)
681 })
682 })
683 });
684 let url = find_meta_value(nodes, &["og:url", "twitter:url", "url"])
685 .or_else(|| find_canonical_url(nodes));
686 (title, ArticleMeta { author, date, url })
687}
688
689fn collect_code_blocks(nodes: &[HtmlNodeChild]) -> Vec<CodeBlock> {
690 let mut blocks = Vec::new();
691 collect_code_blocks_inner(nodes, &mut blocks);
692 blocks
693}
694
695fn collect_code_blocks_inner(nodes: &[HtmlNodeChild], blocks: &mut Vec<CodeBlock>) {
696 for child in nodes {
697 let HtmlNodeChild::Element(el) = child else {
698 continue;
699 };
700 if el.tag == "pre" || el.tag == "code" {
701 let mut content = String::new();
702 raw_text(&el.children, &mut content);
703 blocks.push(CodeBlock {
704 language: if el.tag == "pre" {
705 detect_code_language(el)
706 } else {
707 code_language(el)
708 },
709 content,
710 });
711 if el.tag == "pre" {
712 continue;
713 }
714 }
715 collect_code_blocks_inner(&el.children, blocks);
716 }
717}
718
719fn filter_boilerplate(nodes: &[HtmlNodeChild]) -> Vec<HtmlNodeChild> {
720 nodes
721 .iter()
722 .filter(|child| {
723 if let HtmlNodeChild::Element(el) = child {
724 !is_boilerplate(el)
725 } else {
726 true
727 }
728 })
729 .cloned()
730 .collect()
731}
732
733fn is_boilerplate(el: &HtmlNode) -> bool {
734 if BOILERPLATE_TAGS.contains(&el.tag.as_str()) {
735 return true;
736 }
737 let role = el
738 .attrs
739 .iter()
740 .find(|(k, _)| k == "role")
741 .map_or("", |(_, v)| v.as_str());
742 if BOILERPLATE_ROLES.contains(&role) {
743 return true;
744 }
745 let class = el
746 .attrs
747 .iter()
748 .find(|(k, _)| k == "class")
749 .map_or("", |(_, v)| v.as_str());
750 BOILERPLATE_CLASSES
751 .iter()
752 .any(|bc| class.split_whitespace().any(|cls| cls.contains(bc)))
753}
754
755fn nodes_to_markdown(nodes: &[HtmlNodeChild]) -> String {
758 let mut output = String::new();
759 render_nodes(nodes, &mut output, &mut RenderState::default());
760 collapse_whitespace(&output)
761}
762
763#[derive(Default)]
764struct RenderState {
765 list_depth: usize,
766 ordered_counter: Vec<usize>,
767 in_pre: bool,
768}
769
770fn render_nodes(nodes: &[HtmlNodeChild], out: &mut String, state: &mut RenderState) {
771 for child in nodes {
772 match child {
773 HtmlNodeChild::Text(text) => {
774 if state.in_pre {
775 out.push_str(text);
776 } else {
777 let normalized = normalize_whitespace(text);
778 if !normalized.is_empty() {
779 out.push_str(&normalized);
780 }
781 }
782 }
783 HtmlNodeChild::Element(el) => render_element(el, out, state),
784 }
785 }
786}
787
788fn collapse_tracking_parameters(href: &str) -> String {
789 let Some((base, query_and_fragment)) = href.split_once('?') else {
790 return href.to_string();
791 };
792 let (query, fragment) = query_and_fragment
793 .split_once('#')
794 .map_or((query_and_fragment, ""), |parts| parts);
795 let kept: Vec<&str> = query
796 .split('&')
797 .filter(|part| {
798 let key = part.split_once('=').map_or(*part, |(key, _)| key);
799 let lower = key.to_ascii_lowercase();
800 !lower.starts_with("utm_") && !TRACKING_QUERY_KEYS.contains(&lower.as_str())
801 })
802 .collect();
803 let mut result = base.to_string();
804 if !kept.is_empty() {
805 result.push('?');
806 result.push_str(&kept.join("&"));
807 }
808 if !fragment.is_empty() {
809 result.push('#');
810 result.push_str(fragment);
811 }
812 result
813}
814
815fn render_element(el: &HtmlNode, out: &mut String, state: &mut RenderState) {
816 match el.tag.as_str() {
817 "h1" => {
818 ensure_newlines(out, 2);
819 out.push_str("# ");
820 render_nodes(&el.children, out, state);
821 ensure_newlines(out, 2);
822 }
823 "h2" => {
824 ensure_newlines(out, 2);
825 out.push_str("## ");
826 render_nodes(&el.children, out, state);
827 ensure_newlines(out, 2);
828 }
829 "h3" => {
830 ensure_newlines(out, 2);
831 out.push_str("### ");
832 render_nodes(&el.children, out, state);
833 ensure_newlines(out, 2);
834 }
835 "h4" => {
836 ensure_newlines(out, 2);
837 out.push_str("#### ");
838 render_nodes(&el.children, out, state);
839 ensure_newlines(out, 2);
840 }
841 "h5" => {
842 ensure_newlines(out, 2);
843 out.push_str("##### ");
844 render_nodes(&el.children, out, state);
845 ensure_newlines(out, 2);
846 }
847 "h6" => {
848 ensure_newlines(out, 2);
849 out.push_str("###### ");
850 render_nodes(&el.children, out, state);
851 ensure_newlines(out, 2);
852 }
853 "p" | "div" | "section" | "article" => {
854 ensure_newlines(out, 2);
855 render_nodes(&el.children, out, state);
856 ensure_newlines(out, 2);
857 }
858 "br" => {
859 out.push('\n');
860 }
861 "hr" => {
862 ensure_newlines(out, 2);
863 out.push_str("---");
864 ensure_newlines(out, 2);
865 }
866 "strong" | "b" => {
867 out.push_str("**");
868 render_nodes(&el.children, out, state);
869 out.push_str("**");
870 }
871 "em" | "i" => {
872 out.push('*');
873 render_nodes(&el.children, out, state);
874 out.push('*');
875 }
876 "code" if !state.in_pre => {
877 out.push('`');
878 render_nodes(&el.children, out, state);
879 out.push('`');
880 }
881 "pre" => {
882 ensure_newlines(out, 2);
883 let lang = detect_code_language(el);
884 out.push_str("```");
885 out.push_str(&lang);
886 out.push('\n');
887 state.in_pre = true;
888 render_nodes(&el.children, out, state);
889 state.in_pre = false;
890 if !out.ends_with('\n') {
891 out.push('\n');
892 }
893 out.push_str("```");
894 ensure_newlines(out, 2);
895 }
896 "a" => {
897 let href = el
898 .attrs
899 .iter()
900 .find(|(k, _)| k == "href")
901 .map_or("", |(_, v)| v.as_str());
902 let mut link_text = String::new();
903 render_nodes(&el.children, &mut link_text, state);
904 let link_text = link_text.trim().to_string();
905 if !link_text.is_empty()
906 && !href.is_empty()
907 && !href.starts_with('#')
908 && !href.starts_with("javascript:")
909 {
910 out.push('[');
911 out.push_str(&link_text);
912 out.push_str("](");
913 out.push_str(&collapse_tracking_parameters(href));
914 out.push(')');
915 } else if !link_text.is_empty() {
916 out.push_str(&link_text);
917 }
918 }
919 "ul" => {
920 ensure_newlines(out, 2);
921 state.list_depth += 1;
922 render_nodes(&el.children, out, state);
923 state.list_depth -= 1;
924 ensure_newlines(out, 2);
925 }
926 "ol" => {
927 ensure_newlines(out, 2);
928 state.list_depth += 1;
929 state.ordered_counter.push(0);
930 render_nodes(&el.children, out, state);
931 state.ordered_counter.pop();
932 state.list_depth -= 1;
933 ensure_newlines(out, 2);
934 }
935 "li" => {
936 ensure_newlines(out, 1);
937 let indent = " ".repeat(state.list_depth.saturating_sub(1));
938 out.push_str(&indent);
939 if let Some(counter) = state.ordered_counter.last_mut() {
940 *counter += 1;
941 out.push_str(&format!("{counter}. "));
942 } else {
943 out.push_str("- ");
944 }
945 render_nodes(&el.children, out, state);
946 }
947 "blockquote" => {
948 ensure_newlines(out, 2);
949 let mut inner = String::new();
950 render_nodes(&el.children, &mut inner, state);
951 for line in inner.trim().lines() {
952 out.push_str("> ");
953 out.push_str(line);
954 out.push('\n');
955 }
956 ensure_newlines(out, 2);
957 }
958 "table" => {
959 ensure_newlines(out, 2);
960 render_table(el, out, state);
961 ensure_newlines(out, 2);
962 }
963 "img" => {
964 let alt = el
965 .attrs
966 .iter()
967 .find(|(k, _)| k == "alt")
968 .map_or("", |(_, v)| v.as_str());
969 let src = el
970 .attrs
971 .iter()
972 .find(|(k, _)| k == "src")
973 .map_or("", |(_, v)| v.as_str());
974 if !alt.is_empty() && !src.is_empty() {
975 out.push_str(&format!(""));
976 }
977 }
978 _ => {
979 render_nodes(&el.children, out, state);
980 }
981 }
982}
983
984fn render_table(el: &HtmlNode, out: &mut String, state: &mut RenderState) {
985 let rows = collect_table_rows(el);
986 if rows.is_empty() {
987 return;
988 }
989
990 let col_count = rows.iter().map(Vec::len).max().unwrap_or(0);
991 if col_count == 0 {
992 return;
993 }
994
995 for (i, row) in rows.iter().enumerate() {
996 out.push('|');
997 for col in 0..col_count {
998 let cell = row.get(col).map_or("", String::as_str);
999 out.push(' ');
1000 out.push_str(cell.trim());
1001 out.push_str(" |");
1002 }
1003 out.push('\n');
1004 if i == 0 {
1005 out.push('|');
1006 for _ in 0..col_count {
1007 out.push_str(" --- |");
1008 }
1009 out.push('\n');
1010 }
1011 }
1012 let _ = state;
1013}
1014
1015fn collect_table_rows(el: &HtmlNode) -> Vec<Vec<String>> {
1016 let mut rows = Vec::new();
1017 collect_rows_recursive(el, &mut rows);
1018 rows
1019}
1020
1021fn collect_rows_recursive(el: &HtmlNode, rows: &mut Vec<Vec<String>>) {
1022 if el.tag == "tr" {
1023 let cells: Vec<String> = el
1024 .children
1025 .iter()
1026 .filter_map(|child| {
1027 if let HtmlNodeChild::Element(cell) = child {
1028 if cell.tag == "td" || cell.tag == "th" {
1029 let mut text = String::new();
1030 render_nodes(&cell.children, &mut text, &mut RenderState::default());
1031 return Some(text.trim().to_string());
1032 }
1033 }
1034 None
1035 })
1036 .collect();
1037 if !cells.is_empty() {
1038 rows.push(cells);
1039 }
1040 }
1041 for child in &el.children {
1042 if let HtmlNodeChild::Element(child_el) = child {
1043 collect_rows_recursive(child_el, rows);
1044 }
1045 }
1046}
1047
1048fn code_language(el: &HtmlNode) -> String {
1049 let class = attribute(el, "class").unwrap_or("");
1050 for cls in class.split_whitespace() {
1051 if let Some(lang) = cls.strip_prefix("language-") {
1052 return lang.to_string();
1053 }
1054 if let Some(lang) = cls.strip_prefix("lang-") {
1055 return lang.to_string();
1056 }
1057 if matches!(
1058 cls,
1059 "rust"
1060 | "python"
1061 | "javascript"
1062 | "typescript"
1063 | "go"
1064 | "java"
1065 | "c"
1066 | "cpp"
1067 | "ruby"
1068 | "bash"
1069 | "sh"
1070 | "json"
1071 | "yaml"
1072 | "toml"
1073 | "sql"
1074 | "html"
1075 | "css"
1076 ) {
1077 return cls.to_string();
1078 }
1079 }
1080 String::new()
1081}
1082
1083fn detect_code_language(pre: &HtmlNode) -> String {
1084 for child in &pre.children {
1085 if let HtmlNodeChild::Element(code) = child {
1086 if code.tag == "code" {
1087 let language = code_language(code);
1088 if !language.is_empty() {
1089 return language;
1090 }
1091 }
1092 }
1093 }
1094 code_language(pre)
1095}
1096
1097fn normalize_whitespace(text: &str) -> String {
1098 let mut result = String::with_capacity(text.len());
1099 let mut last_was_space = false;
1100 for ch in text.chars() {
1101 if ch.is_ascii_whitespace() {
1102 if !last_was_space {
1103 result.push(' ');
1104 last_was_space = true;
1105 }
1106 } else {
1107 result.push(ch);
1108 last_was_space = false;
1109 }
1110 }
1111 result
1112}
1113
1114fn ensure_newlines(out: &mut String, count: usize) {
1115 let trailing_newlines = out.chars().rev().take_while(|&c| c == '\n').count();
1116 for _ in trailing_newlines..count {
1117 out.push('\n');
1118 }
1119}
1120
1121fn collapse_whitespace(text: &str) -> String {
1122 let mut result = String::with_capacity(text.len());
1123 let mut consecutive_newlines = 0u32;
1124
1125 for ch in text.chars() {
1126 if ch == '\n' {
1127 consecutive_newlines += 1;
1128 if consecutive_newlines <= 2 {
1129 result.push('\n');
1130 }
1131 } else {
1132 consecutive_newlines = 0;
1133 result.push(ch);
1134 }
1135 }
1136 result.trim().to_string()
1137}
1138
1139#[cfg(test)]
1140mod tests {
1141 use super::*;
1142
1143 #[test]
1144 fn detects_html_content() {
1145 assert!(is_html_content(
1146 "<!DOCTYPE html><html><body>hi</body></html>"
1147 ));
1148 assert!(is_html_content(
1149 " <html lang='en'><head></head><body></body></html>"
1150 ));
1151 assert!(!is_html_content("just plain text"));
1152 assert!(!is_html_content("{\"json\": true}"));
1153 }
1154
1155 #[test]
1156 fn extracts_article_element() {
1157 let html = r"
1158 <html><body>
1159 <nav>Navigation stuff</nav>
1160 <article>
1161 <h1>Title</h1>
1162 <p>Important content here.</p>
1163 </article>
1164 <footer>Footer junk</footer>
1165 </body></html>
1166 ";
1167 let result = extract_article(html);
1168 assert!(result.contains("# Title"));
1169 assert!(result.contains("Important content here."));
1170 assert!(!result.contains("Navigation stuff"));
1171 assert!(!result.contains("Footer junk"));
1172 }
1173
1174 #[test]
1175 fn extracts_main_element() {
1176 let html = r"
1177 <html><body>
1178 <header>Header</header>
1179 <main>
1180 <h2>Main Content</h2>
1181 <p>The real stuff.</p>
1182 </main>
1183 <aside>Sidebar</aside>
1184 </body></html>
1185 ";
1186 let result = extract_article(html);
1187 assert!(result.contains("## Main Content"));
1188 assert!(result.contains("The real stuff."));
1189 assert!(!result.contains("Header"));
1190 assert!(!result.contains("Sidebar"));
1191 }
1192
1193 #[test]
1194 fn preserves_code_blocks() {
1195 let html = r#"
1196 <html><body><article>
1197 <p>Example:</p>
1198 <pre><code class="language-rust">fn main() {
1199 println!("hello");
1200}</code></pre>
1201 </article></body></html>
1202 "#;
1203 let result = extract_article(html);
1204 assert!(result.contains("```rust"));
1205 assert!(result.contains("fn main()"));
1206 assert!(result.contains("```"));
1207 }
1208
1209 #[test]
1210 fn converts_links() {
1211 let html = r#"<html><body><article><p>See <a href="https://example.com">docs</a></p></article></body></html>"#;
1212 let result = extract_article(html);
1213 assert!(result.contains("[docs](https://example.com)"));
1214 }
1215
1216 #[test]
1217 fn converts_tables() {
1218 let html = r"
1219 <html><body><article>
1220 <table>
1221 <tr><th>Name</th><th>Value</th></tr>
1222 <tr><td>foo</td><td>42</td></tr>
1223 </table>
1224 </article></body></html>
1225 ";
1226 let result = extract_article(html);
1227 assert!(result.contains("| Name | Value |"));
1228 assert!(result.contains("| foo | 42 |"));
1229 }
1230
1231 #[test]
1232 fn crush_rejects_small_input() {
1233 let small = "<html><body><p>hi</p></body></html>";
1234 assert!(crush_if_beneficial(small).is_none());
1235 }
1236
1237 #[test]
1238 fn crush_is_deterministic() {
1239 let html = format!(
1240 "<html><body><nav>{}</nav><article><h1>Title</h1><p>{}</p></article><footer>{}</footer></body></html>",
1241 "x".repeat(3000),
1242 "content ".repeat(200),
1243 "y".repeat(3000)
1244 );
1245 let r1 = crush_if_beneficial(&html);
1246 let r2 = crush_if_beneficial(&html);
1247 assert_eq!(r1.as_ref().map(|r| &r.text), r2.as_ref().map(|r| &r.text));
1248 }
1249}
1250
1251#[cfg(test)]
1252mod edge_tests {
1253 use super::*;
1254
1255 #[test]
1256 fn handles_malformed_html_gracefully() {
1257 let broken = "<html><body><div><p>Unclosed paragraph<div>Nested wrong</p></div>";
1258 let result = extract_article(broken);
1259 assert!(result.contains("Unclosed paragraph") || result.contains("Nested wrong"));
1260 }
1261
1262 #[test]
1263 fn handles_empty_html() {
1264 let empty = "<html><body></body></html>";
1265 assert!(crush_if_beneficial(empty).is_none());
1266 }
1267
1268 #[test]
1269 fn handles_unicode_content() {
1270 let html = "<html><body><article><h1>\u{65E5}\u{672C}\u{8A9E}</h1><p>\u{00DC}nic\u{00F6}d\u{00E9} with emojis \u{1F680}</p></article></body></html>";
1271 let result = extract_article(html);
1272 assert!(result.contains("\u{65E5}\u{672C}\u{8A9E}"));
1273 assert!(result.contains("\u{1F680}"));
1274 }
1275
1276 #[test]
1277 fn handles_deeply_nested_structures() {
1278 let mut html = String::from("<html><body><article>");
1279 for i in 0..50 {
1280 html.push_str(&format!("<div><p>Level {i}</p>"));
1281 }
1282 for _ in 0..50 {
1283 html.push_str("</div>");
1284 }
1285 html.push_str("</article></body></html>");
1286 let result = extract_article(&html);
1287 assert!(result.contains("Level 0"));
1288 assert!(result.contains("Level 49"));
1289 }
1290
1291 #[test]
1292 fn handles_script_and_style_exclusion() {
1293 let html = "<html><body><article><script>var x = 'not content';</script><style>.foo{}</style><p>Real content.</p></article></body></html>";
1294 let result = extract_article(html);
1295 assert!(result.contains("Real content."));
1296 assert!(!result.contains("not content"));
1297 assert!(!result.contains(".foo"));
1298 }
1299
1300 #[test]
1301 fn handles_entities_correctly() {
1302 let html =
1303 "<html><body><article><p>5 > 3 && 2 < 4</p></article></body></html>";
1304 let result = extract_article(html);
1305 assert!(result.contains("5 > 3 && 2 < 4"));
1306 }
1307
1308 #[test]
1309 fn handles_empty_article() {
1310 let html = "<html><body><article> \n\t </article></body></html>";
1311 let result = extract_article(html);
1312 assert!(result.trim().is_empty());
1313 }
1314}