arch_toolkit/news/article.rs
1//! Safe, bounded HTML-to-text extraction for news article pages.
2
3use crate::error::{ArchToolkitError, Result};
4
5/// Maximum HTML response size accepted for one article extraction.
6pub const MAX_ARTICLE_HTML_BYTES: usize = 512 * 1024;
7/// Maximum text size emitted from one article extraction.
8pub const MAX_ARTICLE_TEXT_BYTES: usize = 256 * 1024;
9/// Maximum raw anchor destination length accepted before URL resolution.
10const MAX_LINK_BYTES: usize = 4 * 1024;
11
12/// What: Extract readable text from a bounded HTML news article.
13///
14/// Inputs:
15/// - `html`: Article HTML, limited to [`MAX_ARTICLE_HTML_BYTES`] bytes.
16/// - `base_url`: Absolute HTTP(S) article URL used to resolve relative links.
17///
18/// Output:
19/// - Plain text with paragraphs, list items, code blocks, and Markdown-safe
20/// HTTP(S) links, or an explicit validation/size error.
21///
22/// Details:
23/// - Script, style, template, and noscript content is discarded rather than
24/// interpreted. HTML is never executed.
25/// - Relative links are resolved against `base_url`; non-HTTP(S) links remain
26/// readable text without a destination.
27/// - This intentionally small extractor is not a general browser or HTML
28/// sanitizer. It preserves the article structures needed by news callers
29/// while enforcing input and output bounds.
30///
31/// # Errors
32///
33/// Returns `ArchToolkitError::InputTooLong` when the input or output exceeds
34/// its bound, and `ArchToolkitError::InvalidInput` for an invalid base URL.
35pub fn extract_article_text(html: &str, base_url: &str) -> Result<String> {
36 ensure_article_input_bound(html)?;
37 let base = parse_article_base_url(base_url)?;
38 let mut extractor = ArticleTextExtractor::new();
39 scan_article_html(html, &base, &mut extractor)?;
40 extractor.finish()
41}
42
43/// What: Fetch one article through a caller-provided client and extract its text.
44///
45/// Inputs:
46/// - `client`: Caller-configured reqwest client controlling transport policy.
47/// - `article_url`: Absolute HTTP(S) article URL to fetch and use as link base.
48///
49/// Output:
50/// - Extracted bounded article text or a transport, status, validation, or
51/// extraction error.
52///
53/// Details:
54/// - The caller owns timeouts, redirects, proxy policy, and fetch cadence.
55/// - The response body is read incrementally and rejected above
56/// [`MAX_ARTICLE_HTML_BYTES`].
57///
58/// # Errors
59///
60/// Returns an error for invalid URLs, failed requests, non-success responses,
61/// oversized bodies, invalid UTF-8, or extraction failures.
62pub async fn fetch_article_text(client: &reqwest::Client, article_url: &str) -> Result<String> {
63 let html =
64 fetch_bounded_text(client, article_url, MAX_ARTICLE_HTML_BYTES, "news article").await?;
65 extract_article_text(&html, article_url)
66}
67
68/// What: Read a successful HTTP response into a string without exceeding a byte bound.
69///
70/// Inputs:
71/// - `client`: Caller-configured reqwest client.
72/// - `url`: HTTP(S) URL to request.
73/// - `maximum_bytes`: Inclusive response-size bound.
74/// - `resource_name`: Human-readable resource label for errors.
75///
76/// Output:
77/// - UTF-8 response text no larger than `maximum_bytes`.
78///
79/// Details:
80/// - Checks both Content-Length when provided and streamed chunks, so omitted
81/// or misleading headers cannot bypass the bound.
82/// - Shared by feed and article fetchers without depending on AUR internals.
83///
84/// # Errors
85///
86/// Returns an error for zero bounds, request failures, non-success statuses,
87/// oversized responses, or invalid UTF-8.
88pub(super) async fn fetch_bounded_text(
89 client: &reqwest::Client,
90 url: &str,
91 maximum_bytes: usize,
92 resource_name: &str,
93) -> Result<String> {
94 if maximum_bytes == 0 {
95 return Err(ArchToolkitError::InvalidInput(format!(
96 "{resource_name} response bound must be greater than zero"
97 )));
98 }
99 let parsed_url = parse_http_url(url, resource_name)?;
100 let mut response = client.get(parsed_url).send().await.map_err(|error| {
101 ArchToolkitError::Parse(format!("{resource_name} request failed: {error}"))
102 })?;
103 let status = response.status();
104 if !status.is_success() {
105 return Err(ArchToolkitError::Parse(format!(
106 "{resource_name} returned status {status}"
107 )));
108 }
109
110 let maximum_length = u64::try_from(maximum_bytes).map_err(|_| {
111 ArchToolkitError::InvalidInput(format!("{resource_name} response bound is too large"))
112 })?;
113 if response
114 .content_length()
115 .is_some_and(|length| length > maximum_length)
116 {
117 return Err(response_too_large_error(resource_name, maximum_bytes));
118 }
119
120 let mut bytes = Vec::new();
121 while let Some(chunk) = response.chunk().await.map_err(|error| {
122 ArchToolkitError::Parse(format!("{resource_name} response read failed: {error}"))
123 })? {
124 if chunk.len() > maximum_bytes.saturating_sub(bytes.len()) {
125 return Err(response_too_large_error(resource_name, maximum_bytes));
126 }
127 bytes.extend_from_slice(&chunk);
128 }
129 String::from_utf8(bytes).map_err(|error| {
130 ArchToolkitError::Parse(format!(
131 "{resource_name} response was not valid UTF-8: {error}"
132 ))
133 })
134}
135
136/// What: Validate an absolute article base URL.
137///
138/// Inputs:
139/// - `base_url`: Candidate absolute URL supplied by the caller.
140///
141/// Output:
142/// - Parsed HTTP(S) URL suitable for relative-link resolution.
143///
144/// Details:
145/// - Rejects non-HTTP(S) schemes before any extraction output is generated.
146fn parse_article_base_url(base_url: &str) -> Result<reqwest::Url> {
147 parse_http_url(base_url, "article base URL")
148}
149
150/// What: Validate an HTTP(S) URL for a bounded caller-owned request.
151///
152/// Inputs:
153/// - `url`: Candidate URL to parse.
154/// - `resource_name`: Resource label used in errors.
155///
156/// Output:
157/// - Parsed URL restricted to `http` or `https`.
158///
159/// Details:
160/// - Does not make a request; caller-client transport policy remains in the
161/// supplied reqwest client.
162fn parse_http_url(url: &str, resource_name: &str) -> Result<reqwest::Url> {
163 let parsed = reqwest::Url::parse(url).map_err(|error| {
164 ArchToolkitError::InvalidInput(format!("invalid {resource_name} URL: {error}"))
165 })?;
166 if matches!(parsed.scheme(), "http" | "https") {
167 return Ok(parsed);
168 }
169 Err(ArchToolkitError::InvalidInput(format!(
170 "{resource_name} URL must use http or https"
171 )))
172}
173
174/// What: Build a consistent explicit error for response-bound violations.
175///
176/// Inputs:
177/// - `resource_name`: Resource label shown to callers.
178/// - `maximum_bytes`: Configured maximum body size.
179///
180/// Output:
181/// - `ArchToolkitError::InputTooLong` with the known safety bound.
182///
183/// Details:
184/// - The actual streamed size is intentionally not reported because it may be
185/// incomplete when the body is rejected mid-stream.
186fn response_too_large_error(resource_name: &str, maximum_bytes: usize) -> ArchToolkitError {
187 ArchToolkitError::InputTooLong {
188 field: format!("{resource_name} response"),
189 max_length: maximum_bytes,
190 actual_length: maximum_bytes.saturating_add(1),
191 }
192}
193
194/// What: Reject article HTML larger than the extractor's fixed bound.
195///
196/// Inputs:
197/// - `html`: Candidate article HTML.
198///
199/// Output:
200/// - `Ok(())` within the bound, otherwise `InputTooLong`.
201///
202/// Details:
203/// - This check occurs before parsing to bound scanner work and allocations.
204fn ensure_article_input_bound(html: &str) -> Result<()> {
205 if html.len() <= MAX_ARTICLE_HTML_BYTES {
206 return Ok(());
207 }
208 Err(ArchToolkitError::InputTooLong {
209 field: "article HTML".to_string(),
210 max_length: MAX_ARTICLE_HTML_BYTES,
211 actual_length: html.len(),
212 })
213}
214
215/// What: Scan article HTML and dispatch text/tag tokens to an extractor.
216///
217/// Inputs:
218/// - `html`: Previously bounded article markup.
219/// - `base_url`: Valid HTTP(S) URL for relative links.
220/// - `extractor`: Mutable extraction state.
221///
222/// Output:
223/// - `Ok(())` after all complete tokens are processed.
224///
225/// Details:
226/// - Unterminated tags are treated as literal text, which avoids silently
227/// dropping visible content from malformed pages.
228fn scan_article_html(
229 html: &str,
230 base_url: &reqwest::Url,
231 extractor: &mut ArticleTextExtractor,
232) -> Result<()> {
233 let mut remaining = html;
234 while let Some(start) = remaining.find('<') {
235 extractor.append_text(&remaining[..start])?;
236 remaining = &remaining[start..];
237 if let Some(after_comment) = skip_html_comment(remaining) {
238 remaining = after_comment;
239 continue;
240 }
241 let Some(end) = find_tag_end(remaining) else {
242 extractor.append_text(remaining)?;
243 return Ok(());
244 };
245 extractor.handle_tag(&remaining[1..end], base_url)?;
246 remaining = &remaining[end + 1..];
247 }
248 extractor.append_text(remaining)
249}
250
251/// What: Skip one complete HTML comment when the input begins with one.
252///
253/// Inputs:
254/// - `input`: Remaining HTML beginning at a possible `<` token.
255///
256/// Output:
257/// - Remaining input after a complete comment, or `None` for non-comments and
258/// unterminated comments.
259///
260/// Details:
261/// - Comment text is never emitted into article output.
262fn skip_html_comment(input: &str) -> Option<&str> {
263 let suffix = input.strip_prefix("<!--")?;
264 let end = suffix.find("-->")?;
265 Some(&suffix[end + 3..])
266}
267
268/// What: Locate the closing `>` of one HTML tag while respecting quotes.
269///
270/// Inputs:
271/// - `input`: Remaining HTML that starts with `<`.
272///
273/// Output:
274/// - Byte index of the closing `>`, or `None` for an unterminated tag.
275///
276/// Details:
277/// - Quoted attribute values may contain `>` and must not terminate a tag.
278fn find_tag_end(input: &str) -> Option<usize> {
279 let mut quote = None;
280 for (index, character) in input.char_indices().skip(1) {
281 match (quote, character) {
282 (None, '\'' | '"') => quote = Some(character),
283 (Some(active), current) if active == current => quote = None,
284 (None, '>') => return Some(index),
285 _ => {}
286 }
287 }
288 None
289}
290
291/// What: Hold one open link's output position and resolved destination.
292///
293/// Inputs:
294/// - Created when an opening anchor tag is encountered.
295///
296/// Output:
297/// - Allows the closing tag to append a safe destination or discard an empty link.
298///
299/// Details:
300/// - Invalid or non-HTTP(S) destinations use `None` and preserve label text.
301struct OpenLink {
302 /// Position of the opening `[` in the output buffer.
303 output_start: usize,
304 /// Resolved and Markdown-escaped HTTP(S) destination when valid.
305 destination: Option<String>,
306}
307
308/// What: Maintain bounded structural state while scanning article HTML.
309///
310/// Inputs:
311/// - Constructed internally by [`extract_article_text`].
312///
313/// Output:
314/// - A plain-text buffer with preserved supported article structures.
315///
316/// Details:
317/// - Suppressed-tag, preformatted, inline-code, and link stacks are independent
318/// so malformed nested tags cannot execute or alter unrelated parser state.
319struct ArticleTextExtractor {
320 /// Accumulated extracted text.
321 output: String,
322 /// Nested tags whose text must be discarded.
323 suppressed_tags: Vec<String>,
324 /// Nesting depth for preformatted code blocks.
325 pre_depth: usize,
326 /// Nesting depth for inline code tags outside preformatted blocks.
327 inline_code_depth: usize,
328 /// Open anchors awaiting their closing tag.
329 open_links: Vec<OpenLink>,
330}
331
332impl ArticleTextExtractor {
333 /// What: Create empty extraction state.
334 ///
335 /// Inputs: None.
336 ///
337 /// Output:
338 /// - Fresh parser state with no visible text or open structures.
339 ///
340 /// Details:
341 /// - All state remains local to one extraction call.
342 const fn new() -> Self {
343 Self {
344 output: String::new(),
345 suppressed_tags: Vec::new(),
346 pre_depth: 0,
347 inline_code_depth: 0,
348 open_links: Vec::new(),
349 }
350 }
351
352 /// What: Append one visible text token using context-appropriate whitespace.
353 ///
354 /// Inputs:
355 /// - `text`: Raw HTML text token outside tag delimiters.
356 ///
357 /// Output:
358 /// - Updates the output buffer or reports a text-size violation.
359 ///
360 /// Details:
361 /// - Preformatted sections preserve whitespace; ordinary text collapses it.
362 /// - Text inside suppressed elements is ignored.
363 fn append_text(&mut self, text: &str) -> Result<()> {
364 if !self.suppressed_tags.is_empty() || text.is_empty() {
365 return Ok(());
366 }
367 let decoded = decode_html_entities(text);
368 if self.pre_depth > 0 {
369 self.push_visible(&decoded);
370 return self.ensure_output_bound();
371 }
372 for word in decoded.split_whitespace() {
373 if self.needs_word_separator(word) {
374 self.push_visible(" ");
375 }
376 self.push_visible(word);
377 }
378 self.ensure_output_bound()
379 }
380
381 /// What: Handle one HTML tag and update supported structural state.
382 ///
383 /// Inputs:
384 /// - `raw_tag`: Tag content without surrounding `<` and `>`.
385 /// - `base_url`: Valid URL used to resolve anchor destinations.
386 ///
387 /// Output:
388 /// - Updates extraction state or reports a text-size violation.
389 ///
390 /// Details:
391 /// - Unknown tags are ignored while their text remains visible.
392 /// - Script-like elements are suppressed before their content is observed.
393 fn handle_tag(&mut self, raw_tag: &str, base_url: &reqwest::Url) -> Result<()> {
394 let Some((tag_name, attributes, closing, self_closing)) = parse_tag(raw_tag) else {
395 return Ok(());
396 };
397 if self.handle_suppressed_tag(&tag_name, closing, self_closing) {
398 return Ok(());
399 }
400 if closing {
401 self.close_tag(&tag_name)?;
402 } else {
403 self.open_tag(&tag_name, attributes, base_url)?;
404 if self_closing {
405 self.close_tag(&tag_name)?;
406 }
407 }
408 self.ensure_output_bound()
409 }
410
411 /// What: Suppress script-like tag content and consume matching close tags.
412 ///
413 /// Inputs:
414 /// - `tag_name`: Normalized HTML tag name.
415 /// - `closing`: Whether this is a closing tag.
416 /// - `self_closing`: Whether this is a self-closing tag.
417 ///
418 /// Output:
419 /// - `true` when the caller should not process the tag further.
420 ///
421 /// Details:
422 /// - Nested suppressed tags are tracked by name, avoiding accidental exit
423 /// when malformed markup contains unrelated closing tags.
424 fn handle_suppressed_tag(&mut self, tag_name: &str, closing: bool, self_closing: bool) -> bool {
425 if let Some(open_tag) = self.suppressed_tags.last() {
426 if closing && open_tag == tag_name {
427 let _ = self.suppressed_tags.pop();
428 }
429 return true;
430 }
431 if !closing && !self_closing && is_suppressed_tag(tag_name) {
432 self.suppressed_tags.push(tag_name.to_string());
433 return true;
434 }
435 false
436 }
437
438 /// What: Process an opening supported HTML tag.
439 ///
440 /// Inputs:
441 /// - `tag_name`: Normalized HTML tag name.
442 /// - `attributes`: Raw tag attributes.
443 /// - `base_url`: URL used to resolve an optional anchor destination.
444 ///
445 /// Output:
446 /// - Updates visible structural output and parser stacks.
447 ///
448 /// Details:
449 /// - Paragraph-like tags delimit blocks, list items gain a `- ` marker,
450 /// and code tags produce Markdown-safe code delimiters.
451 fn open_tag(
452 &mut self,
453 tag_name: &str,
454 attributes: &str,
455 base_url: &reqwest::Url,
456 ) -> Result<()> {
457 match tag_name {
458 "br" => self.ensure_line_breaks(1),
459 "li" => {
460 self.ensure_line_breaks(1);
461 self.push_visible("- ");
462 }
463 "pre" => {
464 self.ensure_line_breaks(2);
465 self.push_visible("```\n");
466 self.pre_depth += 1;
467 }
468 "code" if self.pre_depth == 0 => {
469 if self.needs_word_separator("code") {
470 self.push_visible(" ");
471 }
472 self.push_visible("`");
473 self.inline_code_depth += 1;
474 }
475 "a" => self.open_link(attributes, base_url),
476 _ if is_block_tag(tag_name) => self.ensure_line_breaks(2),
477 _ => {}
478 }
479 self.ensure_output_bound()
480 }
481
482 /// What: Process a closing supported HTML tag.
483 ///
484 /// Inputs:
485 /// - `tag_name`: Normalized HTML tag name.
486 ///
487 /// Output:
488 /// - Updates visible structural output and parser stacks.
489 ///
490 /// Details:
491 /// - Unmatched close tags are harmless, making extraction resilient to
492 /// partially malformed article markup.
493 fn close_tag(&mut self, tag_name: &str) -> Result<()> {
494 match tag_name {
495 "li" => self.ensure_line_breaks(1),
496 "pre" if self.pre_depth > 0 => {
497 self.pre_depth -= 1;
498 if self.pre_depth == 0 {
499 self.ensure_line_breaks(1);
500 self.push_visible("```\n\n");
501 }
502 }
503 "code" if self.pre_depth == 0 && self.inline_code_depth > 0 => {
504 self.inline_code_depth -= 1;
505 self.push_visible("`");
506 }
507 "a" => self.close_link(),
508 _ if is_block_tag(tag_name) => self.ensure_line_breaks(2),
509 _ => {}
510 }
511 self.ensure_output_bound()
512 }
513
514 /// What: Open an anchor while preserving its visible label text.
515 ///
516 /// Inputs:
517 /// - `attributes`: Raw anchor attributes.
518 /// - `base_url`: Valid URL used to resolve a relative `href`.
519 ///
520 /// Output:
521 /// - Pushes an open-link state for a later closing anchor tag.
522 ///
523 /// Details:
524 /// - Only HTTP(S) destinations are retained, and invalid destinations do
525 /// not cause label text to be dropped.
526 fn open_link(&mut self, attributes: &str, base_url: &reqwest::Url) {
527 let destination =
528 attribute_value(attributes, "href").and_then(|href| resolve_http_link(base_url, &href));
529 if destination.is_some() && self.needs_word_separator("link") {
530 self.push_visible(" ");
531 }
532 let output_start = self.output.len();
533 if destination.is_some() {
534 self.push_visible("[");
535 }
536 self.open_links.push(OpenLink {
537 output_start,
538 destination,
539 });
540 }
541
542 /// What: Close the most recent anchor and append a safe destination.
543 ///
544 /// Inputs: None.
545 ///
546 /// Output:
547 /// - A Markdown-safe `](url)` suffix, or unchanged visible label text.
548 ///
549 /// Details:
550 /// - Empty links remove their opening bracket rather than emitting `[]()`.
551 fn close_link(&mut self) {
552 let Some(open_link) = self.open_links.pop() else {
553 return;
554 };
555 let Some(destination) = open_link.destination else {
556 return;
557 };
558 if self.output.len() == open_link.output_start + 1 {
559 self.output.truncate(open_link.output_start);
560 return;
561 }
562 self.output.push_str("](");
563 self.output.push_str(&destination);
564 self.output.push(')');
565 }
566
567 /// What: Append one visible text fragment, escaping anchor labels when needed.
568 ///
569 /// Inputs:
570 /// - `text`: Already normalized visible text fragment.
571 ///
572 /// Output:
573 /// - Appends directly to the extractor output buffer.
574 ///
575 /// Details:
576 /// - Escaping `[`/`]`/`\\` inside live anchor labels prevents HTML text from
577 /// breaking the generated Markdown link structure.
578 fn push_visible(&mut self, text: &str) {
579 if self
580 .open_links
581 .last()
582 .is_some_and(|link| link.destination.is_some())
583 {
584 for character in text.chars() {
585 if matches!(character, '[' | ']' | '\\') {
586 self.output.push('\\');
587 }
588 self.output.push(character);
589 }
590 } else {
591 self.output.push_str(text);
592 }
593 }
594
595 /// What: Decide whether a normal text word needs a preceding space.
596 ///
597 /// Inputs:
598 /// - `word`: Collapsed non-whitespace text token to append.
599 ///
600 /// Output:
601 /// - `true` when a single separator should be emitted first.
602 ///
603 /// Details:
604 /// - Punctuation following a link or word does not gain a spurious space.
605 fn needs_word_separator(&self, word: &str) -> bool {
606 let Some(previous) = self.output.chars().last() else {
607 return false;
608 };
609 !previous.is_whitespace()
610 && !matches!(previous, '[' | '`')
611 && !word_starts_with_punctuation(word)
612 }
613
614 /// What: Ensure a requested number of trailing line breaks.
615 ///
616 /// Inputs:
617 /// - `count`: Number of line breaks that should end the output.
618 ///
619 /// Output:
620 /// - Appends only the missing line breaks.
621 ///
622 /// Details:
623 /// - Whitespace before a block boundary is removed to keep output stable.
624 fn ensure_line_breaks(&mut self, count: usize) {
625 while self.output.ends_with([' ', '\t']) {
626 let _ = self.output.pop();
627 }
628 let existing = self.output.chars().rev().take_while(|c| *c == '\n').count();
629 for _ in existing..count {
630 self.output.push('\n');
631 }
632 }
633
634 /// What: Enforce the article text output bound.
635 ///
636 /// Inputs: None.
637 ///
638 /// Output:
639 /// - `Ok(())` within the bound or `InputTooLong` when exceeded.
640 ///
641 /// Details:
642 /// - Checked incrementally so malformed markup cannot force an unbounded
643 /// intermediate output allocation.
644 fn ensure_output_bound(&self) -> Result<()> {
645 if self.output.len() <= MAX_ARTICLE_TEXT_BYTES {
646 return Ok(());
647 }
648 Err(ArchToolkitError::InputTooLong {
649 field: "article text".to_string(),
650 max_length: MAX_ARTICLE_TEXT_BYTES,
651 actual_length: self.output.len(),
652 })
653 }
654
655 /// What: Finalize extracted text after the scanner reaches end of input.
656 ///
657 /// Inputs: None.
658 ///
659 /// Output:
660 /// - Trimmed human-readable article text.
661 ///
662 /// Details:
663 /// - Unterminated structures retain their already-visible label or code
664 /// content but cannot create executable HTML.
665 fn finish(self) -> Result<String> {
666 self.ensure_output_bound()?;
667 Ok(self.output.trim().to_string())
668 }
669}
670
671/// What: Parse a tag into name, attributes, closing state, and self-closing state.
672///
673/// Inputs:
674/// - `raw_tag`: Tag text excluding `<` and `>` delimiters.
675///
676/// Output:
677/// - Normalized tag metadata, or `None` for declarations and malformed tags.
678///
679/// Details:
680/// - Tag names are restricted to ASCII HTML-style identifier characters to
681/// keep downstream matching simple and deterministic.
682fn parse_tag(raw_tag: &str) -> Option<(String, &str, bool, bool)> {
683 let trimmed = raw_tag.trim();
684 if trimmed.is_empty() || trimmed.starts_with('!') || trimmed.starts_with('?') {
685 return None;
686 }
687 let closing = trimmed.starts_with('/');
688 let without_marker = trimmed.trim_start_matches('/').trim_start();
689 let name_end = without_marker
690 .find(|character: char| !is_tag_name_character(character))
691 .unwrap_or(without_marker.len());
692 if name_end == 0 {
693 return None;
694 }
695 let tag_name = without_marker[..name_end].to_ascii_lowercase();
696 let attributes = &without_marker[name_end..];
697 let self_closing = !closing && attributes.trim_end().ends_with('/');
698 Some((tag_name, attributes, closing, self_closing))
699}
700
701/// What: Decide whether a character is valid in an HTML-style tag or attribute name.
702///
703/// Inputs:
704/// - `character`: Candidate identifier character.
705///
706/// Output:
707/// - `true` for ASCII letters, digits, `-`, `_`, or `:`.
708///
709/// Details:
710/// - Restricting names to these characters avoids treating punctuation in
711/// malformed markup as a supported tag or attribute.
712const fn is_tag_name_character(character: char) -> bool {
713 character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | ':')
714}
715
716/// What: Extract one case-insensitive attribute value from a raw tag suffix.
717///
718/// Inputs:
719/// - `attributes`: Text after an opening tag name.
720/// - `wanted`: Attribute name to find.
721///
722/// Output:
723/// - Decoded attribute value when present, otherwise `None`.
724///
725/// Details:
726/// - Handles single-quoted, double-quoted, and unquoted values without using
727/// a browser parser. Boolean and malformed attributes are ignored.
728fn attribute_value(attributes: &str, wanted: &str) -> Option<String> {
729 let mut remaining = attributes.trim();
730 while !remaining.is_empty() {
731 let name_end = remaining
732 .find(|character: char| !is_tag_name_character(character))
733 .unwrap_or(remaining.len());
734 if name_end == 0 {
735 remaining = &remaining[1..];
736 continue;
737 }
738 let name = &remaining[..name_end];
739 remaining = remaining[name_end..].trim_start();
740 let Some(after_equals) = remaining.strip_prefix('=') else {
741 continue;
742 };
743 remaining = after_equals.trim_start();
744 let (value, after_value) = split_attribute_value(remaining);
745 remaining = after_value.trim_start();
746 if name.eq_ignore_ascii_case(wanted) {
747 return Some(decode_html_entities(value));
748 }
749 }
750 None
751}
752
753/// What: Split the next HTML attribute value from trailing attributes.
754///
755/// Inputs:
756/// - `input`: Text beginning at a quoted or unquoted attribute value.
757///
758/// Output:
759/// - Tuple of value slice and unconsumed suffix.
760///
761/// Details:
762/// - An unterminated quote consumes the rest of the tag as its value, which is
763/// safer than scanning past a malformed delimiter.
764fn split_attribute_value(input: &str) -> (&str, &str) {
765 let Some(first) = input.chars().next() else {
766 return ("", "");
767 };
768 if matches!(first, '\'' | '"') {
769 let quoted = &input[first.len_utf8()..];
770 if let Some(end) = quoted.find(first) {
771 return ("ed[..end], "ed[end + first.len_utf8()..]);
772 }
773 return (quoted, "");
774 }
775 let end = input.find(char::is_whitespace).unwrap_or(input.len());
776 (&input[..end], &input[end..])
777}
778
779/// What: Resolve an anchor destination against an article URL and limit schemes.
780///
781/// Inputs:
782/// - `base_url`: Valid article HTTP(S) URL.
783/// - `href`: Decoded raw anchor destination.
784///
785/// Output:
786/// - Escaped absolute HTTP(S) URL, or `None` when invalid/unsupported.
787///
788/// Details:
789/// - Bounds raw link length before URL parsing and rejects schemes such as
790/// `javascript:`, `data:`, and `mailto:`.
791fn resolve_http_link(base_url: &reqwest::Url, href: &str) -> Option<String> {
792 if href.is_empty() || href.len() > MAX_LINK_BYTES {
793 return None;
794 }
795 let resolved = base_url.join(href).ok()?;
796 if !matches!(resolved.scheme(), "http" | "https") {
797 return None;
798 }
799 Some(escape_markdown_destination(resolved.as_str()))
800}
801
802/// What: Escape a URL for use inside a Markdown link destination.
803///
804/// Inputs:
805/// - `url`: Valid absolute HTTP(S) URL.
806///
807/// Output:
808/// - URL with Markdown delimiter characters escaped.
809///
810/// Details:
811/// - Prevents a URL containing parentheses or backslashes from injecting text
812/// outside the generated link destination.
813fn escape_markdown_destination(url: &str) -> String {
814 url.replace('\\', "\\\\")
815 .replace('(', "\\(")
816 .replace(')', "\\)")
817}
818
819/// What: Identify tags whose contents are not article text.
820///
821/// Inputs:
822/// - `tag_name`: Normalized tag name.
823///
824/// Output:
825/// - `true` for tags that should suppress all nested text.
826///
827/// Details:
828/// - These tags commonly contain executable, styling, fallback, or templating
829/// content rather than readable article prose.
830fn is_suppressed_tag(tag_name: &str) -> bool {
831 matches!(tag_name, "script" | "style" | "template" | "noscript")
832}
833
834/// What: Identify HTML tags that form visible text block boundaries.
835///
836/// Inputs:
837/// - `tag_name`: Normalized tag name.
838///
839/// Output:
840/// - `true` for supported paragraph-like block elements.
841///
842/// Details:
843/// - Lists and preformatted blocks have dedicated formatting rules elsewhere.
844fn is_block_tag(tag_name: &str) -> bool {
845 matches!(
846 tag_name,
847 "p" | "article"
848 | "section"
849 | "div"
850 | "header"
851 | "footer"
852 | "main"
853 | "aside"
854 | "blockquote"
855 | "h1"
856 | "h2"
857 | "h3"
858 | "h4"
859 | "h5"
860 | "h6"
861 | "ul"
862 | "ol"
863 )
864}
865
866/// What: Identify tokens that should attach to preceding visible text.
867///
868/// Inputs:
869/// - `word`: Collapsed text token being appended.
870///
871/// Output:
872/// - `true` when its first character is closing punctuation.
873///
874/// Details:
875/// - Prevents `word .` and `](url) .` output from ordinary HTML text.
876fn word_starts_with_punctuation(word: &str) -> bool {
877 word.starts_with(['.', ',', ';', ':', '!', '?', ')', ']', '}'])
878}
879
880/// What: Decode common named and numeric HTML entities in text or attributes.
881///
882/// Inputs:
883/// - `input`: Raw text containing possible `&name;` or `&#...;` entities.
884///
885/// Output:
886/// - Decoded text while preserving unknown or malformed entities literally.
887///
888/// Details:
889/// - Entity decoding happens before whitespace handling and link resolution so
890/// visible text and relative query strings are represented correctly.
891fn decode_html_entities(input: &str) -> String {
892 let mut output = String::with_capacity(input.len());
893 let mut remaining = input;
894 while let Some(start) = remaining.find('&') {
895 output.push_str(&remaining[..start]);
896 let after_ampersand = &remaining[start + 1..];
897 let Some(end) = after_ampersand.find(';') else {
898 output.push('&');
899 output.push_str(after_ampersand);
900 return output;
901 };
902 let entity = &after_ampersand[..end];
903 if let Some(decoded) = decode_entity(entity) {
904 output.push(decoded);
905 } else {
906 output.push('&');
907 output.push_str(entity);
908 output.push(';');
909 }
910 remaining = &after_ampersand[end + 1..];
911 }
912 output.push_str(remaining);
913 output
914}
915
916/// What: Decode one named or numeric HTML entity.
917///
918/// Inputs:
919/// - `entity`: Entity name without leading `&` or trailing `;`.
920///
921/// Output:
922/// - Decoded character when recognized, otherwise `None`.
923///
924/// Details:
925/// - Supports the standard entities used by Arch feeds and numeric Unicode
926/// code points without adding an HTML parser dependency.
927fn decode_entity(entity: &str) -> Option<char> {
928 match entity {
929 "amp" => Some('&'),
930 "lt" => Some('<'),
931 "gt" => Some('>'),
932 "quot" => Some('"'),
933 "apos" | "#39" => Some('\''),
934 "nbsp" => Some(' '),
935 _ => decode_numeric_entity(entity),
936 }
937}
938
939/// What: Decode a numeric decimal or hexadecimal HTML entity.
940///
941/// Inputs:
942/// - `entity`: Numeric entity body without delimiters.
943///
944/// Output:
945/// - Unicode character when the code point is valid, otherwise `None`.
946///
947/// Details:
948/// - Invalid numeric values remain literal in the caller's output.
949fn decode_numeric_entity(entity: &str) -> Option<char> {
950 let hexadecimal = entity
951 .strip_prefix("#x")
952 .or_else(|| entity.strip_prefix("#X"));
953 if let Some(value) = hexadecimal {
954 return u32::from_str_radix(value, 16).ok().and_then(char::from_u32);
955 }
956 entity
957 .strip_prefix('#')
958 .and_then(|value| value.parse::<u32>().ok())
959 .and_then(char::from_u32)
960}
961
962#[cfg(test)]
963mod tests {
964 use super::{MAX_ARTICLE_HTML_BYTES, extract_article_text};
965
966 #[test]
967 /// What: Verify supported article structures become safe readable text.
968 ///
969 /// Inputs:
970 /// - HTML with paragraphs, lists, inline/preformatted code, relative links,
971 /// entities, and script content.
972 ///
973 /// Output:
974 /// - Text preserves visible structures, resolves the relative link, and
975 /// discards executable script content.
976 ///
977 /// Details:
978 /// - This is the core fixture proof that the extractor does not execute or
979 /// emit raw HTML while retaining the requested article structures.
980 fn extracts_supported_article_content_safely() {
981 let html = r#"<article><p>Read <a href="/guide?one=1&two=2">the [guide]</a>.</p>
982<ul><li>First item</li><li>Use <code>--needed</code></li></ul>
983<pre><code>pacman -Syu
984</code></pre><script>alert('ignored')</script></article>"#;
985 let text = extract_article_text(html, "https://archlinux.org/news/update/")
986 .expect("extract article text");
987
988 assert!(
989 text.contains("Read [the \\[guide\\]](https://archlinux.org/guide?one=1&two=2)."),
990 "actual extracted text: {text:?}"
991 );
992 assert!(text.contains("- First item\n- Use `--needed`"));
993 assert!(text.contains("```\npacman -Syu\n```"));
994 assert!(!text.contains("alert"));
995 assert!(!text.contains("<script"));
996 }
997
998 #[test]
999 /// What: Verify unsafe destinations retain labels without generated links.
1000 ///
1001 /// Inputs:
1002 /// - Article HTML containing a JavaScript URL and a malformed relative URL.
1003 ///
1004 /// Output:
1005 /// - Visible labels remain while unsupported destinations are omitted.
1006 ///
1007 /// Details:
1008 /// - Prevents article HTML from turning non-web schemes into active output.
1009 fn rejects_unsafe_link_schemes() {
1010 let html =
1011 r#"<p><a href="javascript:alert(1)">unsafe</a> and <a href="mailto:x@y">mail</a></p>"#;
1012 let text = extract_article_text(html, "https://archlinux.org/news/update/")
1013 .expect("extract article text");
1014
1015 assert_eq!(text, "unsafe and mail");
1016 }
1017
1018 #[test]
1019 /// What: Verify the HTML input safety bound is enforced before parsing.
1020 ///
1021 /// Inputs:
1022 /// - One byte more than the documented article HTML limit.
1023 ///
1024 /// Output:
1025 /// - An explicit size error.
1026 ///
1027 /// Details:
1028 /// - Avoids unbounded work for hostile or unexpectedly large pages.
1029 fn rejects_oversized_article_html() {
1030 let html = "x".repeat(MAX_ARTICLE_HTML_BYTES + 1);
1031 assert!(extract_article_text(&html, "https://archlinux.org/news/update/").is_err());
1032 }
1033}