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:
2026-08-15 17:46:19 +00:00
commit 9e30c1dcdf
80 changed files with 27578 additions and 0 deletions
+366
View File
@@ -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");
}
}
+308
View File
@@ -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",
..
}
));
}
}
+455
View File
@@ -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);
}
}
+322
View File
@@ -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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
// ---------------------------------------------------------------------------
// 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 &amp; &lt;b&gt;bold&lt;/b&gt;</p><p>second</p>"
);
assert!(!crate::extract::sanitize(&html).contains("<b>"));
}
}