lectito 0.3.0

Article extraction and readability parsing for Lectito.
Documentation
use comrak::{escape_commonmark_inline, escape_commonmark_link_destination};
use kuchiki::NodeRef;
use url::Url;

use super::{RenderContext, block, inline_children};
use crate::{dom, patterns};

#[derive(Debug, Clone)]
struct ImageCandidate {
    url: String,
    width: Option<u32>,
    density: Option<f32>,
    order: usize,
}

pub(super) fn render_image(node: &NodeRef) -> String {
    render_image_from_candidates(node, image_candidates(node, 0))
}

pub(super) fn render_picture(node: &NodeRef) -> String {
    let img = dom::select_nodes(node, "img").into_iter().next();
    let mut candidates = Vec::new();
    let mut order = 0;
    for source in dom::select_nodes(node, "source") {
        candidates.extend(srcset_candidates(&source, order));
        order = candidates.len();
    }
    if let Some(img) = img.as_ref() {
        candidates.extend(image_candidates(img, order));
    }
    render_image_from_candidates(img.as_ref().unwrap_or(node), candidates)
}

pub(super) fn render_figure(node: &NodeRef, ctx: RenderContext) -> Option<String> {
    let media = dom::select_nodes(node, "picture, img, iframe, video, audio, object, embed, blockquote")
        .into_iter()
        .filter(|candidate| dom::node_id(candidate) != dom::node_id(node))
        .filter(|candidate| {
            dom::node_name(candidate) != "img"
                || !candidate.ancestors().any(|ancestor| {
                    dom::node_id(&ancestor) != dom::node_id(node) && dom::node_name(&ancestor) == "picture"
                })
        })
        .filter_map(|candidate| match dom::node_name(&candidate).as_str() {
            "picture" => Some(render_picture(&candidate)),
            "img" => Some(render_image(&candidate)),
            "blockquote" | "iframe" | "video" | "audio" | "object" | "embed" => render_embed(&candidate),
            _ => None,
        })
        .filter(|markdown| !markdown.is_empty())
        .collect::<Vec<String>>();
    if media.len() != 1 || !patterns::normalize_spaces(&non_caption_text(node)).trim().is_empty() {
        return None;
    }

    let mut output = media.into_iter().next().unwrap();
    if let Some(caption) = dom::select_nodes(node, "figcaption")
        .into_iter()
        .map(|caption| inline_children(&caption, ctx))
        .find(|caption| !caption.is_empty())
    {
        output.push_str("\n\n");
        output.push_str(&caption);
    }

    Some(block(output))
}

pub(super) fn render_embed(node: &NodeRef) -> Option<String> {
    let mut embed_url = None;
    if dom::node_name(node) == "blockquote" {
        let class = dom::attr(node, "class").unwrap_or_default();
        if !class.contains("twitter-tweet") && !class.contains("x-tweet") {
            embed_url = None
        } else {
            embed_url = dom::select_nodes(node, "a")
                .into_iter()
                .filter_map(|link| dom::attr(&link, "href"))
                .find_map(|href| normalize_embed_url(&href))
        }
    } else {
        for attr in ["src", "data-src", "data", "href"] {
            if let Some(url) = dom::attr(node, attr)
                && let Some(embed) = normalize_embed_url(&url)
            {
                embed_url = Some(embed)
            }
        }
    }

    embed_url.map(|url| format!("[Video]({})", escape_commonmark_link_destination(&url)))
}

fn render_image_from_candidates(node: &NodeRef, candidates: Vec<ImageCandidate>) -> String {
    let Some(src) = best_image_url(candidates) else {
        return String::new();
    };

    let alt = dom::attr(node, "alt").unwrap_or_default();
    let title = dom::attr(node, "title")
        .filter(|title| !title.trim().is_empty())
        .map(|title| format!(" \"{}\"", title.replace('\\', "\\\\").replace('"', "\\\"")))
        .unwrap_or_default();

    format!(
        "![{}]({}{})",
        escape_commonmark_inline(&alt),
        escape_commonmark_link_destination(&src),
        title
    )
}

fn image_candidates(node: &NodeRef, start_order: usize) -> Vec<ImageCandidate> {
    let mut candidates = srcset_candidates(node, start_order);
    let mut order = start_order + candidates.len();
    for attr in ["data-src", "data-original", "data-lazy-src", "data-url", "src"] {
        if let Some(url) = dom::attr(node, attr).filter(|url| !url.trim().is_empty()) {
            candidates.push(ImageCandidate { url, width: None, density: None, order });
            order += 1;
        }
    }
    candidates
}

fn srcset_candidates(node: &NodeRef, start_order: usize) -> Vec<ImageCandidate> {
    ["data-srcset", "srcset"]
        .into_iter()
        .filter_map(|attr| dom::attr(node, attr))
        .flat_map(|srcset| parse_srcset(&srcset))
        .enumerate()
        .map(|(index, mut candidate)| {
            candidate.order = start_order + index;
            candidate
        })
        .collect()
}

