Files
the-daily-epub/src/imports.rs
T
thalladaandClaude Fable 5.1 f71a4c2799 Keep the publication an article's page declares
Readability already reads og:site_name and JSON-LD publisher.name; carry
that through Page and Extracted and store it in articles.publication, the
same way the page byline is kept. `publication_label` is what the readers
will see after the feed name: the site name, else the domain, and nothing
when it would only repeat the feed's own title.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWmCpUojfHXhSZ2129Z7Nv
2026-09-09 05:48:46 +00:00

560 lines
17 KiB
Rust

//! Historical rating imports queued from the dashboard.
use anyhow::{Context, Result};
use jiff::Timestamp;
use sqlx::Row as _;
use crate::config::Config;
use crate::curate::embedding::EmbeddingService;
use crate::db::{Db, fmt_ts};
use crate::extract::{Extractor, Page};
use crate::types::{Article, ExtractMethod, Extracted, Vote};
const BATCH_SIZE: i64 = 20;
/// One row in the historical rating import queue.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportRow {
pub id: i64,
pub url: String,
pub label: String,
pub note: Option<String>,
pub status: String,
pub message: Option<String>,
pub article_id: Option<i64>,
pub requested_by: Option<i64>,
pub requested_at: String,
pub finished_at: Option<String>,
}
fn row_from(row: &sqlx::sqlite::SqliteRow) -> ImportRow {
ImportRow {
id: row.get("id"),
url: row.get("url"),
label: row.get("label"),
note: row.get("note"),
status: row.get("status"),
message: row.get("message"),
article_id: row.get("article_id"),
requested_by: row.get("requested_by"),
requested_at: row.get("requested_at"),
finished_at: row.get("finished_at"),
}
}
/// Insert one pending import row per URL and return how many were queued.
pub async fn queue(
db: &Db,
urls: &[String],
label: &str,
note: Option<&str>,
requested_by: Option<i64>,
now: Timestamp,
) -> Result<usize, sqlx::Error> {
let mut tx = db.pool().begin().await?;
let requested_at = fmt_ts(now);
for url in urls {
sqlx::query(
"INSERT INTO rating_imports
(url, label, note, status, requested_by, requested_at)
VALUES (?, ?, ?, 'pending', ?, ?)",
)
.bind(url)
.bind(label)
.bind(note)
.bind(requested_by)
.bind(&requested_at)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(urls.len())
}
/// Newest import rows for the ratings dashboard.
pub async fn recent(db: &Db, limit: i64) -> Result<Vec<ImportRow>, sqlx::Error> {
let rows = sqlx::query(
"SELECT id, url, label, note, status, message, article_id, requested_by,
requested_at, finished_at
FROM rating_imports ORDER BY id DESC LIMIT ?",
)
.bind(limit)
.fetch_all(db.pool())
.await?;
Ok(rows.iter().map(row_from).collect())
}
async fn pending(db: &Db) -> Result<Vec<ImportRow>, sqlx::Error> {
let rows = sqlx::query(
"SELECT id, url, label, note, status, message, article_id, requested_by,
requested_at, finished_at
FROM rating_imports WHERE status = 'pending' ORDER BY id LIMIT ?",
)
.bind(BATCH_SIZE)
.fetch_all(db.pool())
.await?;
Ok(rows.iter().map(row_from).collect())
}
async fn finish(
db: &Db,
id: i64,
status: &str,
message: &str,
article_id: Option<i64>,
) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE rating_imports
SET status = ?, message = ?, article_id = ?, finished_at = ? WHERE id = ?",
)
.bind(status)
.bind(short_message(message))
.bind(article_id)
.bind(fmt_ts(Timestamp::now()))
.bind(id)
.execute(db.pool())
.await?;
Ok(())
}
fn short_message(message: &str) -> String {
message
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.chars()
.take(500)
.collect()
}
fn vote(label: &str) -> Result<Vote> {
match label {
"loved" => Ok(Vote::Loved),
"good" => Ok(Vote::Good),
"not_for_me" => Ok(Vote::NotForMe),
"slop" => Ok(Vote::Slop),
other => anyhow::bail!("invalid rating label {other:?}"),
}
}
struct Processed {
article_id: i64,
created: bool,
embedded: bool,
embedding_note: Option<String>,
}
fn imported_article(
canonical_url: String,
page: Page,
extracted: Extracted,
first_seen: Timestamp,
) -> Article {
Article {
id: 0,
canonical_url,
title: if page.title.trim().is_empty() {
page.final_url.clone()
} else {
page.title
},
best_entry_id: 0,
content_html: extracted.content_html,
word_count: extracted.word_count,
excerpt_only: extracted.excerpt_only,
image_count: extracted.image_urls.len() as i64,
sources: Vec::new(),
first_seen,
url: page.final_url,
author: extracted.author,
publication: extracted.publication,
feed_id: 0,
feed_title: "Imported".into(),
category: None,
published_at: None,
comments_url: None,
image_urls: extracted.image_urls,
social: Vec::new(),
extract_method: ExtractMethod::Readability,
}
}
async fn process(
config: &Config,
db: &Db,
extractor: &Extractor,
embedding: Option<&EmbeddingService>,
embedding_unavailable: Option<&str>,
row: &ImportRow,
) -> Result<Processed> {
let canonical = crate::dedupe::canonical_url(&row.url)
.with_context(|| format!("invalid URL {:?}", row.url))?;
let (article, created) = match db.article_id_for_url(&canonical).await? {
Some(id) => (
db.get_article(id)
.await?
.with_context(|| format!("article {id} disappeared"))?,
false,
),
None => {
let page = extractor
.fetch_readable(&canonical)
.await
.with_context(|| format!("fetching {canonical}"))?;
let extracted = extractor.finish_readable(&canonical, &page);
let mut article = imported_article(canonical, page, extracted, Timestamp::now());
article.id = db.upsert_article(&article).await?;
(article, true)
}
};
let mut embedded = false;
let mut embedding_note = embedding_unavailable.map(str::to_string);
if let Some(service) = embedding {
match service.articles(std::slice::from_ref(&article)).await {
Ok(vectors) if vectors.contains_key(&article.id) => embedded = true,
Ok(_) => {
tracing::warn!(url = %row.url, article_id = article.id, "Voyage returned no embedding for rating import");
embedding_note = Some("Voyage returned no embedding".into());
}
Err(error) => {
tracing::warn!(url = %row.url, article_id = article.id, %error, "could not embed rating import");
embedding_note = Some(error.to_string());
}
}
} else if !config.voyage.enabled {
embedding_note = Some("Voyage is disabled".into());
}
crate::rate::record_explicit(
config,
db,
article.id,
Some(vote(&row.label)?),
"import",
row.requested_by,
row.note.clone(),
)
.await?;
Ok(Processed {
article_id: article.id,
created,
embedded,
embedding_note,
})
}
/// Fetch, embed and rate all pending historical imports.
pub async fn run(config: &Config, db: &Db) -> Result<String> {
let http = crate::http::build_client(crate::http::DEFAULT_TIMEOUT)
.context("building article HTTP client")?;
let extractor = Extractor::new(http, config.curation.paywall_domains.clone());
let (embedding, embedding_unavailable) = if config.voyage.enabled {
match EmbeddingService::real(db.clone(), config.voyage.clone()) {
Ok(service) => (Some(service), None),
Err(error) => {
tracing::warn!(%error, "Voyage embeddings unavailable for rating imports");
(None, Some(error.to_string()))
}
}
} else {
(None, None)
};
run_with(
config,
db,
&extractor,
embedding.as_ref(),
embedding_unavailable.as_deref(),
)
.await
}
async fn run_with(
config: &Config,
db: &Db,
extractor: &Extractor,
embedding: Option<&EmbeddingService>,
embedding_unavailable: Option<&str>,
) -> Result<String> {
let mut imported = 0usize;
let mut created = 0usize;
let mut existing = 0usize;
let mut failed = 0usize;
loop {
let rows = pending(db).await?;
if rows.is_empty() {
break;
}
for row in rows {
match process(
config,
db,
extractor,
embedding,
embedding_unavailable,
&row,
)
.await
{
Ok(done) => {
imported += 1;
if done.created {
created += 1;
} else {
existing += 1;
}
let mut message = if done.created {
"created".to_string()
} else {
"existing article".to_string()
};
if done.embedded {
message.push_str(" + embedded");
} else if let Some(note) = done.embedding_note {
message.push_str(&format!("; no embedding ({note})"));
}
finish(db, row.id, "ok", &message, Some(done.article_id)).await?;
tracing::info!(url = %row.url, article_id = done.article_id, %message, "rating import ok");
}
Err(error) => {
failed += 1;
let message = format!("{error:#}");
finish(db, row.id, "failed", &message, None).await?;
tracing::info!(url = %row.url, %message, "rating import failed");
}
}
}
}
if imported == 0 && failed == 0 {
Ok("no pending rating imports".into())
} else {
Ok(format!(
"{imported} imported ({created} new, {existing} existing), {failed} failed"
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Entry;
async fn test_db() -> (tempfile::TempDir, Db) {
let dir = tempfile::tempdir().unwrap();
let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
.await
.unwrap();
(dir, db)
}
async fn existing_article(db: &Db, url: &str) -> i64 {
let now: Timestamp = "2026-09-01T00:00:00Z".parse().unwrap();
db.upsert_entry(&Entry {
id: 42,
feed_id: 7,
feed_title: Some("Feed".into()),
category: None,
title: "Existing".into(),
url: url.into(),
canonical_url: Some(url.into()),
author: None,
published_at: None,
comments_url: None,
raw_content: "<p>Existing body</p>".into(),
fetched_at: now,
})
.await
.unwrap();
db.upsert_article(&Article {
id: 0,
canonical_url: url.into(),
title: "Existing".into(),
best_entry_id: 42,
content_html: "<p>Existing body</p>".into(),
word_count: 2,
excerpt_only: false,
image_count: 0,
sources: Vec::new(),
first_seen: now,
url: url.into(),
author: None,
publication: None,
feed_id: 7,
feed_title: "Feed".into(),
category: None,
published_at: None,
comments_url: None,
image_urls: Vec::new(),
social: Vec::new(),
extract_method: ExtractMethod::Miniflux,
})
.await
.unwrap()
}
fn config_without_voyage() -> Config {
let mut config = Config::default();
config.voyage.enabled = false;
config
}
#[tokio::test]
async fn existing_article_is_reused_and_rated() {
let (_dir, db) = test_db().await;
let id = existing_article(&db, "https://example.com/post").await;
let user = crate::web::users::add(&db, "admin", "correct horse battery", true)
.await
.unwrap();
queue(
&db,
&["https://EXAMPLE.com/post/?utm_source=test".into()],
"good",
Some("still useful"),
Some(user.id),
Timestamp::now(),
)
.await
.unwrap();
let summary = run_with(
&config_without_voyage(),
&db,
&Extractor::offline(Vec::new()),
None,
None,
)
.await
.unwrap();
assert_eq!(summary, "1 imported (0 new, 1 existing), 0 failed");
assert_eq!(db.count_entries().await.unwrap(), 1);
let current = db.current_ratings(36500).await.unwrap();
assert_eq!(current.len(), 1);
assert_eq!(current[0].article_id, id);
assert_eq!(current[0].user_id, Some(user.id));
assert_eq!(current[0].label, "good");
assert_eq!(current[0].note.as_deref(), Some("still useful"));
let source: String =
sqlx::query_scalar("SELECT source FROM rating_events ORDER BY id DESC LIMIT 1")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(source, "import");
let row = recent(&db, 1).await.unwrap().remove(0);
assert_eq!(row.status, "ok");
assert_eq!(row.article_id, Some(id));
assert!(row.message.unwrap().contains("existing article"));
}
#[tokio::test]
async fn invalid_url_fails_and_empty_run_is_benign() {
let (_dir, db) = test_db().await;
queue(
&db,
&["not-a-url".into()],
"loved",
None,
None,
Timestamp::now(),
)
.await
.unwrap();
let config = config_without_voyage();
let extractor = Extractor::offline(Vec::new());
assert_eq!(
run_with(&config, &db, &extractor, None, None)
.await
.unwrap(),
"0 imported (0 new, 0 existing), 1 failed"
);
let row = recent(&db, 1).await.unwrap().remove(0);
assert_eq!(row.status, "failed");
assert!(row.message.unwrap().contains("invalid URL"));
assert_eq!(
run_with(&config, &db, &extractor, None, None)
.await
.unwrap(),
"no pending rating imports"
);
}
#[tokio::test]
async fn fetched_page_becomes_an_entryless_sanitized_article() {
let (_dir, db) = test_db().await;
let extractor = Extractor::offline(Vec::new());
let page = Page {
title: "A historical essay".into(),
html: "<article><p>Useful old writing.</p><script>bad()</script><img src=\"/chart.png\"></article>".into(),
author: Some("Essay Writer".into()),
site_name: Some("Example Review".into()),
final_url: "https://example.com/essays/old".into(),
};
let extracted = extractor.finish_readable("https://example.com/old", &page);
let mut article = imported_article(
"https://example.com/old".into(),
page,
extracted,
"2026-09-06T00:00:00Z".parse().unwrap(),
);
assert_eq!(article.title, "A historical essay");
assert_eq!(article.best_entry_id, 0);
assert_eq!(article.feed_title, "Imported");
assert_eq!(article.author.as_deref(), Some("Essay Writer"));
assert_eq!(article.publication.as_deref(), Some("Example Review"));
assert!(article.sources.is_empty());
assert!(!article.content_html.contains("script"));
assert_eq!(article.image_urls, ["https://example.com/chart.png"]);
article.id = db.upsert_article(&article).await.unwrap();
let loaded = db.get_article(article.id).await.unwrap().unwrap();
assert_eq!(loaded.title, "A historical essay");
assert_eq!(loaded.best_entry_id, 0);
assert_eq!(loaded.feed_title, "Imported");
assert_eq!(loaded.author.as_deref(), Some("Essay Writer"));
assert_eq!(loaded.publication.as_deref(), Some("Example Review"));
}
#[tokio::test]
async fn rows_queued_while_running_are_picked_up_in_the_next_batch() {
let (_dir, db) = test_db().await;
existing_article(&db, "https://example.com/post").await;
queue(
&db,
&["https://example.com/post".into()],
"loved",
None,
None,
Timestamp::now(),
)
.await
.unwrap();
sqlx::query(
"CREATE TRIGGER queue_during_import AFTER UPDATE OF status ON rating_imports
WHEN OLD.status = 'pending' AND NEW.status = 'ok' AND NEW.id = 1
BEGIN
INSERT INTO rating_imports
(url, label, status, requested_at)
VALUES ('https://example.com/post', 'good', 'pending', '2026-09-01T00:00:00Z');
END",
)
.execute(db.pool())
.await
.unwrap();
let summary = run_with(
&config_without_voyage(),
&db,
&Extractor::offline(Vec::new()),
None,
None,
)
.await
.unwrap();
assert_eq!(summary, "2 imported (0 new, 2 existing), 0 failed");
assert_eq!(recent(&db, 10).await.unwrap().len(), 2);
let ratings: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM rating_events")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(ratings, 2);
}
}