Compare commits

...

4 Commits

Author SHA1 Message Date
prosolis
fc4cab3ad6 Seek 5s into videos for thumbnail extraction
The 0.5s seek often landed on the initial keyframe or a fade-in,
producing blocky thumbnails. Aim ~5s in instead, with an ffprobe
midpoint fallback for clips shorter than that and a 2s → 0
retry chain if the seek still overshoots.
2026-05-26 23:03:31 -07:00
prosolis
3e29acaa23 Fix feed settings panel failing to open
btoa() throws InvalidCharacterError on non-Latin1 input, so feed
names with em-dashes ("The Guardian — World") killed render()
before the dialog's hidden class was removed and nothing visibly
happened on click. Use the loop index for the row id instead.
2026-05-26 23:03:23 -07:00
prosolis
78fc3ef811 Add per-source language filter
When a source sets language = "en", drop items whose per-item
language tag is present and doesn't prefix-match. Items without
a language tag pass through unchanged. Politico Europe is the
motivating case — same headlines appear in en, fr, and de.
2026-05-26 23:03:12 -07:00
prosolis
7d469cf8c5 Fix glued-together RSS ledes; parse per-item language
Two ingestion changes:

- extractLede replaced HTML tags with empty string, so adjacent
  block tags like </p><p> fused words across paragraphs. Replace
  tags with a space and collapse whitespace.
- Pull each item's <language> tag (or dc:language) into FeedItem
  so the poller can filter on it. Politico Europe publishes the
  same story in en / fr / de side-by-side and we want to keep
  only one language per source.
2026-05-26 23:03:00 -07:00
6 changed files with 84 additions and 7 deletions

View File

