Compare commits

..
3 Commits
Author SHA1 Message Date
thallada 31d2690701 Merge branch 'index-author' 2026-09-09 18:12:38 +00:00
thalladaandClaude Fable 5.1 1a009a2113 Name the author ahead of the feed on issue index lines
Every list of articles now reads "Author · Feed · Publication · N min":
the signed-in issue page, the public issue page, the RSS feed and the
EPUB's In This Issue page. `author_label` drops an author that is empty
or only repeats the feed's title, as a dozen personal blogs do. Article
pages keep their own byline.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VXYGBPoHZSDSfE5WcJ9bvj
2026-09-09 18:12:34 +00:00
thalladaandClaude Fable 5.1 c538afa540 Unwrap Scour's /r/rss/ redirector when canonicalizing URLs
Scour interest feeds link every entry through
https://scour.ing/r/rss/<percent-encoded article URL>, so the wrapper
became the article's canonical URL: the same post arriving via Hacker
News and Scour never merged, and the publication fallback showed
"scour.ing" instead of the site's own domain. Resolve the wrapper like
the Google News redirector. `publication_label` re-canonicalizes the
stored URL so the 477 articles saved with the wrapper show the real
domain too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VXYGBPoHZSDSfE5WcJ9bvj
2026-09-09 18:12:30 +00:00
9 changed files with 90 additions and 6 deletions

No files matched your search

