Extraction looks for prose, so a front page, a comment thread or a search
result page correctly yields almost nothing. These render the structure
instead, and every link they emit routes back through /read.
/search?q= results from a configurable HTML endpoint
/feed?u= RSS and Atom
/read?u= now picks a site view, falling back to a link index when a
page has too little text to be an article
Feeds are scanned rather than parsed. A feed needs five fields per entry
and an HTML parser mangles XML, so this walks the tags directly: CDATA,
named and numeric entities, and Atom's preference for rel=alternate over
rel=self. No XML dependency.
Hacker News gets a real adapter: stories with score, author and a link
into the discussion, and comment threads rendered with their indent
preserved. Comment bodies go through the article renderer so links inside
them behave like every other link.
Search is deliberately engine-agnostic. Known result shapes are tried
first, then heading links, then any link, because every free HTML endpoint
eventually rate-limits a repeat visitor. When one answers with a challenge
page rather than results the reader says so and points at the config,
instead of showing an empty page; detection reads the body, since these
arrive as 200 or 202 rather than an error status. A blocked search is
never cached.
Pages that turn out to be feeds redirect to the feed view, and a page that
declares its own feed offers it.
208 lines
7.5 KiB
Rust
208 lines
7.5 KiB
Rust
//! Views for pages that are not articles.
|
|
//!
|
|
//! Extraction looks for prose, so a front page or a comment thread correctly
|
|
//! yields almost nothing. These render the structure instead.
|
|
|
|
use std::sync::LazyLock;
|
|
|
|
use furst_read::urljoin::join;
|
|
use scraper::{ElementRef, Html, Selector};
|
|
|
|
use crate::page::{escape, read_link};
|
|
|
|
pub struct View {
|
|
pub title: String,
|
|
pub body: String,
|
|
}
|
|
|
|
/// A site-specific view, when one applies.
|
|
pub fn adapt(doc: &Html, base: &str) -> Option<View> {
|
|
if base.contains("news.ycombinator.com") {
|
|
return Some(hacker_news(doc, base));
|
|
}
|
|
None
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Hacker News
|
|
|
|
static HN_ROW: LazyLock<Selector> = LazyLock::new(|| Selector::parse("tr.athing.submission").unwrap());
|
|
static HN_TITLELINE: LazyLock<Selector> = LazyLock::new(|| Selector::parse("span.titleline > a").unwrap());
|
|
static HN_COMMENT: LazyLock<Selector> = LazyLock::new(|| Selector::parse("tr.athing.comtr").unwrap());
|
|
static HN_IND: LazyLock<Selector> = LazyLock::new(|| Selector::parse("td.ind").unwrap());
|
|
static HN_USER: LazyLock<Selector> = LazyLock::new(|| Selector::parse("a.hnuser").unwrap());
|
|
static HN_AGE: LazyLock<Selector> = LazyLock::new(|| Selector::parse("span.age > a").unwrap());
|
|
static HN_TEXT: LazyLock<Selector> = LazyLock::new(|| Selector::parse(".commtext").unwrap());
|
|
static HN_SUBTEXT: LazyLock<Selector> = LazyLock::new(|| Selector::parse("td.subtext").unwrap());
|
|
|
|
fn hacker_news(doc: &Html, base: &str) -> View {
|
|
let stories = stories(doc, base);
|
|
let comments = comments(doc, base);
|
|
|
|
let mut body = String::new();
|
|
if !stories.is_empty() {
|
|
body.push_str(&stories);
|
|
}
|
|
if !comments.is_empty() {
|
|
if !stories.is_empty() {
|
|
body.push_str("<p class=\"sec\">Comments</p>");
|
|
}
|
|
body.push_str(&comments);
|
|
}
|
|
if body.is_empty() {
|
|
body.push_str("<p class=\"notice\">Nothing to show on this page.</p>");
|
|
}
|
|
View { title: "Hacker News".to_string(), body }
|
|
}
|
|
|
|
fn stories(doc: &Html, base: &str) -> String {
|
|
let mut out = String::new();
|
|
for row in doc.select(&HN_ROW) {
|
|
let Some(link) = row.select(&HN_TITLELINE).next() else { continue };
|
|
let title = text_of(link);
|
|
let target = join(base, link.value().attr("href").unwrap_or(""));
|
|
|
|
// The score and comment count live in the row after the title.
|
|
let sub = row
|
|
.next_siblings()
|
|
.filter_map(ElementRef::wrap)
|
|
.find_map(|s| s.select(&HN_SUBTEXT).next());
|
|
let mut meta = Vec::new();
|
|
let mut discussion = None;
|
|
if let Some(sub) = sub {
|
|
let score = sub
|
|
.select(&Selector::parse("span.score").unwrap())
|
|
.next()
|
|
.map(text_of)
|
|
.unwrap_or_default();
|
|
if !score.is_empty() {
|
|
meta.push(escape(&score));
|
|
}
|
|
if let Some(user) = sub.select(&HN_USER).next() {
|
|
meta.push(escape(&text_of(user)));
|
|
}
|
|
// The discussion link is the last one, reading "N comments".
|
|
for a in sub.select(&Selector::parse("a").unwrap()) {
|
|
let t = text_of(a);
|
|
if t.contains("comment") || t == "discuss" {
|
|
discussion = Some((join(base, a.value().attr("href").unwrap_or("")), t));
|
|
}
|
|
}
|
|
}
|
|
if let Some((_, label)) = &discussion {
|
|
meta.push(escape(label));
|
|
}
|
|
|
|
out.push_str(&format!(
|
|
"<li><a class=\"t\" href=\"{}\">{}</a><span class=\"u\">{}{}</span></li>",
|
|
read_link(&target),
|
|
escape(&title),
|
|
escape(&crate::handler::host_of(&target)),
|
|
if meta.is_empty() { String::new() } else { format!(" · {}", meta.join(" · ")) }
|
|
));
|
|
if let Some((url, label)) = discussion {
|
|
out.push_str(&format!(
|
|
"<li style=\"margin-top:-0.9rem\"><a class=\"u\" href=\"{}\">→ {}</a></li>",
|
|
read_link(&url),
|
|
escape(&label)
|
|
));
|
|
}
|
|
}
|
|
if out.is_empty() { out } else { format!("<ul class=\"rows\">{out}</ul>") }
|
|
}
|
|
|
|
fn comments(doc: &Html, base: &str) -> String {
|
|
let mut out = String::new();
|
|
for row in doc.select(&HN_COMMENT) {
|
|
let depth: usize = row
|
|
.select(&HN_IND)
|
|
.next()
|
|
.and_then(|td| td.value().attr("indent"))
|
|
.and_then(|d| d.parse().ok())
|
|
.unwrap_or(0);
|
|
let user = row.select(&HN_USER).next().map(text_of).unwrap_or_default();
|
|
let age = row.select(&HN_AGE).next().map(text_of).unwrap_or_default();
|
|
let Some(text) = row.select(&HN_TEXT).next() else { continue };
|
|
|
|
// Reuse the article renderer so links inside comments route back
|
|
// through the reader like every other link does.
|
|
let body = furst_read::render::body(
|
|
doc,
|
|
&furst_read::extract::Article {
|
|
title: String::new(),
|
|
byline: None,
|
|
site: None,
|
|
nodes: vec![text.id()],
|
|
},
|
|
base,
|
|
&furst_read::render::Options { images: false, link_prefix: Some("/read?u=".to_string()) },
|
|
);
|
|
out.push_str(&format!(
|
|
"<div class=\"cmt\" style=\"margin-left:{}rem\"><div class=\"h\">{} · {}</div>{}</div>",
|
|
(depth.min(12) as f32) * 1.1,
|
|
escape(&user),
|
|
escape(&age),
|
|
body
|
|
));
|
|
}
|
|
out
|
|
}
|
|
|
|
// ------------------------------------------------------------- generic index
|
|
|
|
static LINKS: LazyLock<Selector> = LazyLock::new(|| Selector::parse("a[href]").unwrap());
|
|
|
|
/// The fallback for a page with no article: every link on it worth following.
|
|
pub fn index(doc: &Html, base: &str) -> String {
|
|
let mut seen: Vec<String> = Vec::new();
|
|
let mut rows = String::new();
|
|
|
|
for a in doc.select(&LINKS) {
|
|
let href = a.value().attr("href").unwrap_or("").trim();
|
|
if href.is_empty() || href.starts_with('#') || href.to_ascii_lowercase().starts_with("javascript:") {
|
|
continue;
|
|
}
|
|
let text = text_of(a);
|
|
// Navigation is short; things worth reading are not.
|
|
if text.chars().count() < 18 {
|
|
continue;
|
|
}
|
|
let target = join(base, href);
|
|
if !target.starts_with("http") || target == base || seen.contains(&target) {
|
|
continue;
|
|
}
|
|
seen.push(target.clone());
|
|
rows.push_str(&format!(
|
|
"<li><a class=\"t\" href=\"{}\">{}</a><span class=\"u\">{}</span></li>",
|
|
read_link(&target),
|
|
escape(&text),
|
|
escape(&crate::handler::host_of(&target))
|
|
));
|
|
if seen.len() >= 200 {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if rows.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!("<p class=\"sec\">{} links on this page</p><ul class=\"rows\">{rows}</ul>", seen.len())
|
|
}
|
|
}
|
|
|
|
/// A feed the page declares for itself, so the reader can offer it.
|
|
pub fn declared_feed(doc: &Html, base: &str) -> Option<String> {
|
|
static ALT: LazyLock<Selector> =
|
|
LazyLock::new(|| Selector::parse("link[rel~=\"alternate\"][href]").unwrap());
|
|
for link in doc.select(&ALT) {
|
|
let kind = link.value().attr("type").unwrap_or("");
|
|
if kind.contains("rss") || kind.contains("atom") {
|
|
return Some(join(base, link.value().attr("href").unwrap_or("")));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn text_of(el: ElementRef) -> String {
|
|
furst_read::extract::normalize(&furst_read::extract::inner_text(el))
|
|
}
|