@@ -70,6 +70,10 @@ type SourceConfig struct {
PollIntervalMinutes int `toml:"poll_interval_minutes"` PollIntervalMinutes int `toml:"poll_interval_minutes"`
DirectRoute string `toml:"direct_route"` DirectRoute string `toml:"direct_route"`
Enabled bool `toml:"enabled"` Enabled bool `toml:"enabled"`
// Language, when set, drops feed items whose per-item language tag is
// present and does not match (prefix). Useful for multilingual feeds
// like Politico Europe that publish English + French side-by-side.
Language string `toml:"language"`
} }
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {

View File

@@ -28,11 +28,13 @@ type FeedItem struct {
ArticleURL string ArticleURL string
Source string Source string
DirectRoute string DirectRoute string
Language string // per-item <language> (or dc:language) when present; "" otherwise
PublishedAt int64 // unix seconds from RSS pubDate / Atom published (or updated as fallback); 0 if absent or unparseable PublishedAt int64 // unix seconds from RSS pubDate / Atom published (or updated as fallback); 0 if absent or unparseable
} }
var ( var (
htmlTagRe = regexp.MustCompile(`<[^>]*>`) htmlTagRe = regexp.MustCompile(`<[^>]*>`)
wsRe = regexp.MustCompile(`\s+`)
imgSrcRe = regexp.MustCompile(`(?i)<img[^>]+src=["']([^"']+)["']`) imgSrcRe = regexp.MustCompile(`(?i)<img[^>]+src=["']([^"']+)["']`)
) )
@@ -59,6 +61,7 @@ func FetchFeed(feedURL string) ([]FeedItem, error) {
Lede: extractLede(item.Description), Lede: extractLede(item.Description),
ImageURL: NormalizeImageURL(extractImageURL(item)), ImageURL: NormalizeImageURL(extractImageURL(item)),
ArticleURL: strings.TrimSpace(item.Link), ArticleURL: strings.TrimSpace(item.Link),
Language: itemLanguage(item),
PublishedAt: itemPublished(item), PublishedAt: itemPublished(item),
} }
if fi.GUID == "" || fi.ArticleURL == "" { if fi.GUID == "" || fi.ArticleURL == "" {
@@ -71,6 +74,22 @@ func FetchFeed(feedURL string) ([]FeedItem, error) {
return items, nil return items, nil
} }
// itemLanguage returns the per-item language tag if the feed provides one.
// Politico Europe puts a raw <language> inside each <item>; gofeed parks
// unknown item-level elements in item.Custom. We also check the standard
// dc:language extension for feeds that use Dublin Core.
func itemLanguage(item *gofeed.Item) string {
if item.Custom != nil {
if v, ok := item.Custom["language"]; ok {
return strings.ToLower(strings.TrimSpace(v))
}
}
if item.DublinCoreExt != nil && len(item.DublinCoreExt.Language) > 0 {
return strings.ToLower(strings.TrimSpace(item.DublinCoreExt.Language[0]))
}
return ""
}
func itemGUID(item *gofeed.Item) string { func itemGUID(item *gofeed.Item) string {
if item.GUID != "" { if item.GUID != "" {
return item.GUID return item.GUID
@@ -96,8 +115,10 @@ func extractLede(desc string) string {
if desc == "" { if desc == "" {
return "" return ""
} }
text := htmlTagRe.ReplaceAllString(desc, "") // Replace tags with a space so adjacent blocks like </p><p> don't fuse words.
text := htmlTagRe.ReplaceAllString(desc, " ")
text = html.UnescapeString(text) text = html.UnescapeString(text)
text = wsRe.ReplaceAllString(text, " ")
return strings.TrimSpace(text) return strings.TrimSpace(text)
} }

View File

@@ -48,6 +48,11 @@ func TestExtractLede(t *testing.T) {
"It&#39;s a new era&#8212;one of change.", "It&#39;s a new era&#8212;one of change.",
"It's a new era\u2014one of change.", "It's a new era\u2014one of change.",
}, },
{
"adjacent block tags inject a space",
"<p>...end of moments</p><p>Clarence B Jones, a former...</p>",
"...end of moments Clarence B Jones, a former...",
},
{ {
"tags and entities combined", "tags and entities combined",
"<p>Oil prices rose &amp; gas fell by &gt;5%.</p>", "<p>Oil prices rose &amp; gas fell by &gt;5%.</p>",

View File

@@ -3,6 +3,7 @@ package ingestion
import ( import (
"context" "context"
"log/slog" "log/slog"
"strings"
"sync" "sync"
"time" "time"
@@ -109,6 +110,17 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e
continue continue
} }
// Drop items in languages this source isn't configured to surface.
// Items without a language tag pass through — only filter when the
// feed explicitly marks an item with a non-matching language.
if src.Language != "" && items[i].Language != "" &&
!strings.HasPrefix(items[i].Language, strings.ToLower(src.Language)) {
slog.Info("dropping story (language filter)",
"guid", items[i].GUID, "source", src.Name,
"item_lang", items[i].Language, "want", src.Language)
continue
}
// Dedup checks first — canonical and headline use the ORIGINAL URL so // Dedup checks first — canonical and headline use the ORIGINAL URL so
// they stay stable whether we end up swapping to a Wayback snapshot. // they stay stable whether we end up swapping to a Wayback snapshot.
originalURL := items[i].ArticleURL originalURL := items[i].ArticleURL

View File

@@ -69,7 +69,7 @@
list.innerHTML = '<p class="px-2 py-4 text-sm text-center text-[color:var(--ink)]/50">No feeds configured.</p>'; list.innerHTML = '<p class="px-2 py-4 text-sm text-center text-[color:var(--ink)]/50">No feeds configured.</p>';
} else { } else {
var lastChannel = null; var lastChannel = null;
sources.forEach(function (src) { sources.forEach(function (src, i) {
if (src.channel !== lastChannel) { if (src.channel !== lastChannel) {
lastChannel = src.channel; lastChannel = src.channel;
var hdr = document.createElement("div"); var hdr = document.createElement("div");
@@ -77,7 +77,7 @@
hdr.textContent = src.channel || "other"; hdr.textContent = src.channel || "other";
list.appendChild(hdr); list.appendChild(hdr);
} }
var id = "pete-feed-" + btoa(src.name).replace(/=/g, ""); var id = "pete-feed-" + i;
var row = document.createElement("label"); var row = document.createElement("label");
row.setAttribute("for", id); row.setAttribute("for", id);
row.className = "flex items-center justify-between gap-3 rounded-2xl px-3 py-2 hover:bg-[color:var(--ink)]/5 cursor-pointer"; row.className = "flex items-center justify-between gap-3 rounded-2xl px-3 py-2 hover:bg-[color:var(--ink)]/5 cursor-pointer";

View File

@@ -17,6 +17,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -305,15 +306,49 @@ func buildAvifFromFFmpeg(body []byte, outPath string) error {
outPNG.Close() outPNG.Close()
defer os.Remove(pngPath) defer os.Remove(pngPath)
if err := extractVideoFrame(inPath, pngPath, "0.5"); err != nil { // Aim ~5s in — past the typical fade-in / first-GOP blockiness while
// Some clips are shorter than the seek offset; retry from frame 0. // still likely within the clip. For shorter clips we fall back to the
if err := extractVideoFrame(inPath, pngPath, "0"); err != nil { // midpoint (when ffprobe is available) so we don't seek off the end.
return err seek := "5"
if d := probeVideoDuration(inPath); d > 0 && d < 5 {
seek = strconv.FormatFloat(d/2, 'f', 2, 64)
}
if err := extractVideoFrame(inPath, pngPath, seek); err != nil {
// Seek overshot or container's funky — try 2s, then frame 0.
if err := extractVideoFrame(inPath, pngPath, "2"); err != nil {
if err := extractVideoFrame(inPath, pngPath, "0"); err != nil {
return err
}
} }
} }
return avifEncodeFromPNG(pngPath, outPath) return avifEncodeFromPNG(pngPath, outPath)
} }
// probeVideoDuration returns the clip length in seconds, or 0 if ffprobe is
// missing or can't read it (image-as-video fallback, malformed containers).
func probeVideoDuration(inPath string) float64 {
if _, err := exec.LookPath("ffprobe"); err != nil {
return 0
}
ctx, cancel := context.WithTimeout(context.Background(), ffmpegTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
inPath,
)
out, err := cmd.Output()
if err != nil {
return 0
}
d, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
if err != nil || d <= 0 {
return 0
}
return d
}
// extractVideoFrame asks ffmpeg to seek to seekSec, grab one frame, and // extractVideoFrame asks ffmpeg to seek to seekSec, grab one frame, and
// downscale it to at most thumbWidth wide before writing pngPath. // downscale it to at most thumbWidth wide before writing pngPath.
func extractVideoFrame(inPath, pngPath, seekSec string) error { func extractVideoFrame(inPath, pngPath, seekSec string) error {