Generated
+1
View File
@@ -905,6 +905,7 @@ dependencies = [
"lettre", "lettre",
"libc", "libc",
"password-auth", "password-auth",
"percent-encoding",
"rand 0.10.2", "rand 0.10.2",
"reqwest", "reqwest",
"resvg", "resvg",
+1
View File
@@ -23,6 +23,7 @@ jiff = { version = "0.2.35", features = ["serde"] }
lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-rustls-tls", "hostname"] } lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-rustls-tls", "hostname"] }
libc = "0.2.189" libc = "0.2.189"
password-auth = "1.0.0" password-auth = "1.0.0"
percent-encoding = "2.3.2"
rand = "0.10.2" rand = "0.10.2"
reqwest = { version = "0.13.4", default-features = false, features = [ reqwest = { version = "0.13.4", default-features = false, features = [
"rustls", "rustls",
+26
View File
@@ -6,6 +6,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use jiff::Timestamp; use jiff::Timestamp;
use percent_encoding::percent_decode_str;
use url::Url; use url::Url;
use crate::types::{Article, Entry, ExtractMethod, SourceKind, SourceRef}; use crate::types::{Article, Entry, ExtractMethod, SourceKind, SourceRef};
@@ -109,6 +110,16 @@ fn is_tracking_param(key: &str) -> bool {
/// The real destination behind a known redirector, if any (§3.2). /// The real destination behind a known redirector, if any (§3.2).
fn redirect_target(url: &Url) -> Option<String> { fn redirect_target(url: &Url) -> Option<String> {
let host = url.host_str()?.to_ascii_lowercase(); let host = url.host_str()?.to_ascii_lowercase();
if matches!(host.as_str(), "scour.ing" | "www.scour.ing")
&& let Some(encoded) = url.path().strip_prefix("/r/rss/")
{
let target = percent_decode_str(encoded).decode_utf8().ok()?.into_owned();
let target_url = Url::parse(&target).ok()?;
if matches!(target_url.scheme(), "http" | "https") && target_url.host_str().is_some() {
return Some(target);
}
return None;
}
let is_google_news = host == "news.google.com" || host.ends_with(".news.google.com"); let is_google_news = host == "news.google.com" || host.ends_with(".news.google.com");
let is_google_redirect = host == "news.url.google.com" let is_google_redirect = host == "news.url.google.com"
|| ((host == "www.google.com" || host == "google.com") && url.path() == "/url"); || ((host == "www.google.com" || host == "google.com") && url.path() == "/url");
@@ -490,6 +501,21 @@ mod tests {
"https://www.google.com/url?q=https://example.com/real&sa=D", "https://www.google.com/url?q=https://example.com/real&sa=D",
Some("https://example.com/real"), Some("https://example.com/real"),
), ),
// Scour RSS redirectors encode the real URL in the path
(
"https://scour.ing/r/rss/https%3A%2F%2Frmzlb.github.io%2Fnotifyd%2Farticles%2Fpostgres-queue-what-skip-locked-does-not-give-you.html",
Some(
"https://rmzlb.github.io/notifyd/articles/postgres-queue-what-skip-locked-does-not-give-you.html",
),
),
(
"https://www.scour.ing/r/rss/https%3A%2F%2Fexample.com%2Fpost%3Fid%3D7%26utm_source%3Dscour%26utm_medium%3Drss",
Some("https://example.com/post?id=7"),
),
(
"https://scour.ing/@tyler/interests/Kernel%20Development",
Some("https://scour.ing/@tyler/interests/Kernel%20Development"),
),
// non-http schemes and junk // non-http schemes and junk
("mailto:tyler@hallada.net", None), ("mailto:tyler@hallada.net", None),
("ftp://example.com/file", None), ("ftp://example.com/file", None),
+12 -2
View File
@@ -370,6 +370,14 @@ fn source_line(article: &Article) -> String {
} }
} }
/// The index's meta lead: the author, when known, ahead of [`source_line`].
fn index_source_line(article: &Article) -> String {
match article.author_label() {
Some(author) => format!("{author} · {}", source_line(article)),
None => source_line(article),
}
}
/// "In This Issue": per section, each article's title, source, reading time and /// "In This Issue": per section, each article's title, source, reading time and
/// summary, linked to its chapter (§3.10). /// summary, linked to its chapter (§3.10).
pub fn render_in_this_issue(issue: &Issue) -> Result<Chapter, EpubError> { pub fn render_in_this_issue(issue: &Issue) -> Result<Chapter, EpubError> {
@@ -382,7 +390,7 @@ pub fn render_in_this_issue(issue: &Issue) -> Result<Chapter, EpubError> {
.map(|pick| IndexEntry { .map(|pick| IndexEntry {
href: article_href(pick), href: article_href(pick),
title: pick.article.title.clone(), title: pick.article.title.clone(),
source: source_line(&pick.article), source: index_source_line(&pick.article),
reading_minutes: pick.article.reading_minutes(), reading_minutes: pick.article.reading_minutes(),
summary: summary_for(issue, pick).unwrap_or_default().to_string(), summary: summary_for(issue, pick).unwrap_or_default().to_string(),
why: pick.why.clone(), why: pick.why.clone(),
@@ -891,14 +899,16 @@ mod tests {
let mut issue = issue(); let mut issue = issue();
issue.lineup.picks[0].article.publication = Some("Example Journal".into()); issue.lineup.picks[0].article.publication = Some("Example Journal".into());
issue.lineup.picks[1].article.publication = Some("Example Feed".into()); issue.lineup.picks[1].article.publication = Some("Example Feed".into());
issue.lineup.picks[1].article.author = Some("example feed".into());
let index = render_in_this_issue(&issue).unwrap(); let index = render_in_this_issue(&issue).unwrap();
assert!( assert!(
index index
.xhtml .xhtml
.contains("Example Feed · Example Journal &#183; 6 min read") .contains("A. Writer · Example Feed · Example Journal &#183; 6 min read")
); );
assert!(!index.xhtml.contains("Example Feed · Example Feed")); assert!(!index.xhtml.contains("Example Feed · Example Feed"));
assert!(!index.xhtml.contains("example feed · Example Feed"));
let article = render_article( let article = render_article(
&issue, &issue,
+36 -1
View File
@@ -160,13 +160,27 @@ impl Article {
let publication = self let publication = self
.publication .publication
.clone() .clone()
.or_else(|| domain(&self.canonical_url))?; // Covers rows stored before Scour wrappers were unwrapped.
.or_else(|| {
domain(
crate::dedupe::canonical_url(&self.canonical_url)
.as_deref()
.unwrap_or(&self.canonical_url),
)
})?;
(!publication (!publication
.trim() .trim()
.eq_ignore_ascii_case(self.feed_title.trim())) .eq_ignore_ascii_case(self.feed_title.trim()))
.then_some(publication) .then_some(publication)
} }
/// The trimmed author, unless empty or identical to the feed name.
pub fn author_label(&self) -> Option<String> {
let author = self.author.as_deref()?.trim();
(!author.is_empty() && !author.eq_ignore_ascii_case(self.feed_title.trim()))
.then(|| author.to_string())
}
/// Stable EPUB chapter id used by TOC and rating links (implementation notes §12). /// Stable EPUB chapter id used by TOC and rating links (implementation notes §12).
pub fn chapter_id(&self) -> String { pub fn chapter_id(&self) -> String {
format!("art-{}", self.best_entry_id) format!("art-{}", self.best_entry_id)
@@ -914,6 +928,14 @@ mod tests {
assert_eq!(article.publication_label().as_deref(), Some("example.com")); assert_eq!(article.publication_label().as_deref(), Some("example.com"));
} }
#[test]
fn publication_label_unwraps_stored_scour_urls() {
let mut article = publication_article();
article.canonical_url =
"https://scour.ing/r/rss/https%3A%2F%2Fwww.example.com%2Fpost".into();
assert_eq!(article.publication_label().as_deref(), Some("example.com"));
}
#[test] #[test]
fn publication_label_omits_a_publication_matching_the_feed() { fn publication_label_omits_a_publication_matching_the_feed() {
let mut article = publication_article(); let mut article = publication_article();
@@ -929,6 +951,19 @@ mod tests {
assert_eq!(article.publication_label(), None); assert_eq!(article.publication_label(), None);
} }
#[test]
fn author_label_trims_and_omits_empty_or_matching_authors() {
let mut article = publication_article();
article.author = Some(" Jane Doe ".into());
assert_eq!(article.author_label().as_deref(), Some("Jane Doe"));
article.author = Some(" ".into());
assert_eq!(article.author_label(), None);
article.author = Some(" a FEED ".into());
assert_eq!(article.author_label(), None);
}
#[test] #[test]
fn composite_social_score_matches_spec_formula() { fn composite_social_score_matches_spec_formula() {
let refs = vec![ let refs = vec![
+11
View File
@@ -876,6 +876,7 @@ struct FullEntry {
title: String, title: String,
href: String, href: String,
dashboard_href: String, dashboard_href: String,
author: Option<String>,
source: Source, source: Source,
reading_minutes: i64, reading_minutes: i64,
is_lead: bool, is_lead: bool,
@@ -1021,6 +1022,7 @@ pub async fn render_full(
title: pick.article.title.clone(), title: pick.article.title.clone(),
href: article_href(date, pick.article.id), href: article_href(date, pick.article.id),
dashboard_href: format!("/dashboard/articles/{}", pick.article.id), dashboard_href: format!("/dashboard/articles/{}", pick.article.id),
author: pick.article.author_label(),
source: Source::new(&pick.article, &config, is_admin), source: Source::new(&pick.article, &config, is_admin),
reading_minutes: pick.article.reading_minutes(), reading_minutes: pick.article.reading_minutes(),
is_lead: pick.is_lead, is_lead: pick.is_lead,
@@ -1946,6 +1948,13 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn signed_in_full_issue_article_world_and_behind_render_private_content() { async fn signed_in_full_issue_article_world_and_behind_render_private_content() {
let (_dir, db, source) = seeded_issue(true).await; let (_dir, db, source) = seeded_issue(true).await;
let matching_author = &source.lineup.picks[1].article;
sqlx::query("UPDATE articles SET author = ? WHERE id = ?")
.bind(format!(" {} ", matching_author.feed_title.to_lowercase()))
.bind(matching_author.id)
.execute(db.pool())
.await
.unwrap();
crate::web::users::add(&db, "reader", "correct horse battery", false) crate::web::users::add(&db, "reader", "correct horse battery", false)
.await .await
.unwrap(); .unwrap();
@@ -1993,6 +2002,8 @@ mod tests {
assert!(issue.contains("A short abstract for the second piece")); assert!(issue.contains("A short abstract for the second piece"));
assert!(issue.contains("Why it")); assert!(issue.contains("Why it"));
assert!(issue.contains("Interests: Filesystems, Rust")); assert!(issue.contains("Interests: Filesystems, Rust"));
assert!(issue.contains("A. Writer · Example Feed"));
assert!(!issue.contains("example feed · Example Feed"));
assert!(issue.contains("World Briefing")); assert!(issue.contains("World Briefing"));
assert!(issue.contains("Behind the paper")); assert!(issue.contains("Behind the paper"));
assert!(!issue.contains("Was this a good pick?")); assert!(!issue.contains("Was this a good pick?"));
+1 -1
View File
@@ -114,7 +114,7 @@ impl From<&Issue> for PublicIssue {
PublicEntry { PublicEntry {
title: article.title.clone(), title: article.title.clone(),
url: article.canonical_url.clone(), url: article.canonical_url.clone(),
author: article.author.clone(), author: article.author_label(),
source: article.feed_title.clone(), source: article.feed_title.clone(),
publication: article.publication_label(), publication: article.publication_label(),
reading_minutes: article.reading_minutes(), reading_minutes: article.reading_minutes(),
+1 -1
View File
@@ -1 +1 @@
{% for section in issue.sections %}<h2>{{ section.name }}</h2><ul>{% for entry in section.entries %}<li><a href="{{ entry.url }}">{{ entry.title }}</a> — {{ entry.source }}{% match entry.publication %}{% when Some with (publication) %} · {{ publication }}{% when None %}{% endmatch %}{% match entry.summary %}{% when Some with (summary) %}<p class="summary">{{ summary }}</p>{% when None %}{% endmatch %}{% match entry.why %}{% when Some with (why) %}<p class="why"><em>Why it's here: {{ why }}</em></p>{% when None %}{% endmatch %}{% if !entry.comment_links.is_empty() %} · {% for link in entry.comment_links %}<a href="{{ link.url }}">{{ link.label }}</a>{% endfor %}{% endif %}</li>{% endfor %}</ul>{% endfor %} {% for section in issue.sections %}<h2>{{ section.name }}</h2><ul>{% for entry in section.entries %}<li><a href="{{ entry.url }}">{{ entry.title }}</a> — {% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }}{% match entry.publication %}{% when Some with (publication) %} · {{ publication }}{% when None %}{% endmatch %}{% match entry.summary %}{% when Some with (summary) %}<p class="summary">{{ summary }}</p>{% when None %}{% endmatch %}{% match entry.why %}{% when Some with (why) %}<p class="why"><em>Why it's here: {{ why }}</em></p>{% when None %}{% endmatch %}{% if !entry.comment_links.is_empty() %} · {% for link in entry.comment_links %}<a href="{{ link.url }}">{{ link.label }}</a>{% endfor %}{% endif %}</li>{% endfor %}</ul>{% endfor %}
+1 -1
View File
@@ -5,7 +5,7 @@
<section aria-labelledby="brief-heading" data-toc-entry="/issues/{{ date }}"><h1 id="brief-heading" class="reader-section-heading">The Brief</h1><div class="editorial prose-body mt-5 [&>p:first-child]:first-letter:float-left [&>p:first-child]:first-letter:mr-2 [&>p:first-child]:first-letter:mt-1 [&>p:first-child]:first-letter:font-serif [&>p:first-child]:first-letter:text-[4.6rem] [&>p:first-child]:first-letter:font-semibold [&>p:first-child]:first-letter:leading-[0.72]">{{ front_page_html|safe }}</div></section> <section aria-labelledby="brief-heading" data-toc-entry="/issues/{{ date }}"><h1 id="brief-heading" class="reader-section-heading">The Brief</h1><div class="editorial prose-body mt-5 [&>p:first-child]:first-letter:float-left [&>p:first-child]:first-letter:mr-2 [&>p:first-child]:first-letter:mt-1 [&>p:first-child]:first-letter:font-serif [&>p:first-child]:first-letter:text-[4.6rem] [&>p:first-child]:first-letter:font-semibold [&>p:first-child]:first-letter:leading-[0.72]">{{ front_page_html|safe }}</div></section>
{% if (page.is_admin() && read_href.is_some()) || downloads.is_some() %}<div class="my-8 flex flex-wrap gap-2 border-y border-rule py-4">{% if page.is_admin() %}{% match read_href %}{% when Some with (href) %}<a class="btn" href="{{ href }}" target="_blank" rel="noopener">Read in BookOrbit</a>{% when None %}{% endmatch %}{% endif %}{% match downloads %}{% when Some with (downloads) %}{% if downloads.others.is_empty() %}<a class="btn" href="{{ downloads.primary.href }}">Download {{ downloads.primary.label }}</a>{% else %}<div class="relative inline-flex"><a class="btn rounded-r-none" href="{{ downloads.primary.href }}">Download {{ downloads.primary.label }}</a><details class="-ml-px"><summary class="btn list-none rounded-l-none px-2 [&::-webkit-details-marker]:hidden" aria-label="Choose download format"><span aria-hidden="true">▾</span></summary><div class="absolute left-0 top-full z-20 mt-1 min-w-52 border border-rule bg-paper p-1 shadow-[0_18px_32px_-26px_rgb(0_0_0_/_0.6)]"><a class="btn group w-full justify-between border-0" href="{{ downloads.primary.href }}"><span>{% if downloads.primary.label == "EPUB" %}Standard {% endif %}{{ downloads.primary.label }}</span><span class="ml-4 text-muted group-hover:text-paper">{{ downloads.primary.size }}</span></a>{% for download in downloads.others %}<a class="btn group w-full justify-between border-0" href="{{ download.href }}"><span>{{ download.label }}</span><span class="ml-4 text-muted group-hover:text-paper">{{ download.size }}</span></a>{% endfor %}</div></details></div>{% endif %}{% when None %}{% endmatch %}</div>{% endif %} {% if (page.is_admin() && read_href.is_some()) || downloads.is_some() %}<div class="my-8 flex flex-wrap gap-2 border-y border-rule py-4">{% if page.is_admin() %}{% match read_href %}{% when Some with (href) %}<a class="btn" href="{{ href }}" target="_blank" rel="noopener">Read in BookOrbit</a>{% when None %}{% endmatch %}{% endif %}{% match downloads %}{% when Some with (downloads) %}{% if downloads.others.is_empty() %}<a class="btn" href="{{ downloads.primary.href }}">Download {{ downloads.primary.label }}</a>{% else %}<div class="relative inline-flex"><a class="btn rounded-r-none" href="{{ downloads.primary.href }}">Download {{ downloads.primary.label }}</a><details class="-ml-px"><summary class="btn list-none rounded-l-none px-2 [&::-webkit-details-marker]:hidden" aria-label="Choose download format"><span aria-hidden="true">▾</span></summary><div class="absolute left-0 top-full z-20 mt-1 min-w-52 border border-rule bg-paper p-1 shadow-[0_18px_32px_-26px_rgb(0_0_0_/_0.6)]"><a class="btn group w-full justify-between border-0" href="{{ downloads.primary.href }}"><span>{% if downloads.primary.label == "EPUB" %}Standard {% endif %}{{ downloads.primary.label }}</span><span class="ml-4 text-muted group-hover:text-paper">{{ downloads.primary.size }}</span></a>{% for download in downloads.others %}<a class="btn group w-full justify-between border-0" href="{{ download.href }}"><span>{{ download.label }}</span><span class="ml-4 text-muted group-hover:text-paper">{{ download.size }}</span></a>{% endfor %}</div></details></div>{% endif %}{% when None %}{% endmatch %}</div>{% endif %}
<section class="mt-12" aria-labelledby="index-heading"><h2 id="index-heading" class="text-center text-3xl font-semibold leading-[1.1] tracking-[-0.01em]">In This Issue</h2> <section class="mt-12" aria-labelledby="index-heading"><h2 id="index-heading" class="text-center text-3xl font-semibold leading-[1.1] tracking-[-0.01em]">In This Issue</h2>
{% for section in sections %}<section class="mt-14"><h3 class="reader-section-heading">{{ section.name }}</h3><ul class="m-0 list-none p-0">{% for entry in section.entries %}<li class="border-b border-rule py-6" data-toc-entry="{{ entry.href }}"><h4 class="font-serif font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.href }}">{{ entry.title }}</a></h4><p class="mt-2 font-sans text-sm text-muted">{% let source = entry.source %}{% include "_source.html" %} · {{ entry.reading_minutes }} min read{% if page.is_admin() %} · <a class="text-muted hover:text-accent" href="{{ entry.dashboard_href }}">dashboard</a>{% endif %}</p>{% if !entry.summary.is_empty() %}<p class="index-summary mt-4">{{ entry.summary }}</p>{% endif %}{% match entry.why %}{% when Some with (why) %}<p class="mt-4 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match entry.understanding %}{% when Some with (line) %}<p class="mt-3 font-sans text-sm text-muted">{{ line }}</p>{% when None %}{% endmatch %}{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}</li>{% endfor %}</ul></section>{% endfor %} {% for section in sections %}<section class="mt-14"><h3 class="reader-section-heading">{{ section.name }}</h3><ul class="m-0 list-none p-0">{% for entry in section.entries %}<li class="border-b border-rule py-6" data-toc-entry="{{ entry.href }}"><h4 class="font-serif font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.href }}">{{ entry.title }}</a></h4><p class="mt-2 font-sans text-sm text-muted">{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{% let source = entry.source %}{% include "_source.html" %} · {{ entry.reading_minutes }} min read{% if page.is_admin() %} · <a class="text-muted hover:text-accent" href="{{ entry.dashboard_href }}">dashboard</a>{% endif %}</p>{% if !entry.summary.is_empty() %}<p class="index-summary mt-4">{{ entry.summary }}</p>{% endif %}{% match entry.why %}{% when Some with (why) %}<p class="mt-4 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match entry.understanding %}{% when Some with (line) %}<p class="mt-3 font-sans text-sm text-muted">{{ line }}</p>{% when None %}{% endmatch %}{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}</li>{% endfor %}</ul></section>{% endfor %}
</section> </section>
{% if has_world || has_behind %}<nav class="my-12 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-y border-rule py-4 text-center font-sans text-sm uppercase tracking-[0.08em]" aria-label="Issue chapters">{% if has_world %}<a class="text-ink no-underline hover:text-accent" href="/issues/{{ date }}/world">World Briefing</a>{% endif %}{% if has_world && has_behind %}<span class="text-muted" aria-hidden="true">·</span>{% endif %}{% if has_behind %}<a class="text-ink no-underline hover:text-accent" href="/issues/{{ date }}/behind">Behind the paper</a>{% endif %}</nav>{% endif %} {% if has_world || has_behind %}<nav class="my-12 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-y border-rule py-4 text-center font-sans text-sm uppercase tracking-[0.08em]" aria-label="Issue chapters">{% if has_world %}<a class="text-ink no-underline hover:text-accent" href="/issues/{{ date }}/world">World Briefing</a>{% endif %}{% if has_world && has_behind %}<span class="text-muted" aria-hidden="true">·</span>{% endif %}{% if has_behind %}<a class="text-ink no-underline hover:text-accent" href="/issues/{{ date }}/behind">Behind the paper</a>{% endif %}</nav>{% endif %}
<footer id="colophon" class="mt-14 scroll-mt-6 font-sans text-sm leading-relaxed" data-toc-entry="/issues/{{ date }}#colophon"><h2 class="reader-section-heading">Colophon</h2><p class="my-4 text-ink-2"><em>The Daily EPUB</em> is assembled every morning from a personal feed reader.</p><dl class="kv border-y border-rule py-4 text-xs sm:text-sm"><dt>Generated</dt><dd>{{ colophon.generated_at }}</dd><dt>Bulk model</dt><dd>{{ colophon.bulk_model }}</dd><dt>Editor model</dt><dd>{{ colophon.editor_model }}</dd><dt>Summaries model</dt><dd>{{ colophon.summaries_model }}</dd><dt>Entries considered</dt><dd>{% match colophon.entries_fetched %}{% when Some with (entries) %}{{ entries }}{% match colophon.feeds_seen %}{% when Some with (feeds) %} from {{ feeds }} feeds{% when None %}{% endmatch %}{% when None %}n/a{% endmatch %}</dd><dt>Candidates scored</dt><dd>{% match colophon.candidates %}{% when Some with (candidates) %}{{ candidates }}{% when None %}n/a{% endmatch %}</dd><dt>Articles selected</dt><dd>{{ colophon.article_count }} across {{ colophon.section_count }} sections</dd><dt>Words</dt><dd>{{ colophon.total_words }} · ~{{ colophon.reading_minutes }} min read</dd>{% for cost in colophon.provider_costs %}<dt>{{ cost.provider }} cost</dt><dd>{{ cost.cost }}</dd>{% endfor %}<dt>Total token cost</dt><dd>{% match colophon.cost_usd %}{% when Some with (cost) %}{{ cost }}{% when None %}n/a{% endmatch %}</dd><dt>Generator</dt><dd>{{ colophon.generator_version }}</dd></dl></footer> <footer id="colophon" class="mt-14 scroll-mt-6 font-sans text-sm leading-relaxed" data-toc-entry="/issues/{{ date }}#colophon"><h2 class="reader-section-heading">Colophon</h2><p class="my-4 text-ink-2"><em>The Daily EPUB</em> is assembled every morning from a personal feed reader.</p><dl class="kv border-y border-rule py-4 text-xs sm:text-sm"><dt>Generated</dt><dd>{{ colophon.generated_at }}</dd><dt>Bulk model</dt><dd>{{ colophon.bulk_model }}</dd><dt>Editor model</dt><dd>{{ colophon.editor_model }}</dd><dt>Summaries model</dt><dd>{{ colophon.summaries_model }}</dd><dt>Entries considered</dt><dd>{% match colophon.entries_fetched %}{% when Some with (entries) %}{{ entries }}{% match colophon.feeds_seen %}{% when Some with (feeds) %} from {{ feeds }} feeds{% when None %}{% endmatch %}{% when None %}n/a{% endmatch %}</dd><dt>Candidates scored</dt><dd>{% match colophon.candidates %}{% when Some with (candidates) %}{{ candidates }}{% when None %}n/a{% endmatch %}</dd><dt>Articles selected</dt><dd>{{ colophon.article_count }} across {{ colophon.section_count }} sections</dd><dt>Words</dt><dd>{{ colophon.total_words }} · ~{{ colophon.reading_minutes }} min read</dd>{% for cost in colophon.provider_costs %}<dt>{{ cost.provider }} cost</dt><dd>{{ cost.cost }}</dd>{% endfor %}<dt>Total token cost</dt><dd>{% match colophon.cost_usd %}{% when Some with (cost) %}{{ cost }}{% when None %}n/a{% endmatch %}</dd><dt>Generator</dt><dd>{{ colophon.generator_version }}</dd></dl></footer>