use crate::error::{ArchToolkitError, Result};
pub const MAX_ARTICLE_HTML_BYTES: usize = 512 * 1024;
pub const MAX_ARTICLE_TEXT_BYTES: usize = 256 * 1024;
const MAX_LINK_BYTES: usize = 4 * 1024;
pub fn extract_article_text(html: &str, base_url: &str) -> Result<String> {
ensure_article_input_bound(html)?;
let base = parse_article_base_url(base_url)?;
let mut extractor = ArticleTextExtractor::new();
scan_article_html(html, &base, &mut extractor)?;
extractor.finish()
}
pub async fn fetch_article_text(client: &reqwest::Client, article_url: &str) -> Result<String> {
let html =
fetch_bounded_text(client, article_url, MAX_ARTICLE_HTML_BYTES, "news article").await?;
extract_article_text(&html, article_url)
}
pub(super) async fn fetch_bounded_text(
client: &reqwest::Client,
url: &str,
maximum_bytes: usize,
resource_name: &str,
) -> Result<String> {
if maximum_bytes == 0 {
return Err(ArchToolkitError::InvalidInput(format!(
"{resource_name} response bound must be greater than zero"
)));
}
let parsed_url = parse_http_url(url, resource_name)?;
let mut response = client.get(parsed_url).send().await.map_err(|error| {
ArchToolkitError::Parse(format!("{resource_name} request failed: {error}"))
})?;
let status = response.status();
if !status.is_success() {
return Err(ArchToolkitError::Parse(format!(
"{resource_name} returned status {status}"
)));
}
let maximum_length = u64::try_from(maximum_bytes).map_err(|_| {
ArchToolkitError::InvalidInput(format!("{resource_name} response bound is too large"))
})?;
if response
.content_length()
.is_some_and(|length| length > maximum_length)
{
return Err(response_too_large_error(resource_name, maximum_bytes));
}
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|error| {
ArchToolkitError::Parse(format!("{resource_name} response read failed: {error}"))
})? {
if chunk.len() > maximum_bytes.saturating_sub(bytes.len()) {
return Err(response_too_large_error(resource_name, maximum_bytes));
}
bytes.extend_from_slice(&chunk);
}
String::from_utf8(bytes).map_err(|error| {
ArchToolkitError::Parse(format!(
"{resource_name} response was not valid UTF-8: {error}"
))
})
}
fn parse_article_base_url(base_url: &str) -> Result<reqwest::Url> {
parse_http_url(base_url, "article base URL")
}
fn parse_http_url(url: &str, resource_name: &str) -> Result<reqwest::Url> {
let parsed = reqwest::Url::parse(url).map_err(|error| {
ArchToolkitError::InvalidInput(format!("invalid {resource_name} URL: {error}"))
})?;
if matches!(parsed.scheme(), "http" | "https") {
return Ok(parsed);
}
Err(ArchToolkitError::InvalidInput(format!(
"{resource_name} URL must use http or https"
)))
}
fn response_too_large_error(resource_name: &str, maximum_bytes: usize) -> ArchToolkitError {
ArchToolkitError::InputTooLong {
field: format!("{resource_name} response"),
max_length: maximum_bytes,
actual_length: maximum_bytes.saturating_add(1),
}
}
fn ensure_article_input_bound(html: &str) -> Result<()> {
if html.len() <= MAX_ARTICLE_HTML_BYTES {
return Ok(());
}
Err(ArchToolkitError::InputTooLong {
field: "article HTML".to_string(),
max_length: MAX_ARTICLE_HTML_BYTES,
actual_length: html.len(),
})
}
fn scan_article_html(
html: &str,
base_url: &reqwest::Url,
extractor: &mut ArticleTextExtractor,
) -> Result<()> {
let mut remaining = html;
while let Some(start) = remaining.find('<') {
extractor.append_text(&remaining[..start])?;
remaining = &remaining[start..];
if let Some(after_comment) = skip_html_comment(remaining) {
remaining = after_comment;
continue;
}
let Some(end) = find_tag_end(remaining) else {
extractor.append_text(remaining)?;
return Ok(());
};
extractor.handle_tag(&remaining[1..end], base_url)?;
remaining = &remaining[end + 1..];
}
extractor.append_text(remaining)
}
fn skip_html_comment(input: &str) -> Option<&str> {
let suffix = input.strip_prefix("<!--")?;
let end = suffix.find("-->")?;
Some(&suffix[end + 3..])
}
fn find_tag_end(input: &str) -> Option<usize> {
let mut quote = None;
for (index, character) in input.char_indices().skip(1) {
match (quote, character) {
(None, '\'' | '"') => quote = Some(character),
(Some(active), current) if active == current => quote = None,
(None, '>') => return Some(index),
_ => {}
}
}
None
}
struct OpenLink {
output_start: usize,
destination: Option<String>,
}
struct ArticleTextExtractor {
output: String,
suppressed_tags: Vec<String>,
pre_depth: usize,
inline_code_depth: usize,
open_links: Vec<OpenLink>,
}
impl ArticleTextExtractor {
const fn new() -> Self {
Self {
output: String::new(),
suppressed_tags: Vec::new(),
pre_depth: 0,
inline_code_depth: 0,
open_links: Vec::new(),
}
}
fn append_text(&mut self, text: &str) -> Result<()> {
if !self.suppressed_tags.is_empty() || text.is_empty() {
return Ok(());
}
let decoded = decode_html_entities(text);
if self.pre_depth > 0 {
self.push_visible(&decoded);
return self.ensure_output_bound();
}
for word in decoded.split_whitespace() {
if self.needs_word_separator(word) {
self.push_visible(" ");
}
self.push_visible(word);
}
self.ensure_output_bound()
}
fn handle_tag(&mut self, raw_tag: &str, base_url: &reqwest::Url) -> Result<()> {
let Some((tag_name, attributes, closing, self_closing)) = parse_tag(raw_tag) else {
return Ok(());
};
if self.handle_suppressed_tag(&tag_name, closing, self_closing) {
return Ok(());
}
if closing {
self.close_tag(&tag_name)?;
} else {
self.open_tag(&tag_name, attributes, base_url)?;
if self_closing {
self.close_tag(&tag_name)?;
}
}
self.ensure_output_bound()
}
fn handle_suppressed_tag(&mut self, tag_name: &str, closing: bool, self_closing: bool) -> bool {
if let Some(open_tag) = self.suppressed_tags.last() {
if closing && open_tag == tag_name {
let _ = self.suppressed_tags.pop();
}
return true;
}
if !closing && !self_closing && is_suppressed_tag(tag_name) {
self.suppressed_tags.push(tag_name.to_string());
return true;
}
false
}
fn open_tag(
&mut self,
tag_name: &str,
attributes: &str,
base_url: &reqwest::Url,
) -> Result<()> {
match tag_name {
"br" => self.ensure_line_breaks(1),
"li" => {
self.ensure_line_breaks(1);
self.push_visible("- ");
}
"pre" => {
self.ensure_line_breaks(2);
self.push_visible("```\n");
self.pre_depth += 1;
}
"code" if self.pre_depth == 0 => {
if self.needs_word_separator("code") {
self.push_visible(" ");
}
self.push_visible("`");
self.inline_code_depth += 1;
}
"a" => self.open_link(attributes, base_url),
_ if is_block_tag(tag_name) => self.ensure_line_breaks(2),
_ => {}
}
self.ensure_output_bound()
}
fn close_tag(&mut self, tag_name: &str) -> Result<()> {
match tag_name {
"li" => self.ensure_line_breaks(1),
"pre" if self.pre_depth > 0 => {
self.pre_depth -= 1;
if self.pre_depth == 0 {
self.ensure_line_breaks(1);
self.push_visible("```\n\n");
}
}
"code" if self.pre_depth == 0 && self.inline_code_depth > 0 => {
self.inline_code_depth -= 1;
self.push_visible("`");
}
"a" => self.close_link(),
_ if is_block_tag(tag_name) => self.ensure_line_breaks(2),
_ => {}
}
self.ensure_output_bound()
}
fn open_link(&mut self, attributes: &str, base_url: &reqwest::Url) {
let destination =
attribute_value(attributes, "href").and_then(|href| resolve_http_link(base_url, &href));
if destination.is_some() && self.needs_word_separator("link") {
self.push_visible(" ");
}
let output_start = self.output.len();
if destination.is_some() {
self.push_visible("[");
}
self.open_links.push(OpenLink {
output_start,
destination,
});
}
fn close_link(&mut self) {
let Some(open_link) = self.open_links.pop() else {
return;
};
let Some(destination) = open_link.destination else {
return;
};
if self.output.len() == open_link.output_start + 1 {
self.output.truncate(open_link.output_start);
return;
}
self.output.push_str("](");
self.output.push_str(&destination);
self.output.push(')');
}
fn push_visible(&mut self, text: &str) {
if self
.open_links
.last()
.is_some_and(|link| link.destination.is_some())
{
for character in text.chars() {
if matches!(character, '[' | ']' | '\\') {
self.output.push('\\');
}
self.output.push(character);
}
} else {
self.output.push_str(text);
}
}
fn needs_word_separator(&self, word: &str) -> bool {
let Some(previous) = self.output.chars().last() else {
return false;
};
!previous.is_whitespace()
&& !matches!(previous, '[' | '`')
&& !word_starts_with_punctuation(word)
}
fn ensure_line_breaks(&mut self, count: usize) {
while self.output.ends_with([' ', '\t']) {
let _ = self.output.pop();
}
let existing = self.output.chars().rev().take_while(|c| *c == '\n').count();
for _ in existing..count {
self.output.push('\n');
}
}
fn ensure_output_bound(&self) -> Result<()> {
if self.output.len() <= MAX_ARTICLE_TEXT_BYTES {
return Ok(());
}
Err(ArchToolkitError::InputTooLong {
field: "article text".to_string(),
max_length: MAX_ARTICLE_TEXT_BYTES,
actual_length: self.output.len(),
})
}
fn finish(self) -> Result<String> {
self.ensure_output_bound()?;
Ok(self.output.trim().to_string())
}
}
fn parse_tag(raw_tag: &str) -> Option<(String, &str, bool, bool)> {
let trimmed = raw_tag.trim();
if trimmed.is_empty() || trimmed.starts_with('!') || trimmed.starts_with('?') {
return None;
}
let closing = trimmed.starts_with('/');
let without_marker = trimmed.trim_start_matches('/').trim_start();
let name_end = without_marker
.find(|character: char| !is_tag_name_character(character))
.unwrap_or(without_marker.len());
if name_end == 0 {
return None;
}
let tag_name = without_marker[..name_end].to_ascii_lowercase();
let attributes = &without_marker[name_end..];
let self_closing = !closing && attributes.trim_end().ends_with('/');
Some((tag_name, attributes, closing, self_closing))
}
const fn is_tag_name_character(character: char) -> bool {
character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | ':')
}
fn attribute_value(attributes: &str, wanted: &str) -> Option<String> {
let mut remaining = attributes.trim();
while !remaining.is_empty() {
let name_end = remaining
.find(|character: char| !is_tag_name_character(character))
.unwrap_or(remaining.len());
if name_end == 0 {
remaining = &remaining[1..];
continue;
}
let name = &remaining[..name_end];
remaining = remaining[name_end..].trim_start();
let Some(after_equals) = remaining.strip_prefix('=') else {
continue;
};
remaining = after_equals.trim_start();
let (value, after_value) = split_attribute_value(remaining);
remaining = after_value.trim_start();
if name.eq_ignore_ascii_case(wanted) {
return Some(decode_html_entities(value));
}
}
None
}
fn split_attribute_value(input: &str) -> (&str, &str) {
let Some(first) = input.chars().next() else {
return ("", "");
};
if matches!(first, '\'' | '"') {
let quoted = &input[first.len_utf8()..];
if let Some(end) = quoted.find(first) {
return ("ed[..end], "ed[end + first.len_utf8()..]);
}
return (quoted, "");
}
let end = input.find(char::is_whitespace).unwrap_or(input.len());
(&input[..end], &input[end..])
}
fn resolve_http_link(base_url: &reqwest::Url, href: &str) -> Option<String> {
if href.is_empty() || href.len() > MAX_LINK_BYTES {
return None;
}
let resolved = base_url.join(href).ok()?;
if !matches!(resolved.scheme(), "http" | "https") {
return None;
}
Some(escape_markdown_destination(resolved.as_str()))
}
fn escape_markdown_destination(url: &str) -> String {
url.replace('\\', "\\\\")
.replace('(', "\\(")
.replace(')', "\\)")
}
fn is_suppressed_tag(tag_name: &str) -> bool {
matches!(tag_name, "script" | "style" | "template" | "noscript")
}
fn is_block_tag(tag_name: &str) -> bool {
matches!(
tag_name,
"p" | "article"
| "section"
| "div"
| "header"
| "footer"
| "main"
| "aside"
| "blockquote"
| "h1"
| "h2"
| "h3"
| "h4"
| "h5"
| "h6"
| "ul"
| "ol"
)
}
fn word_starts_with_punctuation(word: &str) -> bool {
word.starts_with(['.', ',', ';', ':', '!', '?', ')', ']', '}'])
}
fn decode_html_entities(input: &str) -> String {
let mut output = String::with_capacity(input.len());
let mut remaining = input;
while let Some(start) = remaining.find('&') {
output.push_str(&remaining[..start]);
let after_ampersand = &remaining[start + 1..];
let Some(end) = after_ampersand.find(';') else {
output.push('&');
output.push_str(after_ampersand);
return output;
};
let entity = &after_ampersand[..end];
if let Some(decoded) = decode_entity(entity) {
output.push(decoded);
} else {
output.push('&');
output.push_str(entity);
output.push(';');
}
remaining = &after_ampersand[end + 1..];
}
output.push_str(remaining);
output
}
fn decode_entity(entity: &str) -> Option<char> {
match entity {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" | "#39" => Some('\''),
"nbsp" => Some(' '),
_ => decode_numeric_entity(entity),
}
}
fn decode_numeric_entity(entity: &str) -> Option<char> {
let hexadecimal = entity
.strip_prefix("#x")
.or_else(|| entity.strip_prefix("#X"));
if let Some(value) = hexadecimal {
return u32::from_str_radix(value, 16).ok().and_then(char::from_u32);
}
entity
.strip_prefix('#')
.and_then(|value| value.parse::<u32>().ok())
.and_then(char::from_u32)
}
#[cfg(test)]
mod tests {
use super::{MAX_ARTICLE_HTML_BYTES, extract_article_text};
#[test]
fn extracts_supported_article_content_safely() {
let html = r#"<article><p>Read <a href="/guide?one=1&two=2">the [guide]</a>.</p>
<ul><li>First item</li><li>Use <code>--needed</code></li></ul>
<pre><code>pacman -Syu
</code></pre><script>alert('ignored')</script></article>"#;
let text = extract_article_text(html, "https://archlinux.org/news/update/")
.expect("extract article text");
assert!(
text.contains("Read [the \\[guide\\]](https://archlinux.org/guide?one=1&two=2)."),
"actual extracted text: {text:?}"
);
assert!(text.contains("- First item\n- Use `--needed`"));
assert!(text.contains("```\npacman -Syu\n```"));
assert!(!text.contains("alert"));
assert!(!text.contains("<script"));
}
#[test]
fn rejects_unsafe_link_schemes() {
let html =
r#"<p><a href="javascript:alert(1)">unsafe</a> and <a href="mailto:x@y">mail</a></p>"#;
let text = extract_article_text(html, "https://archlinux.org/news/update/")
.expect("extract article text");
assert_eq!(text, "unsafe and mail");
}
#[test]
fn rejects_oversized_article_html() {
let html = "x".repeat(MAX_ARTICLE_HTML_BYTES + 1);
assert!(extract_article_text(&html, "https://archlinux.org/news/update/").is_err());
}
}