Compare commits
4 Commits
59658e7ebb
...
fc4cab3ad6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc4cab3ad6 | ||
|
|
3e29acaa23 | ||
|
|
78fc3ef811 | ||
|
|
7d469cf8c5 |
@@ -70,6 +70,10 @@ type SourceConfig struct {
|
||||
PollIntervalMinutes int `toml:"poll_interval_minutes"`
|
||||
DirectRoute string `toml:"direct_route"`
|
||||
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) {
|
||||
|
||||
@@ -28,11 +28,13 @@ type FeedItem struct {
|
||||
ArticleURL string
|
||||
Source 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
|
||||
}
|
||||
|
||||
var (
|
||||
htmlTagRe = regexp.MustCompile(`<[^>]*>`)
|
||||
wsRe = regexp.MustCompile(`\s+`)
|
||||
imgSrcRe = regexp.MustCompile(`(?i)<img[^>]+src=["']([^"']+)["']`)
|
||||
)
|
||||
|
||||
@@ -59,6 +61,7 @@ func FetchFeed(feedURL string) ([]FeedItem, error) {
|
||||
Lede: extractLede(item.Description),
|
||||
ImageURL: NormalizeImageURL(extractImageURL(item)),
|
||||
ArticleURL: strings.TrimSpace(item.Link),
|
||||
Language: itemLanguage(item),
|
||||
PublishedAt: itemPublished(item),
|
||||
}
|
||||
if fi.GUID == "" || fi.ArticleURL == "" {
|
||||
@@ -71,6 +74,22 @@ func FetchFeed(feedURL string) ([]FeedItem, error) {
|
||||
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 {
|
||||
if item.GUID != "" {
|
||||
return item.GUID
|
||||
@@ -96,8 +115,10 @@ func extractLede(desc string) string {
|
||||
if desc == "" {
|
||||
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 = wsRe.ReplaceAllString(text, " ")
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,11 @@ func TestExtractLede(t *testing.T) {
|
||||
"It's a new era—one 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",
|
||||
"<p>Oil prices rose & gas fell by >5%.</p>",
|
||||
|
||||
@@ -3,6 +3,7 @@ package ingestion
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -109,6 +110,17 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e
|
||||
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
|
||||
// they stay stable whether we end up swapping to a Wayback snapshot.
|
||||
originalURL := items[i].ArticleURL
|
||||
|
||||
@@ -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>';
|
||||
} else {
|
||||
var lastChannel = null;
|
||||
sources.forEach(function (src) {
|
||||
sources.forEach(function (src, i) {
|
||||
if (src.channel !== lastChannel) {
|
||||
lastChannel = src.channel;
|
||||
var hdr = document.createElement("div");
|
||||
@@ -77,7 +77,7 @@
|
||||
hdr.textContent = src.channel || "other";
|
||||
list.appendChild(hdr);
|
||||
}
|
||||
var id = "pete-feed-" + btoa(src.name).replace(/=/g, "");
|
||||
var id = "pete-feed-" + i;
|
||||
var row = document.createElement("label");
|
||||
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";
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -305,15 +306,49 @@ func buildAvifFromFFmpeg(body []byte, outPath string) error {
|
||||
outPNG.Close()
|
||||
defer os.Remove(pngPath)
|
||||
|
||||
if err := extractVideoFrame(inPath, pngPath, "0.5"); err != nil {
|
||||
// Some clips are shorter than the seek offset; retry from frame 0.
|
||||
if err := extractVideoFrame(inPath, pngPath, "0"); err != nil {
|
||||
return err
|
||||
// Aim ~5s in — past the typical fade-in / first-GOP blockiness while
|
||||
// still likely within the clip. For shorter clips we fall back to the
|
||||
// midpoint (when ffprobe is available) so we don't seek off the end.
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
// downscale it to at most thumbWidth wide before writing pngPath.
|
||||
func extractVideoFrame(inPath, pngPath, seekSec string) error {
|
||||
|
||||
Reference in New Issue
Block a user