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