Initial commit: The Daily EPUB full implementation
Full implementation of a personalized daily newspaper delivered as an EPUB. Articles are pulled from a local self-hosted Miniflux instance, enriched with comments, summarized and filtered by DeepSeek AI, and then assembled into two EPUB editions: standard and optimized for the Xteink X4 e-ink reader. Both are served by the local self-hosted BookOrbit OPDS server in a separate library. Then the X4 edition is futher converted to XTC format and served over a separate OPDS server hosted by the Rust binary. Runs are tracked in a local SQLite database so runs are idempotent per date. Full documentation of the plan is in docs/plans and setup and install instructions are in the README.md file.
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
//! HackerNews via the Algolia API (spec §3.4, §3.7).
|
||||
|
||||
use jiff::Timestamp;
|
||||
use serde::Deserialize;
|
||||
use url::Url;
|
||||
|
||||
use super::SocialError;
|
||||
use crate::types::{ArticleId, Comment, CommentThread, SocialRef, SocialSource};
|
||||
|
||||
/// Algolia search endpoint (free, generous limits) (§3.4).
|
||||
pub const SEARCH_URL: &str = "https://hn.algolia.com/api/v1/search";
|
||||
/// Algolia item-tree endpoint used for comment chapters (§3.7).
|
||||
pub const ITEM_URL: &str = "https://hn.algolia.com/api/v1/items";
|
||||
/// Canonical HN item page prefix.
|
||||
pub const ITEM_PAGE: &str = "https://news.ycombinator.com/item?id=";
|
||||
|
||||
/// Hits requested per URL search — enough to spot the canonical submission.
|
||||
const HITS_PER_PAGE: &str = "10";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One Algolia search hit (only the fields §3.4 uses).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Hit {
|
||||
#[serde(rename = "objectID")]
|
||||
pub object_id: String,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub points: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub num_comments: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct SearchResponse {
|
||||
#[serde(default)]
|
||||
hits: Vec<Hit>,
|
||||
}
|
||||
|
||||
/// A node of the Algolia item tree (`/items/{id}`) (§3.7).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct Item {
|
||||
#[serde(default)]
|
||||
id: Option<i64>,
|
||||
#[serde(default, rename = "type")]
|
||||
kind: Option<String>,
|
||||
#[serde(default)]
|
||||
author: Option<String>,
|
||||
// The story's own `title` is deliberately not deserialized here: the comment
|
||||
// renderer takes the article title from the `Pick`, not from Algolia (§3.7).
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
#[serde(default)]
|
||||
points: Option<i64>,
|
||||
#[serde(default)]
|
||||
children: Vec<Item>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure parsing (unit-tested against `tests/fixtures/hn_*.json`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Extract the story id from a `news.ycombinator.com/item?id=N` comments URL (§3.4).
|
||||
pub fn story_id_from_comments_url(comments_url: &str) -> Option<String> {
|
||||
let url = Url::parse(comments_url.trim()).ok()?;
|
||||
let host = url.host_str()?.to_ascii_lowercase();
|
||||
if host != "news.ycombinator.com" && !host.ends_with(".ycombinator.com") {
|
||||
return None;
|
||||
}
|
||||
if !url.path().starts_with("/item") {
|
||||
return None;
|
||||
}
|
||||
url.query_pairs()
|
||||
.find(|(k, _)| k == "id")
|
||||
.map(|(_, v)| v.into_owned())
|
||||
.filter(|id| !id.is_empty() && id.chars().all(|c| c.is_ascii_digit()))
|
||||
}
|
||||
|
||||
fn parse_hits(body: &str) -> Result<Vec<Hit>, SocialError> {
|
||||
serde_json::from_str::<SearchResponse>(body)
|
||||
.map(|r| r.hits)
|
||||
.map_err(|e| SocialError::Unexpected {
|
||||
platform: "hn",
|
||||
detail: e.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Turn a search response into the best [`SocialRef`] for `canonical_url` (§3.4).
|
||||
///
|
||||
/// Prefers a hit whose own URL canonicalizes to `canonical_url`; otherwise the
|
||||
/// highest-scoring hit wins (Algolia sorts by relevance, not points).
|
||||
pub fn parse_search_response(
|
||||
body: &str,
|
||||
canonical_url: &str,
|
||||
article_id: ArticleId,
|
||||
fetched_at: Timestamp,
|
||||
) -> Result<Option<SocialRef>, SocialError> {
|
||||
let hits = parse_hits(body)?;
|
||||
Ok(best_hit(&hits, Some(canonical_url)).map(|hit| social_ref(hit, article_id, fetched_at)))
|
||||
}
|
||||
|
||||
/// Turn a `search?tags=story_{id}` response into a [`SocialRef`] (§3.4).
|
||||
pub fn parse_story_response(
|
||||
body: &str,
|
||||
article_id: ArticleId,
|
||||
fetched_at: Timestamp,
|
||||
) -> Result<Option<SocialRef>, SocialError> {
|
||||
let hits = parse_hits(body)?;
|
||||
Ok(best_hit(&hits, None).map(|hit| social_ref(hit, article_id, fetched_at)))
|
||||
}
|
||||
|
||||
fn best_hit<'a>(hits: &'a [Hit], canonical_url: Option<&str>) -> Option<&'a Hit> {
|
||||
let exact = canonical_url.and_then(|want| {
|
||||
hits.iter()
|
||||
.filter(|h| {
|
||||
h.url
|
||||
.as_deref()
|
||||
.and_then(crate::dedupe::canonical_url)
|
||||
.is_some_and(|c| c == want)
|
||||
})
|
||||
.max_by_key(|h| h.points.unwrap_or(0))
|
||||
});
|
||||
exact.or_else(|| hits.iter().max_by_key(|h| h.points.unwrap_or(0)))
|
||||
}
|
||||
|
||||
fn social_ref(hit: &Hit, article_id: ArticleId, fetched_at: Timestamp) -> SocialRef {
|
||||
SocialRef {
|
||||
article_id,
|
||||
source: SocialSource::Hn,
|
||||
item_id: Some(hit.object_id.clone()),
|
||||
score: hit.points.unwrap_or(0),
|
||||
num_comments: hit.num_comments.unwrap_or(0),
|
||||
item_url: Some(format!("{ITEM_PAGE}{}", hit.object_id)),
|
||||
fetched_at,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `/items/{id}` into a [`CommentThread`] (§3.7).
|
||||
///
|
||||
/// The tree is returned in full; [`crate::comments::truncate`] applies the §3.7
|
||||
/// display limits.
|
||||
pub fn parse_item_response(body: &str) -> Result<CommentThread, SocialError> {
|
||||
let item: Item = serde_json::from_str(body).map_err(|e| SocialError::Unexpected {
|
||||
platform: "hn",
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
let object_id = item.id.map(|i| i.to_string()).unwrap_or_default();
|
||||
let comments = map_children(&item.children, 0);
|
||||
let total = count_comments(&item.children);
|
||||
Ok(CommentThread {
|
||||
source: SocialSource::Hn,
|
||||
item_url: format!("{ITEM_PAGE}{object_id}"),
|
||||
total_comments: total,
|
||||
comments,
|
||||
})
|
||||
}
|
||||
|
||||
fn map_children(children: &[Item], depth: usize) -> Vec<Comment> {
|
||||
children
|
||||
.iter()
|
||||
.filter(|c| c.kind.as_deref() != Some("story"))
|
||||
.filter_map(|child| {
|
||||
let text = child.text.as_deref().unwrap_or("").trim();
|
||||
let kids = map_children(&child.children, depth + 1);
|
||||
if text.is_empty() && kids.is_empty() {
|
||||
return None; // deleted comment with no surviving replies
|
||||
}
|
||||
Some(Comment {
|
||||
author: child.author.clone().unwrap_or_else(|| "[deleted]".into()),
|
||||
points: child.points,
|
||||
text_html: crate::extract::sanitize(text),
|
||||
depth,
|
||||
children: kids,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn count_comments(children: &[Item]) -> i64 {
|
||||
children
|
||||
.iter()
|
||||
.map(|c| 1 + count_comments(&c.children))
|
||||
.sum()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Network
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn get_text(
|
||||
http: &reqwest::Client,
|
||||
url: &str,
|
||||
query: &[(&str, &str)],
|
||||
) -> Result<String, SocialError> {
|
||||
let response = http.get(url).query(query).send().await?;
|
||||
if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||
return Err(SocialError::RateLimited("hn"));
|
||||
}
|
||||
Ok(response.error_for_status()?.text().await?)
|
||||
}
|
||||
|
||||
/// `GET /search?query=<url>&restrictSearchableAttributes=url` → best hit (§3.4).
|
||||
///
|
||||
/// Returns `None` when HN has no submission for this URL.
|
||||
pub async fn search_by_url(
|
||||
http: &reqwest::Client,
|
||||
canonical_url: &str,
|
||||
article_id: ArticleId,
|
||||
) -> Result<Option<SocialRef>, SocialError> {
|
||||
let body = get_text(
|
||||
http,
|
||||
SEARCH_URL,
|
||||
&[
|
||||
("query", canonical_url),
|
||||
("restrictSearchableAttributes", "url"),
|
||||
("tags", "story"),
|
||||
("hitsPerPage", HITS_PER_PAGE),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
parse_search_response(&body, canonical_url, article_id, Timestamp::now())
|
||||
}
|
||||
|
||||
/// `GET /search?tags=story_{id}` → points/comment count for a known story (§3.4).
|
||||
///
|
||||
/// The `/items/{id}` endpoint carries the whole comment tree; the search endpoint
|
||||
/// answers the same question with a fraction of the bytes.
|
||||
pub async fn fetch_story(
|
||||
http: &reqwest::Client,
|
||||
object_id: &str,
|
||||
article_id: ArticleId,
|
||||
) -> Result<Option<SocialRef>, SocialError> {
|
||||
let tag = format!("story_{object_id}");
|
||||
let body = get_text(
|
||||
http,
|
||||
SEARCH_URL,
|
||||
&[("tags", tag.as_str()), ("hitsPerPage", "1")],
|
||||
)
|
||||
.await?;
|
||||
parse_story_response(&body, article_id, Timestamp::now())
|
||||
}
|
||||
|
||||
/// Full comment tree for a story (§3.7).
|
||||
pub async fn fetch_comments(
|
||||
http: &reqwest::Client,
|
||||
object_id: &str,
|
||||
) -> Result<CommentThread, SocialError> {
|
||||
let url = format!("{ITEM_URL}/{object_id}");
|
||||
let body = get_text(http, &url, &[]).await?;
|
||||
parse_item_response(&body)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SEARCH: &str = include_str!("../../tests/fixtures/m2_hn_search_by_url.json");
|
||||
const EMPTY: &str = include_str!("../../tests/fixtures/m2_hn_search_empty.json");
|
||||
const ITEM: &str = include_str!("../../tests/fixtures/m2_hn_item.json");
|
||||
|
||||
fn ts() -> Timestamp {
|
||||
"2026-08-15T05:30:00Z".parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_url_yields_the_story_id() {
|
||||
assert_eq!(
|
||||
story_id_from_comments_url("https://news.ycombinator.com/item?id=41234567").as_deref(),
|
||||
Some("41234567")
|
||||
);
|
||||
assert_eq!(
|
||||
story_id_from_comments_url("http://news.ycombinator.com/item?id=1&foo=bar").as_deref(),
|
||||
Some("1")
|
||||
);
|
||||
assert_eq!(story_id_from_comments_url("https://lobste.rs/s/abc"), None);
|
||||
assert_eq!(
|
||||
story_id_from_comments_url("https://news.ycombinator.com/newest"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
story_id_from_comments_url("https://news.ycombinator.com/item?id=abc"),
|
||||
None
|
||||
);
|
||||
assert_eq!(story_id_from_comments_url(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_response_picks_the_matching_submission() {
|
||||
let got = parse_search_response(SEARCH, "https://blog.dev/post", 7, ts())
|
||||
.unwrap()
|
||||
.expect("a hit");
|
||||
assert_eq!(got.article_id, 7);
|
||||
assert_eq!(got.source, SocialSource::Hn);
|
||||
assert_eq!(got.item_id.as_deref(), Some("41234567"));
|
||||
assert_eq!(got.score, 342);
|
||||
assert_eq!(got.num_comments, 210);
|
||||
assert_eq!(
|
||||
got.item_url.as_deref(),
|
||||
Some("https://news.ycombinator.com/item?id=41234567")
|
||||
);
|
||||
assert_eq!(got.fetched_at, ts());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_response_falls_back_to_the_top_hit() {
|
||||
// No hit canonicalizes to this URL, so the highest-scoring one wins.
|
||||
let got = parse_search_response(SEARCH, "https://elsewhere.dev/x", 7, ts())
|
||||
.unwrap()
|
||||
.expect("a hit");
|
||||
assert_eq!(got.item_id.as_deref(), Some("41234567"));
|
||||
assert_eq!(got.score, 342);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_search_response_is_not_an_error() {
|
||||
assert!(
|
||||
parse_search_response(EMPTY, "https://blog.dev/never-submitted", 1, ts())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(parse_story_response(EMPTY, 1, ts()).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_json_is_reported_not_panicked() {
|
||||
let err = parse_search_response("{not json", "https://x.dev", 1, ts()).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
SocialError::Unexpected { platform: "hn", .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_response_becomes_a_comment_tree() {
|
||||
let thread = parse_item_response(ITEM).unwrap();
|
||||
assert_eq!(thread.source, SocialSource::Hn);
|
||||
assert_eq!(
|
||||
thread.item_url,
|
||||
"https://news.ycombinator.com/item?id=41234567"
|
||||
);
|
||||
// 5 comment nodes in the fixture (one of them deleted).
|
||||
assert_eq!(thread.total_comments, 5);
|
||||
// The deleted, childless comment is dropped from the render tree.
|
||||
assert_eq!(thread.comments.len(), 2);
|
||||
|
||||
let first = &thread.comments[0];
|
||||
assert_eq!(first.author, "dbnerd");
|
||||
assert_eq!(first.points, Some(88));
|
||||
assert_eq!(first.depth, 0);
|
||||
assert!(first.text_html.contains("page splits"));
|
||||
// <i> is not in the allowlist, its text survives.
|
||||
assert!(!first.text_html.contains("<i>"));
|
||||
assert!(first.text_html.contains("Bookmarked."));
|
||||
|
||||
let reply = &first.children[0];
|
||||
assert_eq!(reply.author, "tylerh");
|
||||
assert_eq!(reply.depth, 1);
|
||||
assert_eq!(reply.children[0].depth, 2);
|
||||
assert_eq!(thread.comments[1].author, "skeptic");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
//! Lobsters via `/s/{id}.json` (spec §3.4, §3.7).
|
||||
//!
|
||||
//! Lobsters has no public URL-search API, so linkage only works when the entry
|
||||
//! arrived through a lobste.rs feed or its `comments_url` points at a story (§7).
|
||||
|
||||
use jiff::Timestamp;
|
||||
use serde::Deserialize;
|
||||
use url::Url;
|
||||
|
||||
use super::SocialError;
|
||||
use crate::types::{ArticleId, Comment, CommentThread, SocialRef, SocialSource};
|
||||
|
||||
pub const STORY_URL_PREFIX: &str = "https://lobste.rs/s/";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct Story {
|
||||
#[serde(default)]
|
||||
short_id: Option<String>,
|
||||
#[serde(default)]
|
||||
short_id_url: Option<String>,
|
||||
#[serde(default)]
|
||||
comments_url: Option<String>,
|
||||
#[serde(default)]
|
||||
score: Option<i64>,
|
||||
#[serde(default)]
|
||||
comment_count: Option<i64>,
|
||||
#[serde(default)]
|
||||
comments: Vec<RawComment>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct RawComment {
|
||||
#[serde(default)]
|
||||
short_id: Option<String>,
|
||||
#[serde(default)]
|
||||
parent_comment: Option<String>,
|
||||
#[serde(default)]
|
||||
comment: Option<String>,
|
||||
#[serde(default)]
|
||||
score: Option<i64>,
|
||||
#[serde(default)]
|
||||
is_deleted: bool,
|
||||
/// String in the current API; older responses nested it in an object.
|
||||
#[serde(default)]
|
||||
commenting_user: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl RawComment {
|
||||
fn author(&self) -> String {
|
||||
match &self.commenting_user {
|
||||
Some(serde_json::Value::String(s)) => s.clone(),
|
||||
Some(serde_json::Value::Object(o)) => o
|
||||
.get("username")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("[unknown]")
|
||||
.to_string(),
|
||||
_ => "[unknown]".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure parsing (unit-tested against `tests/fixtures/m2_lobsters_story.json`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Extract the story id from a `lobste.rs/s/<id>` URL (§3.4).
|
||||
pub fn story_id_from_url(url: &str) -> Option<String> {
|
||||
let parsed = Url::parse(url.trim()).ok()?;
|
||||
let host = parsed.host_str()?.to_ascii_lowercase();
|
||||
if host != "lobste.rs" && !host.ends_with(".lobste.rs") {
|
||||
return None;
|
||||
}
|
||||
let mut segments = parsed.path_segments()?;
|
||||
if segments.next()? != "s" {
|
||||
return None;
|
||||
}
|
||||
segments
|
||||
.next()
|
||||
.map(str::to_string)
|
||||
.filter(|id| !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric()))
|
||||
}
|
||||
|
||||
fn parse_story(body: &str) -> Result<Story, SocialError> {
|
||||
serde_json::from_str(body).map_err(|e| SocialError::Unexpected {
|
||||
platform: "lobsters",
|
||||
detail: e.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Turn a `/s/{id}.json` body into a [`SocialRef`] (§3.4).
|
||||
pub fn parse_story_response(
|
||||
body: &str,
|
||||
story_id: &str,
|
||||
article_id: ArticleId,
|
||||
fetched_at: Timestamp,
|
||||
) -> Result<Option<SocialRef>, SocialError> {
|
||||
let story = parse_story(body)?;
|
||||
let id = story
|
||||
.short_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| story_id.to_string());
|
||||
Ok(Some(SocialRef {
|
||||
article_id,
|
||||
source: SocialSource::Lobsters,
|
||||
item_id: Some(id.clone()),
|
||||
score: story.score.unwrap_or(0),
|
||||
num_comments: story.comment_count.unwrap_or(story.comments.len() as i64),
|
||||
item_url: Some(
|
||||
story
|
||||
.short_id_url
|
||||
.or(story.comments_url)
|
||||
.unwrap_or_else(|| format!("{STORY_URL_PREFIX}{id}")),
|
||||
),
|
||||
fetched_at,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Turn the same body's flat `comments` array into a nested tree (§3.7).
|
||||
pub fn parse_comments_response(body: &str, story_id: &str) -> Result<CommentThread, SocialError> {
|
||||
let story = parse_story(body)?;
|
||||
let id = story
|
||||
.short_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| story_id.to_string());
|
||||
let item_url = story
|
||||
.short_id_url
|
||||
.clone()
|
||||
.or_else(|| story.comments_url.clone())
|
||||
.unwrap_or_else(|| format!("{STORY_URL_PREFIX}{id}"));
|
||||
let total = story.comment_count.unwrap_or(story.comments.len() as i64);
|
||||
Ok(CommentThread {
|
||||
source: SocialSource::Lobsters,
|
||||
item_url,
|
||||
total_comments: total,
|
||||
comments: build_tree(&story.comments),
|
||||
})
|
||||
}
|
||||
|
||||
/// Lobsters returns a flat list ordered depth-first with `parent_comment` links.
|
||||
fn build_tree(raw: &[RawComment]) -> Vec<Comment> {
|
||||
let mut roots: Vec<Comment> = Vec::new();
|
||||
// Path of `short_id`s from the root to the comment most recently inserted.
|
||||
let mut path: Vec<String> = Vec::new();
|
||||
|
||||
for item in raw {
|
||||
if item.is_deleted {
|
||||
continue;
|
||||
}
|
||||
let text = item.comment.as_deref().unwrap_or("").trim();
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let comment = Comment {
|
||||
author: item.author(),
|
||||
points: item.score,
|
||||
text_html: crate::extract::sanitize(text),
|
||||
depth: 0,
|
||||
children: Vec::new(),
|
||||
};
|
||||
let short_id = item.short_id.clone().unwrap_or_default();
|
||||
match item.parent_comment.as_deref() {
|
||||
Some(parent) => {
|
||||
while path.last().is_some_and(|p| p != parent) {
|
||||
path.pop();
|
||||
}
|
||||
if path.is_empty() {
|
||||
// Parent was dropped (deleted); promote to a root thread.
|
||||
roots.push(comment);
|
||||
path = vec![short_id];
|
||||
continue;
|
||||
}
|
||||
let depth = path.len();
|
||||
if let Some(node) = descend(&mut roots, &path) {
|
||||
let mut child = comment;
|
||||
child.depth = depth;
|
||||
node.children.push(child);
|
||||
path.push(short_id);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
roots.push(comment);
|
||||
path = vec![short_id];
|
||||
}
|
||||
}
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
/// Walk `roots` along the ids in `path`, returning the last node on it.
|
||||
fn descend<'a>(roots: &'a mut [Comment], path: &[String]) -> Option<&'a mut Comment> {
|
||||
let mut node = roots.last_mut()?;
|
||||
for _ in 1..path.len() {
|
||||
node = node.children.last_mut()?;
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Network
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn get_text(http: &reqwest::Client, story_id: &str) -> Result<String, SocialError> {
|
||||
let url = format!("{STORY_URL_PREFIX}{story_id}.json");
|
||||
let response = http.get(&url).send().await?;
|
||||
if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||
return Err(SocialError::RateLimited("lobsters"));
|
||||
}
|
||||
Ok(response.error_for_status()?.text().await?)
|
||||
}
|
||||
|
||||
/// `GET https://lobste.rs/s/{id}.json` → score + comment count (§3.4).
|
||||
pub async fn fetch_story(
|
||||
http: &reqwest::Client,
|
||||
story_id: &str,
|
||||
article_id: ArticleId,
|
||||
) -> Result<Option<SocialRef>, SocialError> {
|
||||
let body = get_text(http, story_id).await?;
|
||||
parse_story_response(&body, story_id, article_id, Timestamp::now())
|
||||
}
|
||||
|
||||
/// The same endpoint's `comments` array, as a tree (§3.7).
|
||||
pub async fn fetch_comments(
|
||||
http: &reqwest::Client,
|
||||
story_id: &str,
|
||||
) -> Result<CommentThread, SocialError> {
|
||||
let body = get_text(http, story_id).await?;
|
||||
parse_comments_response(&body, story_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const STORY: &str = include_str!("../../tests/fixtures/m2_lobsters_story.json");
|
||||
|
||||
fn ts() -> Timestamp {
|
||||
"2026-08-15T05:30:00Z".parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn story_ids_come_out_of_lobsters_urls() {
|
||||
assert_eq!(
|
||||
story_id_from_url("https://lobste.rs/s/abcdef/a_deep_dive").as_deref(),
|
||||
Some("abcdef")
|
||||
);
|
||||
assert_eq!(
|
||||
story_id_from_url("https://lobste.rs/s/abcdef").as_deref(),
|
||||
Some("abcdef")
|
||||
);
|
||||
assert_eq!(story_id_from_url("https://lobste.rs/"), None);
|
||||
assert_eq!(story_id_from_url("https://lobste.rs/s/"), None);
|
||||
assert_eq!(
|
||||
story_id_from_url("https://news.ycombinator.com/item?id=1"),
|
||||
None
|
||||
);
|
||||
assert_eq!(story_id_from_url("garbage"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn story_response_gives_score_and_comment_count() {
|
||||
let got = parse_story_response(STORY, "abcdef", 9, ts())
|
||||
.unwrap()
|
||||
.expect("a story");
|
||||
assert_eq!(got.article_id, 9);
|
||||
assert_eq!(got.source, SocialSource::Lobsters);
|
||||
assert_eq!(got.item_id.as_deref(), Some("abcdef"));
|
||||
assert_eq!(got.score, 78);
|
||||
assert_eq!(got.num_comments, 4);
|
||||
assert_eq!(got.item_url.as_deref(), Some("https://lobste.rs/s/abcdef"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_comments_become_a_tree() {
|
||||
let thread = parse_comments_response(STORY, "abcdef").unwrap();
|
||||
assert_eq!(thread.source, SocialSource::Lobsters);
|
||||
assert_eq!(thread.total_comments, 4);
|
||||
// Two top-level threads; the deleted reply is dropped.
|
||||
assert_eq!(thread.comments.len(), 2);
|
||||
|
||||
let first = &thread.comments[0];
|
||||
assert_eq!(first.author, "bob");
|
||||
assert_eq!(first.points, Some(21));
|
||||
assert_eq!(first.depth, 0);
|
||||
assert_eq!(first.children.len(), 1);
|
||||
assert_eq!(first.children[0].author, "carol");
|
||||
assert_eq!(first.children[0].depth, 1);
|
||||
assert!(first.children[0].text_html.contains("tight too"));
|
||||
|
||||
let second = &thread.comments[1];
|
||||
assert_eq!(second.author, "dave");
|
||||
assert!(second.children.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_json_is_reported_not_panicked() {
|
||||
assert!(matches!(
|
||||
parse_story_response("nope", "abcdef", 1, ts()).unwrap_err(),
|
||||
SocialError::Unexpected {
|
||||
platform: "lobsters",
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
//! Social-proof enrichment (spec §3.4).
|
||||
//!
|
||||
//! For every deduped article, look up HackerNews (Algolia), Lobsters and Reddit
|
||||
//! in parallel behind a semaphore, caching results in the `social` table. Every
|
||||
//! lookup is best-effort: failures never fail the run (notes §3).
|
||||
|
||||
pub mod hn;
|
||||
pub mod lobsters;
|
||||
pub mod reddit;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::StreamExt;
|
||||
use jiff::Timestamp;
|
||||
use tokio::sync::{Mutex, Semaphore};
|
||||
|
||||
use crate::db::Db;
|
||||
use crate::types::{Article, ArticleId, SocialRef, SocialSource, SourceKind};
|
||||
|
||||
/// Concurrent social lookups (§3.4).
|
||||
pub const CONCURRENCY: usize = 8;
|
||||
/// Reddit pacing: roughly one request per second (§3.4).
|
||||
pub const REDDIT_MIN_INTERVAL_MS: u64 = 1000;
|
||||
/// Cached rows younger than this are reused instead of re-fetched (§3.4).
|
||||
pub const CACHE_TTL_HOURS: i64 = 24;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SocialError {
|
||||
#[error("http error: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
#[error("unexpected response from {platform}: {detail}")]
|
||||
Unexpected {
|
||||
platform: &'static str,
|
||||
detail: String,
|
||||
},
|
||||
#[error("rate limited by {0}")]
|
||||
RateLimited(&'static str),
|
||||
}
|
||||
|
||||
/// Serializes a platform's requests to at most one per `interval` (§3.4 Reddit).
|
||||
#[derive(Debug, Clone)]
|
||||
struct Pacer {
|
||||
last: Arc<Mutex<Option<tokio::time::Instant>>>,
|
||||
interval: Duration,
|
||||
}
|
||||
|
||||
impl Pacer {
|
||||
fn new(interval: Duration) -> Self {
|
||||
Self {
|
||||
last: Arc::new(Mutex::new(None)),
|
||||
interval,
|
||||
}
|
||||
}
|
||||
|
||||
/// Block until the caller may issue the next request.
|
||||
async fn tick(&self) {
|
||||
let mut last = self.last.lock().await;
|
||||
if let Some(previous) = *last {
|
||||
let elapsed = previous.elapsed();
|
||||
if elapsed < self.interval {
|
||||
tokio::time::sleep(self.interval - elapsed).await;
|
||||
}
|
||||
}
|
||||
*last = Some(tokio::time::Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// The identifiers one article needs for its three lookups (§3.4).
|
||||
#[derive(Debug, Clone)]
|
||||
struct Lookup {
|
||||
article_id: ArticleId,
|
||||
canonical_url: String,
|
||||
/// HN story id from `comments_url`, when the feed handed us one.
|
||||
hn_story_id: Option<String>,
|
||||
/// Lobsters story id — only available for lobste.rs-originated entries (§7).
|
||||
lobsters_story_id: Option<String>,
|
||||
}
|
||||
|
||||
impl Lookup {
|
||||
fn for_article(article: &Article) -> Self {
|
||||
let comments_url = article.comments_url.as_deref().unwrap_or("");
|
||||
let hn_story_id = hn::story_id_from_comments_url(comments_url);
|
||||
|
||||
// Lobsters linkage requires a lobste.rs origin: either the feed itself or
|
||||
// a comments URL pointing at a story (§3.4, §7).
|
||||
let via_lobsters = article.came_via(SourceKind::Lobsters);
|
||||
let lobsters_story_id = lobsters::story_id_from_url(comments_url).or_else(|| {
|
||||
via_lobsters
|
||||
.then(|| lobsters::story_id_from_url(&article.url))
|
||||
.flatten()
|
||||
});
|
||||
|
||||
Self {
|
||||
article_id: article.id,
|
||||
canonical_url: article.canonical_url.clone(),
|
||||
hn_story_id,
|
||||
lobsters_story_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Orchestrates the per-platform clients and the `social` cache (§3.4).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SocialEnricher {
|
||||
http: reqwest::Client,
|
||||
db: Db,
|
||||
reddit_pacer: Pacer,
|
||||
/// Set once Reddit 429s: the rest of the run skips Reddit entirely (§3.4).
|
||||
reddit_blocked: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl SocialEnricher {
|
||||
pub fn new(http: reqwest::Client, db: Db) -> Self {
|
||||
Self {
|
||||
http,
|
||||
db,
|
||||
reddit_pacer: Pacer::new(Duration::from_millis(REDDIT_MIN_INTERVAL_MS)),
|
||||
reddit_blocked: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enrich every article in place, writing hits to the `social` table (§3.4).
|
||||
///
|
||||
/// Returns the number of articles for which at least one platform had a hit.
|
||||
pub async fn enrich_all(&self, articles: &mut [Article]) -> usize {
|
||||
self.run(articles, false).await
|
||||
}
|
||||
|
||||
/// [`Self::enrich_all`] ignoring the cache — used by `backfill-social` (§2).
|
||||
pub async fn refresh_all(&self, articles: &mut [Article]) -> usize {
|
||||
self.run(articles, true).await
|
||||
}
|
||||
|
||||
async fn run(&self, articles: &mut [Article], force: bool) -> usize {
|
||||
let span = tracing::info_span!("social", articles = articles.len(), force);
|
||||
let _guard = span.enter();
|
||||
|
||||
let lookups: Vec<Lookup> = articles.iter().map(Lookup::for_article).collect();
|
||||
let semaphore = Arc::new(Semaphore::new(CONCURRENCY));
|
||||
let results: Vec<(usize, Vec<SocialRef>)> =
|
||||
futures::stream::iter(lookups.iter().enumerate())
|
||||
.map(|(i, lookup)| {
|
||||
let semaphore = Arc::clone(&semaphore);
|
||||
async move {
|
||||
// A closed semaphore is impossible here; treat it as "no limit".
|
||||
let _permit = semaphore.acquire().await.ok();
|
||||
(i, self.lookup(lookup, force).await)
|
||||
}
|
||||
})
|
||||
.buffer_unordered(CONCURRENCY)
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
let mut hits = 0;
|
||||
for (i, refs) in results {
|
||||
if !refs.is_empty() {
|
||||
hits += 1;
|
||||
}
|
||||
articles[i].social = refs;
|
||||
}
|
||||
tracing::info!(hits, "social enrichment complete");
|
||||
hits
|
||||
}
|
||||
|
||||
/// Look up every platform for one article, honoring the cache TTL (§3.4).
|
||||
pub async fn enrich_one(&self, article: &Article) -> Vec<SocialRef> {
|
||||
self.lookup(&Lookup::for_article(article), false).await
|
||||
}
|
||||
|
||||
async fn lookup(&self, target: &Lookup, force: bool) -> Vec<SocialRef> {
|
||||
let mut refs: Vec<SocialRef> = if force || target.article_id == 0 {
|
||||
Vec::new()
|
||||
} else {
|
||||
cached_refs(&self.db, target.article_id).await
|
||||
};
|
||||
let cached = refs.len();
|
||||
|
||||
if !refs.iter().any(|r| r.source == SocialSource::Hn)
|
||||
&& let Some(found) = self.lookup_hn(target).await
|
||||
{
|
||||
refs.push(found);
|
||||
}
|
||||
if !refs.iter().any(|r| r.source == SocialSource::Lobsters)
|
||||
&& let Some(found) = self.lookup_lobsters(target).await
|
||||
{
|
||||
refs.push(found);
|
||||
}
|
||||
if !refs.iter().any(|r| r.source == SocialSource::Reddit)
|
||||
&& let Some(found) = self.lookup_reddit(target).await
|
||||
{
|
||||
refs.push(found);
|
||||
}
|
||||
|
||||
// Persist only what we just fetched; cached rows are already stored.
|
||||
if target.article_id != 0 {
|
||||
for r in refs.iter().skip(cached) {
|
||||
if let Err(e) = self.db.upsert_social(r).await {
|
||||
tracing::warn!(
|
||||
article = target.article_id,
|
||||
"storing social ref failed: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
refs.sort_by_key(|r| r.source);
|
||||
refs
|
||||
}
|
||||
|
||||
async fn lookup_hn(&self, target: &Lookup) -> Option<SocialRef> {
|
||||
let result = match &target.hn_story_id {
|
||||
Some(id) => hn::fetch_story(&self.http, id, target.article_id).await,
|
||||
None => hn::search_by_url(&self.http, &target.canonical_url, target.article_id).await,
|
||||
};
|
||||
best_effort("hn", &target.canonical_url, result)
|
||||
}
|
||||
|
||||
async fn lookup_lobsters(&self, target: &Lookup) -> Option<SocialRef> {
|
||||
let id = target.lobsters_story_id.as_deref()?;
|
||||
let result = lobsters::fetch_story(&self.http, id, target.article_id).await;
|
||||
best_effort("lobsters", &target.canonical_url, result)
|
||||
}
|
||||
|
||||
async fn lookup_reddit(&self, target: &Lookup) -> Option<SocialRef> {
|
||||
if self.reddit_blocked.load(Ordering::Relaxed) {
|
||||
return None;
|
||||
}
|
||||
self.reddit_pacer.tick().await;
|
||||
let result =
|
||||
reddit::lookup_by_url(&self.http, &target.canonical_url, target.article_id).await;
|
||||
if matches!(result, Err(SocialError::RateLimited(_))) {
|
||||
// Back off for the rest of the run: social data is best-effort (§3.4).
|
||||
self.reddit_blocked.store(true, Ordering::Relaxed);
|
||||
tracing::warn!("reddit rate-limited us; skipping reddit for the rest of this run");
|
||||
return None;
|
||||
}
|
||||
best_effort("reddit", &target.canonical_url, result)
|
||||
}
|
||||
|
||||
/// Re-poll recent articles' social scores (`daily-epub backfill-social`, §2).
|
||||
pub async fn backfill(&self, days: u32) -> anyhow::Result<usize> {
|
||||
let span = tracing::info_span!("backfill_social", days);
|
||||
let _guard = span.enter();
|
||||
|
||||
let cutoff = Timestamp::now() - jiff::Span::new().hours(24 * i64::from(days.max(1)));
|
||||
let rows = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT id FROM articles WHERE first_seen >= ? ORDER BY first_seen DESC",
|
||||
)
|
||||
.bind(crate::db::fmt_ts(cutoff))
|
||||
.fetch_all(self.db.pool())
|
||||
.await?;
|
||||
|
||||
let mut articles: Vec<Article> = Vec::with_capacity(rows.len());
|
||||
for id in rows {
|
||||
match self.db.get_article(id).await {
|
||||
Ok(Some(article)) => articles.push(article),
|
||||
Ok(None) => {}
|
||||
Err(e) => tracing::warn!(article = id, "loading article failed: {e}"),
|
||||
}
|
||||
}
|
||||
tracing::info!(articles = articles.len(), "re-polling social scores");
|
||||
Ok(self.refresh_all(&mut articles).await)
|
||||
}
|
||||
}
|
||||
|
||||
/// Log-and-drop wrapper: no social lookup may ever fail the run (notes §3).
|
||||
fn best_effort(
|
||||
platform: &'static str,
|
||||
url: &str,
|
||||
result: Result<Option<SocialRef>, SocialError>,
|
||||
) -> Option<SocialRef> {
|
||||
match result {
|
||||
Ok(found) => found,
|
||||
Err(e) => {
|
||||
tracing::debug!(platform, url, "social lookup failed: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load cached refs for an article, ignoring rows older than [`CACHE_TTL_HOURS`].
|
||||
pub async fn cached_refs(db: &Db, article_id: ArticleId) -> Vec<SocialRef> {
|
||||
match db.social_for_article(article_id).await {
|
||||
Ok(refs) => {
|
||||
let now = Timestamp::now();
|
||||
refs.into_iter().filter(|r| is_fresh(r, now)).collect()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(article = article_id, "reading social cache failed: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True while a cached row is younger than [`CACHE_TTL_HOURS`] (§3.4).
|
||||
pub fn is_fresh(social_ref: &SocialRef, now: Timestamp) -> bool {
|
||||
// A negative age (clock skew, a row written moments ago) is fresh too.
|
||||
now.as_second() - social_ref.fetched_at.as_second() < CACHE_TTL_HOURS * 3600
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::{ExtractMethod, SourceRef};
|
||||
|
||||
fn ts(s: &str) -> Timestamp {
|
||||
s.parse().unwrap()
|
||||
}
|
||||
|
||||
fn article(url: &str) -> Article {
|
||||
Article {
|
||||
id: 1,
|
||||
canonical_url: url.into(),
|
||||
title: "T".into(),
|
||||
best_entry_id: 1,
|
||||
content_html: "<p>x</p>".into(),
|
||||
word_count: 1,
|
||||
excerpt_only: false,
|
||||
image_count: 0,
|
||||
sources: vec![SourceRef {
|
||||
entry_id: 1,
|
||||
feed_id: 1,
|
||||
feed_title: "Feed".into(),
|
||||
category: None,
|
||||
kind: SourceKind::Feed,
|
||||
}],
|
||||
first_seen: ts("2026-08-15T05:00:00Z"),
|
||||
url: url.into(),
|
||||
author: None,
|
||||
feed_id: 1,
|
||||
feed_title: "Feed".into(),
|
||||
category: None,
|
||||
published_at: None,
|
||||
comments_url: None,
|
||||
image_urls: vec![],
|
||||
social: vec![],
|
||||
extract_method: ExtractMethod::Miniflux,
|
||||
}
|
||||
}
|
||||
|
||||
fn social(source: SocialSource, fetched_at: Timestamp) -> SocialRef {
|
||||
SocialRef {
|
||||
article_id: 1,
|
||||
source,
|
||||
item_id: Some("1".into()),
|
||||
score: 10,
|
||||
num_comments: 5,
|
||||
item_url: None,
|
||||
fetched_at,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_freshness_window() {
|
||||
let now = ts("2026-08-15T12:00:00Z");
|
||||
assert!(is_fresh(&social(SocialSource::Hn, now), now));
|
||||
assert!(is_fresh(
|
||||
&social(SocialSource::Hn, ts("2026-08-14T13:00:00Z")),
|
||||
now
|
||||
));
|
||||
assert!(!is_fresh(
|
||||
&social(SocialSource::Hn, ts("2026-08-14T11:00:00Z")),
|
||||
now
|
||||
));
|
||||
// Clock skew (a row from the future) counts as fresh, never as ancient.
|
||||
assert!(is_fresh(
|
||||
&social(SocialSource::Hn, ts("2026-08-15T13:00:00Z")),
|
||||
now
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_targets_come_from_comments_urls_and_sources() {
|
||||
let mut a = article("https://blog.dev/post");
|
||||
a.comments_url = Some("https://news.ycombinator.com/item?id=41234567".into());
|
||||
let l = Lookup::for_article(&a);
|
||||
assert_eq!(l.hn_story_id.as_deref(), Some("41234567"));
|
||||
assert_eq!(l.lobsters_story_id, None);
|
||||
assert_eq!(l.canonical_url, "https://blog.dev/post");
|
||||
|
||||
a.comments_url = Some("https://lobste.rs/s/abcdef/a_deep_dive".into());
|
||||
let l = Lookup::for_article(&a);
|
||||
assert_eq!(l.hn_story_id, None);
|
||||
assert_eq!(l.lobsters_story_id.as_deref(), Some("abcdef"));
|
||||
|
||||
// No comments URL and no lobsters origin: no lobsters lookup at all (§7).
|
||||
a.comments_url = None;
|
||||
assert_eq!(Lookup::for_article(&a).lobsters_story_id, None);
|
||||
|
||||
// Lobsters-origin entry whose URL is the story itself.
|
||||
a.url = "https://lobste.rs/s/zzzzzz/title".into();
|
||||
a.sources[0].kind = SourceKind::Lobsters;
|
||||
assert_eq!(
|
||||
Lookup::for_article(&a).lobsters_story_id.as_deref(),
|
||||
Some("zzzzzz")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pacer_serializes_requests() {
|
||||
let pacer = Pacer::new(Duration::from_millis(30));
|
||||
let started = std::time::Instant::now();
|
||||
pacer.tick().await;
|
||||
pacer.tick().await;
|
||||
assert!(started.elapsed() >= Duration::from_millis(30));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_reads_skip_stale_rows() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = Db::open_and_migrate(&dir.path().join("t.db"))
|
||||
.await
|
||||
.unwrap();
|
||||
db.upsert_entry(&crate::types::Entry {
|
||||
id: 1,
|
||||
feed_id: 1,
|
||||
feed_title: Some("Feed".into()),
|
||||
category: None,
|
||||
title: "T".into(),
|
||||
url: "https://blog.dev/post".into(),
|
||||
canonical_url: Some("https://blog.dev/post".into()),
|
||||
author: None,
|
||||
published_at: None,
|
||||
comments_url: None,
|
||||
raw_content: "<p>x</p>".into(),
|
||||
fetched_at: Timestamp::now(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let id = db
|
||||
.upsert_article(&article("https://blog.dev/post"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
db.upsert_social(&SocialRef {
|
||||
article_id: id,
|
||||
fetched_at: Timestamp::now(),
|
||||
..social(SocialSource::Hn, Timestamp::now())
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
db.upsert_social(&SocialRef {
|
||||
article_id: id,
|
||||
fetched_at: Timestamp::now() - jiff::Span::new().hours(48),
|
||||
..social(SocialSource::Reddit, Timestamp::now())
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let fresh = cached_refs(&db, id).await;
|
||||
assert_eq!(fresh.len(), 1);
|
||||
assert_eq!(fresh[0].source, SocialSource::Hn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
//! Reddit via the public JSON endpoints (spec §3.4, §3.7).
|
||||
//!
|
||||
//! Requires the descriptive User-Agent from [`crate::http::USER_AGENT`], ~1 req/s
|
||||
//! pacing, and must degrade gracefully on 429 (social data is best-effort).
|
||||
|
||||
use jiff::Timestamp;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::SocialError;
|
||||
use crate::types::{ArticleId, Comment, CommentThread, SocialRef, SocialSource};
|
||||
|
||||
/// `GET /api/info.json?url=…` — finds submissions of a given URL (§3.4).
|
||||
pub const INFO_URL: &str = "https://www.reddit.com/api/info.json";
|
||||
pub const BASE_URL: &str = "https://www.reddit.com";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct Listing {
|
||||
#[serde(default)]
|
||||
data: ListingData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
struct ListingData {
|
||||
#[serde(default)]
|
||||
children: Vec<Child>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct Child {
|
||||
#[serde(default)]
|
||||
kind: String,
|
||||
#[serde(default)]
|
||||
data: serde_json::Value,
|
||||
}
|
||||
|
||||
/// A `t3` submission (only the fields §3.4 uses).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Post {
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub score: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub num_comments: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub permalink: Option<String>,
|
||||
#[serde(default)]
|
||||
pub subreddit: Option<String>,
|
||||
}
|
||||
|
||||
impl Post {
|
||||
/// Absolute link a human can open (§3.4).
|
||||
pub fn item_url(&self) -> Option<String> {
|
||||
self.permalink.as_ref().map(|p| format!("{BASE_URL}{p}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct RawComment {
|
||||
#[serde(default)]
|
||||
author: Option<String>,
|
||||
#[serde(default)]
|
||||
score: Option<i64>,
|
||||
#[serde(default)]
|
||||
body: Option<String>,
|
||||
#[serde(default)]
|
||||
stickied: bool,
|
||||
#[serde(default)]
|
||||
replies: serde_json::Value,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure parsing (unit-tested against `tests/fixtures/reddit_*.json`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Every `t3` submission in an `api/info.json` response.
|
||||
pub fn parse_posts(body: &str) -> Result<Vec<Post>, SocialError> {
|
||||
let listing: Listing = serde_json::from_str(body).map_err(|e| SocialError::Unexpected {
|
||||
platform: "reddit",
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
Ok(listing
|
||||
.data
|
||||
.children
|
||||
.into_iter()
|
||||
.filter(|c| c.kind == "t3")
|
||||
.filter_map(|c| serde_json::from_value::<Post>(c.data).ok())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Best (highest score) submission in an `api/info.json` response (§3.4).
|
||||
pub fn parse_info_response(
|
||||
body: &str,
|
||||
article_id: ArticleId,
|
||||
fetched_at: Timestamp,
|
||||
) -> Result<Option<SocialRef>, SocialError> {
|
||||
let posts = parse_posts(body)?;
|
||||
let Some(best) = posts.into_iter().max_by_key(|p| p.score.unwrap_or(0)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(SocialRef {
|
||||
article_id,
|
||||
source: SocialSource::Reddit,
|
||||
item_id: best.name.clone().or_else(|| best.id.clone()),
|
||||
score: best.score.unwrap_or(0),
|
||||
num_comments: best.num_comments.unwrap_or(0),
|
||||
item_url: best.item_url(),
|
||||
fetched_at,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Parse a `{permalink}.json` body — `[post listing, comment listing]` (§3.7).
|
||||
pub fn parse_comments_response(body: &str, permalink: &str) -> Result<CommentThread, SocialError> {
|
||||
let listings: Vec<Listing> =
|
||||
serde_json::from_str(body).map_err(|e| SocialError::Unexpected {
|
||||
platform: "reddit",
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
let total = listings
|
||||
.first()
|
||||
.and_then(|l| l.data.children.first())
|
||||
.and_then(|c| serde_json::from_value::<Post>(c.data.clone()).ok())
|
||||
.and_then(|p| p.num_comments)
|
||||
.unwrap_or(0);
|
||||
let comments = listings
|
||||
.get(1)
|
||||
.map(|l| map_children(&l.data.children, 0))
|
||||
.unwrap_or_default();
|
||||
Ok(CommentThread {
|
||||
source: SocialSource::Reddit,
|
||||
item_url: format!("{BASE_URL}{permalink}"),
|
||||
total_comments: total,
|
||||
comments,
|
||||
})
|
||||
}
|
||||
|
||||
fn map_children(children: &[Child], depth: usize) -> Vec<Comment> {
|
||||
children
|
||||
.iter()
|
||||
.filter(|c| c.kind == "t1")
|
||||
.filter_map(|c| serde_json::from_value::<RawComment>(c.data.clone()).ok())
|
||||
.filter(|c| !c.stickied)
|
||||
.filter_map(|raw| {
|
||||
let body = raw.body.as_deref().unwrap_or("").trim().to_string();
|
||||
if body.is_empty() || body == "[removed]" || body == "[deleted]" {
|
||||
return None;
|
||||
}
|
||||
let kids = match serde_json::from_value::<Listing>(raw.replies.clone()) {
|
||||
Ok(listing) => map_children(&listing.data.children, depth + 1),
|
||||
// `replies` is `""` when a comment has none.
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
Some(Comment {
|
||||
author: raw.author.clone().unwrap_or_else(|| "[deleted]".into()),
|
||||
points: raw.score,
|
||||
text_html: crate::extract::sanitize(&markdown_to_html(&body)),
|
||||
depth,
|
||||
children: kids,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reddit comment bodies are markdown; the EPUB only needs paragraphs (§3.7).
|
||||
fn markdown_to_html(body: &str) -> String {
|
||||
body.split("\n\n")
|
||||
.map(str::trim)
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| format!("<p>{}</p>", escape_text(p)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn escape_text(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Network
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn get_text(
|
||||
http: &reqwest::Client,
|
||||
url: &str,
|
||||
query: &[(&str, &str)],
|
||||
) -> Result<String, SocialError> {
|
||||
let response = http
|
||||
.get(url)
|
||||
.header(reqwest::header::USER_AGENT, crate::http::USER_AGENT)
|
||||
.query(query)
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
if status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.as_u16() == 403 {
|
||||
return Err(SocialError::RateLimited("reddit"));
|
||||
}
|
||||
Ok(response.error_for_status()?.text().await?)
|
||||
}
|
||||
|
||||
/// Best (highest score) Reddit post for `canonical_url` (§3.4).
|
||||
///
|
||||
/// Returns `None` when no submission exists or the API rate-limited us.
|
||||
pub async fn lookup_by_url(
|
||||
http: &reqwest::Client,
|
||||
canonical_url: &str,
|
||||
article_id: ArticleId,
|
||||
) -> Result<Option<SocialRef>, SocialError> {
|
||||
let body = get_text(http, INFO_URL, &[("url", canonical_url), ("raw_json", "1")]).await?;
|
||||
parse_info_response(&body, article_id, Timestamp::now())
|
||||
}
|
||||
|
||||
/// `GET {permalink}.json?limit=100&depth=3&sort=top` → comment tree (§3.7).
|
||||
pub async fn fetch_comments(
|
||||
http: &reqwest::Client,
|
||||
permalink: &str,
|
||||
) -> Result<CommentThread, SocialError> {
|
||||
let path = permalink.trim_end_matches('/');
|
||||
let url = format!("{BASE_URL}{path}.json");
|
||||
let body = get_text(
|
||||
http,
|
||||
&url,
|
||||
&[
|
||||
("limit", "100"),
|
||||
("depth", "3"),
|
||||
("sort", "top"),
|
||||
("raw_json", "1"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
parse_comments_response(&body, permalink)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const INFO: &str = include_str!("../../tests/fixtures/m2_reddit_info.json");
|
||||
const INFO_EMPTY: &str = include_str!("../../tests/fixtures/m2_reddit_info_empty.json");
|
||||
const COMMENTS: &str = include_str!("../../tests/fixtures/m2_reddit_comments.json");
|
||||
|
||||
fn ts() -> Timestamp {
|
||||
"2026-08-15T05:30:00Z".parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_response_picks_the_highest_scoring_post() {
|
||||
let got = parse_info_response(INFO, 11, ts())
|
||||
.unwrap()
|
||||
.expect("a post");
|
||||
assert_eq!(got.article_id, 11);
|
||||
assert_eq!(got.source, SocialSource::Reddit);
|
||||
assert_eq!(got.item_id.as_deref(), Some("t3_1abcd2"));
|
||||
assert_eq!(got.score, 845);
|
||||
assert_eq!(got.num_comments, 231);
|
||||
assert_eq!(
|
||||
got.item_url.as_deref(),
|
||||
Some("https://www.reddit.com/r/programming/comments/1abcd2/a_deep_dive_into_btrees/")
|
||||
);
|
||||
assert_eq!(parse_posts(INFO).unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_info_response_is_not_an_error() {
|
||||
assert!(parse_info_response(INFO_EMPTY, 1, ts()).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_json_is_reported_not_panicked() {
|
||||
assert!(matches!(
|
||||
parse_info_response("<html>rate limited</html>", 1, ts()).unwrap_err(),
|
||||
SocialError::Unexpected {
|
||||
platform: "reddit",
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_listing_becomes_a_tree() {
|
||||
let thread =
|
||||
parse_comments_response(COMMENTS, "/r/programming/comments/1abcd2/x/").unwrap();
|
||||
assert_eq!(thread.source, SocialSource::Reddit);
|
||||
assert_eq!(thread.total_comments, 231);
|
||||
assert_eq!(
|
||||
thread.item_url,
|
||||
"https://www.reddit.com/r/programming/comments/1abcd2/x/"
|
||||
);
|
||||
// `more` stubs, the stickied automod post and the removed comment are dropped.
|
||||
assert_eq!(thread.comments.len(), 1);
|
||||
|
||||
let top = &thread.comments[0];
|
||||
assert_eq!(top.author, "index_nerd");
|
||||
assert_eq!(top.points, Some(412));
|
||||
assert_eq!(top.depth, 0);
|
||||
assert!(
|
||||
top.text_html
|
||||
.starts_with("<p>Fan-out is the whole ballgame")
|
||||
);
|
||||
assert_eq!(top.children.len(), 1);
|
||||
assert_eq!(top.children[0].author, "pagecache");
|
||||
assert_eq!(top.children[0].depth, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_bodies_are_escaped_into_paragraphs() {
|
||||
let html = markdown_to_html("first & <b>bold</b>\n\nsecond");
|
||||
assert_eq!(
|
||||
html,
|
||||
"<p>first & <b>bold</b></p><p>second</p>"
|
||||
);
|
||||
assert!(!crate::extract::sanitize(&html).contains("<b>"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user