fn parse_srcset(srcset: &str) -> Vec<ImageCandidate> {
    let parts: Vec<_> = srcset.split(',').collect();
    let mut candidates = Vec::new();
    let mut start = 0;
    let mut current = String::new();

    for (index, part) in parts.iter().enumerate() {
        if !current.is_empty() {
            current.push(',');
        }
        current.push_str(part);

        if current
            .split_whitespace()
            .last()
            .is_some_and(|value| parse_width_descriptor(value).is_some() || parse_density_descriptor(value).is_some())
        {
            if let Some(candidate) = srcset.get(start..start + current.len()) {
                candidates.push(candidate.trim());
            }
            start += current.len() + usize::from(index + 1 < parts.len());
            current.clear();
        }
    }
    if !current.trim().is_empty()
        && let Some(candidate) = srcset.get(start..)
    {
        candidates.push(candidate.trim());
    }

    candidates
        .into_iter()
        .filter(|candidate| !candidate.is_empty())
        .filter_map(|candidate| {
            let mut parts = candidate.split_whitespace();
            let url = parts.next()?.to_string();
            if url.is_empty() {
                return None;
            }
            let descriptor = parts.last();
            let width = descriptor.and_then(parse_width_descriptor);
            let density = descriptor.and_then(parse_density_descriptor);
            Some(ImageCandidate { url, width, density, order: 0 })
        })
        .collect()
}

fn parse_width_descriptor(value: &str) -> Option<u32> {
    value.strip_suffix('w')?.parse::<u32>().ok()
}

fn parse_density_descriptor(value: &str) -> Option<f32> {
    value.strip_suffix('x')?.parse::<f32>().ok()
}

fn best_image_url(candidates: Vec<ImageCandidate>) -> Option<String> {
    candidates
        .into_iter()
        .filter(|candidate| {
            let trimmed = candidate.url.trim().to_ascii_lowercase();
            !(trimmed.is_empty()
                || trimmed.starts_with("data:")
                || trimmed == "#"
                || trimmed == "about:blank"
                || trimmed.contains("placeholder"))
        })
        .max_by(|a, b| {
            image_score(a)
                .total_cmp(&image_score(b))
                .then_with(|| b.order.cmp(&a.order))
        })
        .map(|candidate| candidate.url)
}

fn image_score(candidate: &ImageCandidate) -> f32 {
    if let Some(width) = candidate.width {
        width as f32
    } else if let Some(density) = candidate.density {
        density * 1000.0
    } else {
        0.0
    }
}

fn non_caption_text(node: &NodeRef) -> String {
    match node.as_text() {
        Some(text) => text.borrow().to_string(),
        None => match dom::node_name(node).as_str() {
            "figcaption" | "picture" | "img" | "iframe" | "video" | "audio" | "object" | "embed" => String::new(),
            _ => node
                .children()
                .map(|child| non_caption_text(&child))
                .collect::<Vec<_>>()
                .join(" "),
        },
    }
}

fn normalize_embed_url(url: &str) -> Option<String> {
    let parsed = Url::parse(url).ok()?;
    let host = parsed.host_str()?.trim_start_matches("www.");
    if host == "youtu.be" {
        let id = parsed.path_segments()?.next()?.trim();
        if !id.is_empty() {
            return Some(format!("https://www.youtube.com/watch?v={id}"));
        }
    }
    if host.ends_with("youtube.com") || host.ends_with("youtube-nocookie.com") {
        if parsed.path() == "/watch"
            && let Some(id) = parsed
                .query_pairs()
                .find(|(name, _)| name == "v")
                .map(|(_, value)| value)
        {
            return Some(format!("https://www.youtube.com/watch?v={id}"));
        }
        for prefix in ["/embed/", "/shorts/"] {
            if let Some(id) = parsed
                .path()
                .strip_prefix(prefix)
                .and_then(|path| path.split('/').next())
                && !id.is_empty()
            {
                return Some(format!("https://www.youtube.com/watch?v={id}"));
            }
        }
    }
    if (host.ends_with("twitter.com") || host.ends_with("x.com")) && parsed.path().contains("/status/") {
        return Some(url.to_string());
    }
    None
}

#[cfg(test)]
mod tests {
    #[test]
    fn parses_srcset_urls_with_commas() {
        let candidates = super::parse_srcset(
            "https://cdn.example.com/image,w_400.jpg 400w, https://cdn.example.com/image,w_1600.jpg 1600w",
        );

        assert_eq!(candidates.len(), 2);
        assert_eq!(candidates[0].url, "https://cdn.example.com/image,w_400.jpg");
        assert_eq!(candidates[1].url, "https://cdn.example.com/image,w_1600.jpg");
        assert_eq!(
            super::best_image_url(candidates).as_deref(),
            Some("https://cdn.example.com/image,w_1600.jpg")
        );
    }

    #[test]
    fn parses_srcset_without_spaces_after_commas() {
        let candidates = super::parse_srcset("small.png 36w,medium.png 480w,large.png 2880w");

        assert_eq!(candidates.len(), 3);
        assert_eq!(super::best_image_url(candidates).as_deref(), Some("large.png"));
    }
}