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"`
|
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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ func TestExtractLede(t *testing.T) {
|
|||||||
"It's a new era—one of change.",
|
"It's a new era—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 & gas fell by >5%.</p>",
|
"<p>Oil prices rose & gas fell by >5%.</p>",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
|
||||||
|
// 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 {
|
if err := extractVideoFrame(inPath, pngPath, "0"); err != nil {
|
||||||
return err
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user