Give a feed with no category of its own one of Apple's (#117)

Most blogs and news sites name no <itunes:category>, so they sat under no chip in the
Directory unless an admin set one by hand. When TYPESAFE_KEY is set, a feed's first read, or
a forced refresh, asks TypeSafe's Jev to pick one of Apple's categories or subcategories from
the feed's title and up to 15 item titles, for a catalogue feed with neither its own category
nor an admin's. The answer is stored as the catalogue's category, so a feed is not asked
twice, and an admin can change it. Only on a first read or a forced refresh to keep the calls
down: feeds already subscribed are categorised by one `ipx fetch --force`.

Tried on eight subscribed feeds on 2026-10-04: XDA, Daring Fireball, Ars Technica and The
Verge as Tech News, The Old New Thing as Technology, John D. Cook as Mathematics,
BoardGameGeek as Games, Pluralistic as News Commentary (0.55). About 2,000 input tokens a
feed at $0.042 a million. The likeliest answer is kept however unsure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 22:15:44 +00:00
parent d238681ec1
commit 3e384f34f9
3 changed files with 103 additions and 0 deletions

View File

@@ -1008,6 +1008,77 @@ fn parse_date(s: &str) -> Option<i64> {
.map(|d| d.timestamp())
}
/// Apple's podcast categories, each with its subcategories
/// (https://podcasters.apple.com/support/1691-apple-podcasts-categories): the list a podcast's own
/// `<itunes:category>` comes from, and a fixed one, so the Directory's chips stay few.
const CATEGORIES: &[(&str, &[&str])] = &[
("Arts", &["Books", "Design", "Fashion & Beauty", "Food", "Performing Arts", "Visual Arts"]),
("Business", &["Careers", "Entrepreneurship", "Investing", "Management", "Marketing", "Non-Profit"]),
("Comedy", &["Comedy Interviews", "Improv", "Stand-Up"]),
("Education", &["Courses", "How To", "Language Learning", "Self-Improvement"]),
("Fiction", &["Comedy Fiction", "Drama", "Science Fiction"]),
("Government", &[]),
("History", &[]),
("Health & Fitness", &["Alternative Health", "Fitness", "Medicine", "Mental Health", "Nutrition", "Sexuality"]),
("Kids & Family", &["Education for Kids", "Parenting", "Pets & Animals", "Stories for Kids"]),
("Leisure", &["Animation & Manga", "Automotive", "Aviation", "Crafts", "Games", "Hobbies", "Home & Garden", "Video Games"]),
("Music", &["Music Commentary", "Music History", "Music Interviews"]),
("News", &["Business News", "Daily News", "Entertainment News", "News Commentary", "Politics", "Sports News", "Tech News"]),
("Religion & Spirituality", &["Buddhism", "Christianity", "Hinduism", "Islam", "Judaism", "Religion", "Spirituality"]),
("Science", &["Astronomy", "Chemistry", "Earth Sciences", "Life Sciences", "Mathematics", "Natural Sciences", "Nature", "Physics", "Social Sciences"]),
("Society & Culture", &["Documentary", "Personal Journals", "Philosophy", "Places & Travel", "Relationships"]),
("Sports", &["Baseball", "Basketball", "Cricket", "Fantasy Sports", "American Football", "Golf", "Hockey", "Rugby", "Running", "Football (Soccer)", "Swimming", "Tennis", "Volleyball", "Wilderness", "Wrestling"]),
("Technology", &[]),
("True Crime", &[]),
("TV & Film", &["After Shows", "Film History", "Film Interviews", "Film Reviews", "TV Reviews"]),
];
/// A Directory category for a feed that names none of its own, which most blogs do not (#117):
/// TypeSafe's Jev picks one of Apple's categories or subcategories from the feed's title and
/// latest items, the subcategory being what a podcast's own category is stored as. The likeliest
/// is taken however unsure, since an admin can change it and asking again would cost a call a scan.
pub async fn classify(client: &reqwest::Client, key: &str, feed: &ParsedFeed) -> Result<String> {
let items: Vec<&str> = feed.entries.iter().filter_map(|e| e.title.as_deref()).take(15).collect();
// A subcategory says which category it is in; without that, "Games" or "Drama" is ambiguous.
let mut criteria = serde_json::Map::new();
for (category, subs) in CATEGORIES {
criteria.insert(category.to_string(), serde_json::Value::Null);
for sub in *subs {
criteria.insert(sub.to_string(), format!("Within {category}").into());
}
}
let body = serde_json::json!({
"model": "jev-latest",
"state": { "title": feed.title, "recent_items": items },
"questions": { "category": {
"type": "choice",
"instructions": "Which category best describes what this feed publishes?",
"criteria": criteria,
}},
});
let answer: serde_json::Value = client
.post("https://api.typesafe.ai/v1/systemone")
.bearer_auth(key)
.json(&body)
.timeout(FEED_TIMEOUT)
.send()
.await?
.error_for_status()?
.json()
.await?;
picked(&answer)
}
fn picked(answer: &serde_json::Value) -> Result<String> {
let choice = answer["answers"]["category"]["choice"].as_str().unwrap_or_default();
CATEGORIES
.iter()
.flat_map(|(c, subs)| std::iter::once(c).chain(subs.iter()))
.find(|c| **c == choice)
.map(|c| c.to_string())
.ok_or_else(|| anyhow!("no category in the answer: {answer}"))
}
#[cfg(test)]
mod tests {
/// A server answering by path: /old moves for good to /new, /tmp for now, /chain for good
@@ -1491,5 +1562,14 @@ mod tests {
assert_eq!(pick_guid(None, None, Some("e"), Some("t")).as_deref(), Some("e"));
assert_eq!(pick_guid(None, None, None, None), None);
}
#[test]
fn picked_takes_only_an_apple_category() {
let answer = |c: &str| serde_json::json!({"answers": {"category": {"type": "choice", "choice": c}}});
assert_eq!(super::picked(&answer("Technology")).unwrap(), "Technology");
assert_eq!(super::picked(&answer("Tech News")).unwrap(), "Tech News", "a subcategory");
assert!(super::picked(&answer("Blogs")).is_err(), "not one of Apple's");
assert!(super::picked(&serde_json::json!({"error": "rate limited"})).is_err());
}
}