feat(paddock): initial paddock briefing pages and store changes

This commit is contained in:
2026-05-25 15:16:00 -04:00
parent e060bcba24
commit e3453de788
19 changed files with 1354 additions and 156 deletions

View File

@@ -190,12 +190,13 @@ func runNewsIngestion(dryRun bool, dbPath string) error {
fmt.Fprintf(os.Stderr, "news: refreshing feeds into %s\n", path) fmt.Fprintf(os.Stderr, "news: refreshing feeds into %s\n", path)
} }
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel() defer cancel()
result, err := news.Refresh(ctx, st, news.RefreshOptions{ result, err := news.Refresh(ctx, st, news.RefreshOptions{
Client: &http.Client{Timeout: 10 * time.Second}, Client: &http.Client{Timeout: 10 * time.Second},
DryRun: dryRun, DryRun: dryRun,
Progress: os.Stderr, Progress: os.Stderr,
EnrichOG: !dryRun,
}) })
fmt.Fprintf( fmt.Fprintf(
os.Stderr, os.Stderr,

View File

@@ -1,4 +1,4 @@
import type { LiveStateResponse, Meeting, NewsItem, RaceHub, Weekend } from './types' import type { ArticleContent, LiveStateResponse, Meeting, NewsItem, RaceHub, Weekend } from './types'
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> { export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
const res = await fetch(`/api/v1/race-hub?session_key=${sessionKey}`) const res = await fetch(`/api/v1/race-hub?session_key=${sessionKey}`)
@@ -46,13 +46,29 @@ export async function fetchNews(limit?: number, source?: string): Promise<NewsIt
const params = new URLSearchParams() const params = new URLSearchParams()
if (limit) params.set('limit', limit.toString()) if (limit) params.set('limit', limit.toString())
if (source) params.set('source', source) if (source) params.set('source', source)
const query = params.toString() const query = params.toString()
const url = query ? `/api/v1/news?${query}` : '/api/v1/news' const url = query ? `/api/v1/news?${query}` : '/api/v1/news'
const res = await fetch(url) const res = await fetch(url)
if (!res.ok) { if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`) throw new Error(`API ${res.status}: ${res.statusText}`)
} }
return res.json() return res.json()
} }
export async function fetchNewsArticle(articleUrl: string): Promise<ArticleContent> {
const res = await fetch(`/api/v1/news/article?url=${encodeURIComponent(articleUrl)}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
return res.json()
}
export async function markNewsRead(articleUrl: string): Promise<void> {
await fetch('/api/v1/news/read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: articleUrl }),
})
}

View File

@@ -16,6 +16,9 @@ export function Nav() {
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}> <Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
Race Hub Race Hub
</Link> </Link>
<Link to="/briefing" activeProps={{ className: 'active' }}>
Briefing
</Link>
</div> </div>
<div className="nav-utility"> <div className="nav-utility">
<Link to="/admin" className="nav-utility-link" activeProps={{ className: 'nav-utility-link active' }}> <Link to="/admin" className="nav-utility-link" activeProps={{ className: 'nav-utility-link active' }}>

View File

@@ -1,61 +1,73 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { fetchNews } from '../api' import { fetchNews } from '../api'
import { timeAgo } from '../utils' import { timeAgo } from '../utils'
const SOURCE_DISPLAY: Record<string, string> = {
'fia': 'FIA',
'bbc-f1': 'BBC Sport',
'autosport-f1': 'Autosport',
'racefans-f1': 'RaceFans',
'guardian-f1': 'Guardian',
'racer-f1': 'RACER',
'f1-youtube': 'F1 YouTube',
}
export function PaddockBriefing() { export function PaddockBriefing() {
const { data: news, isLoading, isError } = useQuery({ const { data: news, isLoading, isError } = useQuery({
queryKey: ['news'], queryKey: ['news'],
queryFn: () => fetchNews(12), queryFn: () => fetchNews(100),
staleTime: 60_000, staleTime: 60_000,
}) })
if (isLoading) { const unreadCount = news?.filter((i) => !i.read_at).length ?? 0
return <div className="briefing-state loading-state">loading paddock briefing</div> const preview = news?.slice(0, 5) ?? []
}
if (isError || !news) {
return <div className="briefing-state error-box">Failed to load paddock briefing</div>
}
if (news.length === 0) {
return <div className="briefing-state empty-box">No briefing items available.</div>
}
return ( return (
<section className="cc-briefing" data-testid="paddock-briefing"> <section className="cc-briefing" data-testid="paddock-briefing">
<div className="sec-header"> <div className="sec-header">
<span className="sec-title">Paddock Briefing</span> <span className="sec-title">
<span className="sec-meta mono">Latest intel</span> Paddock Briefing
{unreadCount > 0 && (
<span className="cc-brief-unread">{unreadCount}</span>
)}
</span>
<Link to="/briefing" className="sec-action mono">
View all
</Link>
</div> </div>
<div className="briefing-grid" role="list">
{news.map((item, i) => { {isLoading && <div className="briefing-state loading-state">loading</div>}
const age = timeAgo(item.published_at || item.fetched_at) {isError && <div className="briefing-state error-box">Failed to load briefing</div>}
return (
<a {!isLoading && !isError && preview.length === 0 && (
key={`${item.url}-${i}`} <div className="briefing-state">
href={item.url} No items. Run <code>box-box --ingest-news</code> to populate.
target="_blank" </div>
rel="noopener noreferrer" )}
className="briefing-card"
{preview.length > 0 && (
<div className="cc-brief-strip" role="list">
{preview.map((item) => (
<Link
key={item.url}
to="/briefing"
className={`cc-brief-item${item.read_at ? ' is-read' : ''}`}
role="listitem" role="listitem"
> >
<div className="briefing-card-head mono"> <div className="cc-brief-item-meta mono">
<span className="briefing-source">{item.source}</span> <span className="cc-brief-source">
<span className="briefing-age">{age}</span> {SOURCE_DISPLAY[item.source] ?? item.source}
</span>
<span className="cc-brief-age">
{timeAgo(item.published_at ?? item.fetched_at)}
</span>
</div> </div>
<h3 className="briefing-title">{item.title}</h3> <span className="cc-brief-title">{item.title}</span>
{item.summary && ( </Link>
<p className="briefing-summary"> ))}
{item.summary.length > 120 ? item.summary.substring(0, 117) + '...' : item.summary} </div>
</p> )}
)}
{item.category && (
<div className="briefing-cat mono">{item.category.toLowerCase()}</div>
)}
</a>
)
})}
</div>
</section> </section>
) )
} }

View File

@@ -0,0 +1,371 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchNews, fetchNewsArticle, markNewsRead } from '../api'
import { stripHtml, timeAgo } from '../utils'
import type { ArticleContent, NewsItem } from '../types'
type Category = 'all' | 'official' | 'news' | 'video'
const CATEGORY_LABELS: Record<Category, string> = {
all: 'All',
official: 'Official',
news: 'News',
video: 'Video',
}
const SOURCE_DISPLAY: Record<string, string> = {
'fia': 'FIA',
'bbc-f1': 'BBC Sport',
'autosport-f1': 'Autosport',
'racefans-f1': 'RaceFans',
'guardian-f1': 'Guardian',
'racer-f1': 'RACER',
'f1-youtube': 'F1 YouTube',
}
function displaySource(id: string): string {
return SOURCE_DISPLAY[id] ?? id
}
function categoryOf(item: NewsItem): Category {
const c = (item.category ?? '').toLowerCase()
if (c === 'official') return 'official'
if (c === 'video') return 'video'
return 'news'
}
function CategoryTabs({
active,
counts,
onChange,
}: {
active: Category
counts: Record<Category, number>
onChange: (c: Category) => void
}) {
return (
<div className="bp-cats" role="tablist" aria-label="Briefing categories">
{(Object.keys(CATEGORY_LABELS) as Category[]).map((cat) => (
<button
key={cat}
role="tab"
aria-selected={active === cat}
className={`bp-cat${active === cat ? ' active' : ''}`}
onClick={() => onChange(cat)}
>
{CATEGORY_LABELS[cat]}
{counts[cat] > 0 && (
<span className="bp-cat-count">{counts[cat]}</span>
)}
</button>
))}
</div>
)
}
function OGImage({ url, title }: { url?: string; title: string }) {
const [failed, setFailed] = useState(false)
const initial = (title[0] ?? '?').toUpperCase()
if (!url || failed) {
return (
<div className="bp-card-img bp-card-img-fallback" aria-hidden="true">
<span className="bp-card-img-initial">{initial}</span>
</div>
)
}
return (
<div className="bp-card-img-wrap">
<img
className="bp-card-img"
src={url}
alt=""
loading="lazy"
onError={() => setFailed(true)}
/>
</div>
)
}
function BriefingCard({
item,
isActive,
onSelect,
}: {
item: NewsItem
isActive: boolean
onSelect: (item: NewsItem) => void
}) {
const isRead = !!item.read_at
const isVideo = categoryOf(item) === 'video'
return (
<article
className={`bp-card${isActive ? ' bp-card-active' : ''}${isRead ? ' bp-card-read' : ''}`}
onClick={() => onSelect(item)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && onSelect(item)}
aria-pressed={isActive}
>
<OGImage url={item.og_image_url} title={item.title} />
{isVideo && <span className="bp-video-badge"> Video</span>}
<div className="bp-card-body">
<div className="bp-card-meta mono">
<span className="bp-card-source">{displaySource(item.source)}</span>
<span className="bp-card-age">{timeAgo(item.published_at ?? item.fetched_at)}</span>
</div>
<h3 className="bp-card-title">{item.title}</h3>
{!isVideo && (item.og_description || item.summary) && (
<p className="bp-card-desc">
{stripHtml(item.og_description || item.summary || '')}
</p>
)}
</div>
</article>
)
}
function ReaderPanel({
item,
onClose,
}: {
item: NewsItem | null
onClose: () => void
}) {
const panelRef = useRef<HTMLDivElement>(null)
const [article, setArticle] = useState<ArticleContent | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [onClose])
// Fetch article when item changes
useEffect(() => {
if (!item) {
setArticle(null)
return
}
setLoading(true)
setError(null)
setArticle(null)
fetchNewsArticle(item.url)
.then((data) => { setArticle(data); setLoading(false) })
.catch((err) => { setError(String(err)); setLoading(false) })
}, [item?.url])
// Scroll panel to top when item changes
useEffect(() => {
panelRef.current?.scrollTo({ top: 0 })
}, [item?.url])
const isOpen = item !== null
return (
<>
{isOpen && (
<div className="bp-reader-backdrop" onClick={onClose} aria-hidden="true" />
)}
<aside
ref={panelRef}
className={`bp-reader${isOpen ? ' open' : ''}`}
aria-label="Article reader"
aria-hidden={!isOpen}
>
{item && (
<>
<div className="bp-reader-toolbar">
<div className="bp-reader-toolbar-meta mono">
<span>{displaySource(item.source)}</span>
<span className="bp-reader-dot">·</span>
<span>{timeAgo(item.published_at ?? item.fetched_at)}</span>
</div>
<div className="bp-reader-toolbar-actions">
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="bp-reader-open"
title="Open in browser"
>
</a>
<button
className="bp-reader-close"
onClick={onClose}
aria-label="Close reader"
>
</button>
</div>
</div>
{(article?.image_url || item.og_image_url) && (
<div className="bp-reader-hero">
<img
src={article?.image_url ?? item.og_image_url}
alt=""
loading="lazy"
/>
</div>
)}
<div className="bp-reader-content">
<h1 className="bp-reader-title">
{article?.title ?? item.title}
</h1>
{article?.byline && (
<div className="bp-reader-byline mono">{article.byline}</div>
)}
{loading && (
<div className="bp-reader-loading">Loading article</div>
)}
{error && !loading && (
<div className="bp-reader-error">
<p>Could not load full article.</p>
{(item.og_description || item.summary) && (
<p className="bp-reader-fallback">
{stripHtml(item.og_description || item.summary || '')}
</p>
)}
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="bp-reader-ext-link"
>
Open in browser
</a>
</div>
)}
{article && !loading && article.content && (
<div
className="bp-reader-body"
// readability strips scripts/iframes; sources are all known news outlets
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: article.content }}
/>
)}
{article && !loading && !article.content && (
<div className="bp-reader-error">
<p>No article content found.</p>
{(item.og_description || item.summary) && (
<p className="bp-reader-fallback">
{stripHtml(item.og_description || item.summary || '')}
</p>
)}
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="bp-reader-ext-link"
>
Open in browser
</a>
</div>
)}
</div>
</>
)}
</aside>
</>
)
}
export function BriefingPage() {
const [activeCategory, setActiveCategory] = useState<Category>('all')
const [selectedItem, setSelectedItem] = useState<NewsItem | null>(null)
const queryClient = useQueryClient()
const { data: allNews = [], isLoading, isError } = useQuery({
queryKey: ['news'],
queryFn: () => fetchNews(100),
staleTime: 60_000,
})
const filtered = allNews.filter(
(item) => activeCategory === 'all' || categoryOf(item) === activeCategory,
)
const counts: Record<Category, number> = {
all: allNews.length,
official: allNews.filter((i) => categoryOf(i) === 'official').length,
news: allNews.filter((i) => categoryOf(i) === 'news').length,
video: allNews.filter((i) => categoryOf(i) === 'video').length,
}
const handleSelect = useCallback(
(item: NewsItem) => {
setSelectedItem((prev) => (prev?.url === item.url ? null : item))
if (!item.read_at) {
markNewsRead(item.url).then(() => {
queryClient.setQueryData<NewsItem[]>(['news'], (old) =>
old?.map((n) =>
n.url === item.url ? { ...n, read_at: new Date().toISOString() } : n,
),
)
})
}
},
[queryClient],
)
const handleClose = useCallback(() => setSelectedItem(null), [])
const unreadCount = allNews.filter((i) => !i.read_at).length
return (
<div className="bp-page" data-testid="briefing-page">
<div className="bp-topbar">
<span className="bp-topbar-label mono">box-box · paddock briefing</span>
<span className="bp-topbar-meta mono">
{unreadCount > 0 ? `${unreadCount} unread` : 'all read'}
</span>
</div>
{isLoading && <div className="loading-state">loading briefing</div>}
{isError && <div className="error-box">Failed to load paddock briefing.</div>}
{!isLoading && !isError && (
<>
<CategoryTabs
active={activeCategory}
counts={counts}
onChange={(c) => { setActiveCategory(c); setSelectedItem(null) }}
/>
{filtered.length === 0 ? (
<div className="bp-empty">
No {activeCategory !== 'all' ? activeCategory : ''} items available.
Run <code>box-box --ingest-news</code> to refresh feeds.
</div>
) : (
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
{filtered.map((item) => (
<BriefingCard
key={item.url}
item={item}
isActive={selectedItem?.url === item.url}
onSelect={handleSelect}
/>
))}
</div>
)}
</>
)}
<ReaderPanel item={selectedItem} onClose={handleClose} />
</div>
)
}

View File

@@ -4,6 +4,7 @@ import { CommandCenterPage } from './pages/CommandCenterPage'
import { RaceHubPage } from './pages/RaceHubPage' import { RaceHubPage } from './pages/RaceHubPage'
import { DataLibraryPage } from './pages/DataLibraryPage' import { DataLibraryPage } from './pages/DataLibraryPage'
import { LiveTimingPage } from './pages/LiveTimingPage' import { LiveTimingPage } from './pages/LiveTimingPage'
import { BriefingPage } from './pages/BriefingPage'
type RaceHubSearch = { type RaceHubSearch = {
session_key?: number session_key?: number
@@ -56,12 +57,19 @@ export const liveTimingRoute = createRoute({
component: LiveTimingPage, component: LiveTimingPage,
}) })
export const briefingRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/briefing',
component: BriefingPage,
})
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
commandCenterRoute, commandCenterRoute,
raceHubRoute, raceHubRoute,
adminRoute, adminRoute,
dataLibraryRoute, dataLibraryRoute,
liveTimingRoute, liveTimingRoute,
briefingRoute,
]) ])
export const router = createRouter({ routeTree }) export const router = createRouter({ routeTree })

View File

@@ -2298,69 +2298,266 @@ a { color: inherit; text-decoration: none; }
.cc-empty-title { font-size: 19px; } .cc-empty-title { font-size: 19px; }
} }
/* ── Paddock Briefing ── */ /* ── Command Center — Paddock Briefing preview strip ── */
.cc-briefing { .cc-briefing {
margin-top: var(--s7); margin-top: var(--s7);
} }
.briefing-state { .briefing-state {
margin-top: var(--s7); margin-top: var(--s4);
color: var(--text-2);
font-size: 12px;
} }
.briefing-grid { .sec-action {
display: grid; font-size: 11px;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); color: var(--text-2);
text-decoration: none;
transition: color 0.1s;
letter-spacing: 0.03em;
}
.sec-action:hover { color: var(--text); }
.cc-brief-unread {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 16px;
padding: 0 4px;
margin-left: var(--s2);
background: var(--red);
color: #fff;
font-size: 10px;
font-weight: 700;
border-radius: 8px;
font-family: var(--f-mono);
vertical-align: middle;
}
.cc-brief-strip {
display: flex;
flex-direction: column;
gap: 1px;
}
.cc-brief-item {
display: flex;
flex-direction: column;
gap: var(--s1);
padding: var(--s3) var(--s4);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 2px;
transition: background 0.1s, border-color 0.1s;
cursor: pointer;
}
.cc-brief-item:hover {
background: var(--surface-h);
border-color: var(--border-2);
}
.cc-brief-item.is-read { opacity: 0.55; }
.cc-brief-item-meta {
display: flex;
gap: var(--s3);
font-size: 10px;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.cc-brief-source { color: var(--text-2); font-weight: 700; }
.cc-brief-age { color: var(--text-3); }
.cc-brief-title {
font-size: 13px;
font-weight: 500;
color: var(--text);
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Briefing Full Page ── */
.bp-page {
max-width: 1400px;
margin: 0 auto;
padding: 0 var(--s6) var(--s7);
}
.bp-topbar {
display: flex;
align-items: center;
gap: var(--s4); gap: var(--s4);
padding: var(--s5) 0 var(--s4);
border-bottom: 1px solid var(--border);
margin-bottom: var(--s5);
}
.bp-topbar-label { font-size: 11px; color: var(--text-2); letter-spacing: 0.05em; }
.bp-topbar-meta { font-size: 11px; color: var(--text-3); margin-left: auto; }
/* Category tabs */
.bp-cats {
display: flex;
gap: var(--s2);
margin-bottom: var(--s5);
border-bottom: 1px solid var(--border);
padding-bottom: var(--s3);
}
.bp-cat {
display: flex;
align-items: center;
gap: var(--s2);
padding: var(--s2) var(--s4);
background: none;
border: 1px solid transparent;
color: var(--text-2);
font-size: 12px;
cursor: pointer;
border-radius: 2px;
transition: color 0.1s, border-color 0.1s, background 0.1s;
}
.bp-cat:hover { color: var(--text); background: var(--surface); }
.bp-cat.active {
color: var(--text);
border-color: var(--border-2);
background: var(--surface);
}
.bp-cat-count {
font-size: 10px;
color: var(--text-3);
font-family: var(--f-mono);
} }
.briefing-card { /* Card grid */
.bp-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--s4);
transition: grid-template-columns 0.2s;
}
.bp-grid.bp-grid-narrow {
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
}
.bp-empty {
padding: var(--s7) 0;
color: var(--text-2);
font-size: 13px;
}
.bp-empty code {
font-family: var(--f-mono);
font-size: 11px;
background: var(--surface);
padding: 1px 5px;
border: 1px solid var(--border);
border-radius: 2px;
}
/* Individual card */
.bp-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
padding: var(--s4);
border-radius: 2px; border-radius: 2px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.1s, background 0.1s; transition: border-color 0.1s, background 0.1s;
position: relative;
user-select: none;
}
.bp-card:hover { border-color: var(--border-2); background: var(--surface-h); }
.bp-card.bp-card-active { border-color: var(--red); }
.bp-card.bp-card-read { opacity: 0.6; }
.bp-card-img-wrap {
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
background: var(--surface-2);
}
.bp-card-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.2s;
}
.bp-card:hover .bp-card-img { transform: scale(1.02); }
.bp-card-img.bp-card-img-fallback,
.bp-card-img-fallback {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
aspect-ratio: 16 / 9;
background: linear-gradient(135deg, var(--surface-2) 0%, var(--border) 100%);
}
.bp-card-img-initial {
font-size: 28px;
font-weight: 700;
color: var(--text-3);
font-family: var(--f-mono);
} }
.briefing-card:hover { .bp-video-badge {
background: var(--surface-h); position: absolute;
border-color: var(--border-2); top: var(--s3);
right: var(--s3);
font-size: 10px;
font-family: var(--f-mono);
background: rgba(0,0,0,0.75);
color: var(--text);
padding: 2px 6px;
border-radius: 2px;
letter-spacing: 0.04em;
} }
.briefing-card-head { .bp-card-body {
display: flex;
flex-direction: column;
gap: var(--s2);
padding: var(--s4);
flex: 1;
}
.bp-card-meta {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: baseline;
font-size: 10px; font-size: 10px;
color: var(--text-3); color: var(--text-3);
margin-bottom: var(--s3);
letter-spacing: 0.05em; letter-spacing: 0.05em;
text-transform: uppercase; text-transform: uppercase;
} }
.bp-card-source { color: var(--text-2); font-weight: 700; }
.bp-card-age { color: var(--text-3); }
.briefing-source { .bp-card-title {
color: var(--text-2);
font-weight: 700;
}
.briefing-title {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
line-height: 1.4;
margin-bottom: var(--s3);
color: var(--text); color: var(--text);
line-height: 1.45;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
} }
.briefing-summary { .bp-card-desc {
font-size: 12px; font-size: 12px;
color: var(--text-2); color: var(--text-2);
line-height: 1.5; line-height: 1.5;
margin-bottom: var(--s4); display: -webkit-box;
flex-grow: 1; -webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
flex: 1;
} }
.briefing-cat { .bp-card-cat {
font-size: 9px; font-size: 9px;
color: var(--text-3); color: var(--text-3);
align-self: flex-start; align-self: flex-start;
@@ -2371,6 +2568,175 @@ a { color: inherit; text-decoration: none; }
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
/* Reader overlay backdrop */
.bp-reader-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 49;
}
/* Reader panel */
.bp-reader {
position: fixed;
top: var(--nav-h);
right: 0;
bottom: 0;
width: min(640px, 92vw);
background: var(--surface);
border-left: 1px solid var(--border);
overflow-y: auto;
z-index: 50;
transform: translateX(100%);
transition: transform 0.22s cubic-bezier(0.4, 0, 0.2, 1);
}
.bp-reader.open { transform: translateX(0); }
.bp-reader-toolbar {
position: sticky;
top: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--s3) var(--s5);
background: var(--surface);
border-bottom: 1px solid var(--border);
z-index: 2;
}
.bp-reader-toolbar-meta {
display: flex;
align-items: center;
gap: 0;
font-size: 11px;
color: var(--text-3);
letter-spacing: 0.04em;
}
.bp-reader-dot { margin: 0 var(--s2); }
.bp-reader-toolbar-actions {
display: flex;
align-items: center;
gap: var(--s3);
}
.bp-reader-open {
font-size: 14px;
color: var(--text-2);
text-decoration: none;
transition: color 0.1s;
line-height: 1;
}
.bp-reader-open:hover { color: var(--text); }
.bp-reader-close {
background: none;
border: none;
color: var(--text-3);
font-size: 14px;
cursor: pointer;
padding: 2px 4px;
transition: color 0.1s;
line-height: 1;
}
.bp-reader-close:hover { color: var(--text); }
.bp-reader-hero {
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
background: var(--surface-2);
}
.bp-reader-hero img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.bp-reader-content {
padding: var(--s6) var(--s6) var(--s7);
}
.bp-reader-title {
font-size: 20px;
font-weight: 700;
line-height: 1.35;
color: var(--text);
margin-bottom: var(--s4);
}
.bp-reader-byline {
font-size: 11px;
color: var(--text-3);
margin-bottom: var(--s5);
letter-spacing: 0.04em;
}
.bp-reader-loading {
color: var(--text-2);
font-size: 13px;
padding: var(--s6) 0;
}
.bp-reader-error {
display: flex;
flex-direction: column;
gap: var(--s4);
padding: var(--s5) 0;
color: var(--text-2);
font-size: 13px;
}
.bp-reader-fallback {
color: var(--text-2);
line-height: 1.6;
}
.bp-reader-ext-link {
color: var(--text-2);
text-decoration: underline;
font-size: 12px;
}
.bp-reader-ext-link:hover { color: var(--text); }
/* Extracted article body */
.bp-reader-body {
font-size: 14px;
line-height: 1.75;
color: var(--text);
}
.bp-reader-body p { margin-bottom: 1em; }
.bp-reader-body h1,
.bp-reader-body h2,
.bp-reader-body h3 { font-weight: 600; margin: 1.5em 0 0.5em; line-height: 1.3; }
.bp-reader-body h1 { font-size: 18px; }
.bp-reader-body h2 { font-size: 16px; }
.bp-reader-body h3 { font-size: 14px; }
.bp-reader-body a { color: var(--text-2); text-decoration: underline; }
.bp-reader-body a:hover { color: var(--text); }
.bp-reader-body ul,
.bp-reader-body ol { padding-left: 1.5em; margin-bottom: 1em; }
.bp-reader-body li { margin-bottom: 0.3em; }
.bp-reader-body blockquote {
border-left: 3px solid var(--border-2);
padding-left: var(--s5);
color: var(--text-2);
margin: 1em 0;
}
.bp-reader-body img {
max-width: 100%;
height: auto;
display: block;
margin: var(--s5) 0;
}
.bp-reader-body figure { margin: var(--s5) 0; }
.bp-reader-body figcaption {
font-size: 11px;
color: var(--text-3);
margin-top: var(--s2);
}
@media (max-width: 600px) {
.bp-grid { grid-template-columns: 1fr; }
.bp-reader { width: 100vw; }
.bp-reader-content { padding: var(--s5) var(--s5) var(--s7); }
}
/* ── Race Story Canvas ── */ /* ── Race Story Canvas ── */
.race-story-canvas { .race-story-canvas {
display: flex; display: flex;

View File

@@ -275,4 +275,16 @@ export interface NewsItem {
summary?: string summary?: string
category?: string category?: string
fetched_at: string fetched_at: string
og_image_url?: string
og_description?: string
read_at?: string
}
export interface ArticleContent {
title: string
byline?: string
excerpt?: string
image_url?: string
content: string
site_name?: string
} }

View File

@@ -74,6 +74,13 @@ export function compareFinishPosition(a: number, b: number): number {
return finishPositionOrder(a) - finishPositionOrder(b) return finishPositionOrder(a) - finishPositionOrder(b)
} }
export function stripHtml(html: string): string {
if (!html) return ''
const div = document.createElement('div')
div.innerHTML = html
return div.textContent ?? ''
}
export function timeAgo(dateStr: string): string { export function timeAgo(dateStr: string): string {
if (!dateStr) return '' if (!dateStr) return ''
const d = new Date(dateStr) const d = new Date(dateStr)

10
go.mod
View File

@@ -3,15 +3,19 @@ module github.com/AmanTahiliani/box-box
go 1.25.6 go 1.25.6
require ( require (
codeberg.org/readeck/go-readability/v2 v2.1.1
github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/sahilm/fuzzy v0.1.1 github.com/sahilm/fuzzy v0.1.1
golang.org/x/net v0.55.0
modernc.org/sqlite v1.47.0 modernc.org/sqlite v1.47.0
) )
require ( require (
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect
@@ -23,6 +27,8 @@ require (
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
@@ -35,8 +41,8 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.3.8 // indirect golang.org/x/text v0.37.0 // indirect
modernc.org/libc v1.70.0 // indirect modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect

115
go.sum
View File

@@ -1,3 +1,9 @@
codeberg.org/readeck/go-readability/v2 v2.1.1 h1:1tEwxFuUqDRP5JABzDHXGWRx5p9S7TElS3U8qQwXC5Y=
codeberg.org/readeck/go-readability/v2 v2.1.1/go.mod h1:x3WG9GpWWnkRb7ajP1NmOKSHbafxNUb736lrDZXeXrs=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -22,10 +28,18 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c h1:wpkoddUomPfHiOziHZixGO5ZBS73cKqVzZipfrLmO1w=
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c/go.mod h1:oVDCh3qjJMLVUSILBRwrm+Bc6RNXGZYtoh9xdvf1ffM=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -42,6 +56,7 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
@@ -52,28 +67,108 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=

View File

@@ -10,30 +10,35 @@ import (
"sort" "sort"
"strings" "strings"
"time" "time"
"golang.org/x/net/html"
) )
const UserAgent = "box-box/phase-19b-rss-spike" const UserAgent = "box-box/phase-19b-rss-spike"
// Source describes a feed that can be fetched and normalized. // Source describes a feed that can be fetched and normalized.
type Source struct { type Source struct {
ID string ID string
Name string Name string
URL string URL string
Category string Category string
SkipSummary bool // omit summary for sources where it's useless (e.g. YouTube)
} }
// Item is the normalized shape consumed by storage and API layers. // Item is the normalized shape consumed by storage and API layers.
type Item struct { type Item struct {
Source string Source string
Title string Title string
URL string URL string
PublishedAt time.Time PublishedAt time.Time
Summary string Summary string
Category string Category string
FetchedAt time.Time FetchedAt time.Time
OGImageURL string
OGDescription string
} }
// DefaultSources are free RSS/Atom feeds worth using for the Paddock Briefing spike. // DefaultSources are free RSS/Atom feeds for the Paddock Briefing.
var DefaultSources = []Source{ var DefaultSources = []Source{
{ID: "fia", Name: "FIA", URL: "https://www.fia.com/rss/news", Category: "official"}, {ID: "fia", Name: "FIA", URL: "https://www.fia.com/rss/news", Category: "official"},
{ID: "bbc-f1", Name: "BBC Sport F1", URL: "https://feeds.bbci.co.uk/sport/formula1", Category: "news"}, {ID: "bbc-f1", Name: "BBC Sport F1", URL: "https://feeds.bbci.co.uk/sport/formula1", Category: "news"},
@@ -41,7 +46,7 @@ var DefaultSources = []Source{
{ID: "racefans-f1", Name: "RaceFans F1", URL: "https://www.racefans.net/category/f1-news/feed/", Category: "news"}, {ID: "racefans-f1", Name: "RaceFans F1", URL: "https://www.racefans.net/category/f1-news/feed/", Category: "news"},
{ID: "guardian-f1", Name: "Guardian Formula One", URL: "https://www.theguardian.com/sport/formulaone/rss", Category: "news"}, {ID: "guardian-f1", Name: "Guardian Formula One", URL: "https://www.theguardian.com/sport/formulaone/rss", Category: "news"},
{ID: "racer-f1", Name: "RACER F1", URL: "https://racer.com/f1/feed", Category: "news"}, {ID: "racer-f1", Name: "RACER F1", URL: "https://racer.com/f1/feed", Category: "news"},
{ID: "f1-youtube", Name: "Formula 1 YouTube", URL: "https://www.youtube.com/feeds/videos.xml?channel_id=UCB_qr75-ydFVKSF9Dmo6izg", Category: "video"}, {ID: "f1-youtube", Name: "Formula 1 YouTube", URL: "https://www.youtube.com/feeds/videos.xml?channel_id=UCB_qr75-ydFVKSF9Dmo6izg", Category: "video", SkipSummary: true},
} }
// Fetch retrieves and parses one RSS or Atom feed with the provided HTTP client. // Fetch retrieves and parses one RSS or Atom feed with the provided HTTP client.
@@ -88,6 +93,83 @@ func Parse(source Source, r io.Reader, fetchedAt time.Time) ([]Item, error) {
return normalizeAtom(source, atom.Entries, fetchedAt), nil return normalizeAtom(source, atom.Entries, fetchedAt), nil
} }
// FetchOGMeta fetches only the <head> of a page and extracts og:image and og:description.
// It reads at most 64 KB to avoid full-page downloads.
func FetchOGMeta(ctx context.Context, client *http.Client, rawURL string) (imageURL, description string, err error) {
if client == nil {
client = &http.Client{Timeout: 8 * time.Second}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return "", "", err
}
req.Header.Set("User-Agent", UserAgent)
resp, err := client.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", "", fmt.Errorf("og fetch %s: status %d", rawURL, resp.StatusCode)
}
limited := io.LimitReader(resp.Body, 64<<10)
tokenizer := html.NewTokenizer(limited)
for {
tt := tokenizer.Next()
if tt == html.ErrorToken {
break
}
if tt == html.EndTagToken {
tag, _ := tokenizer.TagName()
if string(tag) == "head" {
break
}
}
if tt != html.SelfClosingTagToken && tt != html.StartTagToken {
continue
}
tag, hasAttr := tokenizer.TagName()
if !hasAttr || string(tag) != "meta" {
continue
}
var property, name, content string
for {
k, v, more := tokenizer.TagAttr()
switch string(k) {
case "property":
property = string(v)
case "name":
name = string(v)
case "content":
content = string(v)
}
if !more {
break
}
}
switch {
case property == "og:image" && imageURL == "":
imageURL = strings.TrimSpace(content)
case property == "og:description" && description == "":
description = stripHTML(content)
case name == "description" && description == "":
description = stripHTML(content)
}
if imageURL != "" && description != "" {
break
}
}
return imageURL, description, nil
}
// DeduplicateByURL keeps the newest instance of each canonical URL. // DeduplicateByURL keeps the newest instance of each canonical URL.
func DeduplicateByURL(items []Item) []Item { func DeduplicateByURL(items []Item) []Item {
byURL := make(map[string]Item, len(items)) byURL := make(map[string]Item, len(items))
@@ -157,12 +239,16 @@ func normalizeRSS(source Source, raw []rssItem, fetchedAt time.Time) []Item {
if link == "" { if link == "" {
link = strings.TrimSpace(entry.GUID) link = strings.TrimSpace(entry.GUID)
} }
summary := ""
if !source.SkipSummary {
summary = stripHTML(entry.Description)
}
items = append(items, Item{ items = append(items, Item{
Source: source.ID, Source: source.ID,
Title: cleanText(entry.Title), Title: cleanText(entry.Title),
URL: link, URL: link,
PublishedAt: parseFeedTime(entry.PubDate), PublishedAt: parseFeedTime(entry.PubDate),
Summary: cleanText(entry.Description), Summary: summary,
Category: firstNonEmpty(entry.Categories, source.Category), Category: firstNonEmpty(entry.Categories, source.Category),
FetchedAt: fetchedAt, FetchedAt: fetchedAt,
}) })
@@ -177,12 +263,17 @@ func normalizeAtom(source Source, raw []atomEntry, fetchedAt time.Time) []Item {
if len(entry.Categories) > 0 { if len(entry.Categories) > 0 {
category = firstNonEmpty([]string{entry.Categories[0].Label, entry.Categories[0].Term}, source.Category) category = firstNonEmpty([]string{entry.Categories[0].Label, entry.Categories[0].Term}, source.Category)
} }
summary := ""
if !source.SkipSummary {
raw := firstNonEmpty([]string{entry.Summary, entry.Content}, "")
summary = stripHTML(raw)
}
items = append(items, Item{ items = append(items, Item{
Source: source.ID, Source: source.ID,
Title: cleanText(entry.Title), Title: cleanText(entry.Title),
URL: atomEntryURL(entry), URL: atomEntryURL(entry),
PublishedAt: parseFeedTime(firstNonEmpty([]string{entry.Published, entry.Updated}, "")), PublishedAt: parseFeedTime(firstNonEmpty([]string{entry.Published, entry.Updated}, "")),
Summary: cleanText(firstNonEmpty([]string{entry.Summary, entry.Content}, "")), Summary: summary,
Category: category, Category: category,
FetchedAt: fetchedAt, FetchedAt: fetchedAt,
}) })
@@ -223,6 +314,33 @@ func parseFeedTime(value string) time.Time {
return time.Time{} return time.Time{}
} }
// stripHTML removes HTML tags and decodes entities, inserting line breaks for
// block-level elements so the resulting text remains readable as prose.
func stripHTML(value string) string {
if value == "" {
return ""
}
tokenizer := html.NewTokenizer(strings.NewReader(value))
var b strings.Builder
for {
tt := tokenizer.Next()
if tt == html.ErrorToken {
break
}
switch tt {
case html.TextToken:
b.WriteString(tokenizer.Token().Data)
case html.StartTagToken, html.EndTagToken, html.SelfClosingTagToken:
tag, _ := tokenizer.TagName()
switch string(tag) {
case "p", "br", "li", "h1", "h2", "h3", "h4", "h5", "h6", "div", "blockquote", "tr":
b.WriteByte('\n')
}
}
}
return cleanText(b.String())
}
func cleanText(value string) string { func cleanText(value string) string {
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
value = strings.ReplaceAll(value, "\n", " ") value = strings.ReplaceAll(value, "\n", " ")

View File

@@ -7,6 +7,7 @@ import (
"io" "io"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
"github.com/AmanTahiliani/box-box/internal/store" "github.com/AmanTahiliani/box-box/internal/store"
@@ -22,12 +23,14 @@ type Store interface {
// RefreshOptions configures one local news refresh run. // RefreshOptions configures one local news refresh run.
type RefreshOptions struct { type RefreshOptions struct {
Sources []Source Sources []Source
Client *http.Client Client *http.Client
TTL time.Duration TTL time.Duration
DryRun bool DryRun bool
Now func() time.Time Now func() time.Time
Progress io.Writer Progress io.Writer
EnrichOG bool // fetch og:image and og:description for each new item
OGParallel int // max concurrent OG fetches (default 5)
} }
// RefreshResult summarizes one local news refresh run. // RefreshResult summarizes one local news refresh run.
@@ -52,6 +55,9 @@ func Refresh(ctx context.Context, st Store, opts RefreshOptions) (RefreshResult,
if opts.TTL <= 0 { if opts.TTL <= 0 {
opts.TTL = DefaultTTL opts.TTL = DefaultTTL
} }
if opts.OGParallel <= 0 {
opts.OGParallel = 5
}
now := func() time.Time { return time.Now().UTC() } now := func() time.Time { return time.Now().UTC() }
if opts.Now != nil { if opts.Now != nil {
now = func() time.Time { return opts.Now().UTC() } now = func() time.Time { return opts.Now().UTC() }
@@ -59,6 +65,10 @@ func Refresh(ctx context.Context, st Store, opts RefreshOptions) (RefreshResult,
var result RefreshResult var result RefreshResult
var failures []string var failures []string
// Collect all items across sources for OG enrichment.
var allItems []Item
for _, source := range opts.Sources { for _, source := range opts.Sources {
fetchedAt := now() fetchedAt := now()
expiresAt := fetchedAt.Add(opts.TTL) expiresAt := fetchedAt.Add(opts.TTL)
@@ -86,6 +96,7 @@ func Refresh(ctx context.Context, st Store, opts RefreshOptions) (RefreshResult,
result.ItemsFetched += len(items) result.ItemsFetched += len(items)
progressf(opts.Progress, "news: %s fetched %d items\n", source.ID, len(items)) progressf(opts.Progress, "news: %s fetched %d items\n", source.ID, len(items))
if opts.DryRun { if opts.DryRun {
allItems = append(allItems, items...)
continue continue
} }
@@ -101,22 +112,40 @@ func Refresh(ctx context.Context, st Store, opts RefreshOptions) (RefreshResult,
}); err != nil { }); err != nil {
return result, err return result, err
} }
for _, item := range items {
item.FetchedAt = fetchedAt for i := range items {
publishedAt := timePtr(item.PublishedAt) items[i].FetchedAt = fetchedAt
if err := st.UpsertNewsItem(store.NewsItem{
URL: item.URL,
Source: item.Source,
Title: item.Title,
PublishedAt: publishedAt,
Summary: item.Summary,
Category: item.Category,
FetchedAt: item.FetchedAt,
}); err != nil {
return result, err
}
result.ItemsUpserted++
} }
allItems = append(allItems, items...)
}
// OG enrichment pass: fetch og:image + og:description for each item.
if opts.EnrichOG && len(allItems) > 0 {
progressf(opts.Progress, "news: enriching %d items with OG metadata\n", len(allItems))
enrichOG(ctx, opts.Client, allItems, opts.OGParallel, opts.Progress)
}
if opts.DryRun {
return result, nil
}
// Store all items (with OG data already populated).
for _, item := range allItems {
publishedAt := timePtr(item.PublishedAt)
if err := st.UpsertNewsItem(store.NewsItem{
URL: item.URL,
Source: item.Source,
Title: item.Title,
PublishedAt: publishedAt,
Summary: item.Summary,
Category: item.Category,
FetchedAt: item.FetchedAt,
OGImageURL: item.OGImageURL,
OGDescription: item.OGDescription,
}); err != nil {
return result, err
}
result.ItemsUpserted++
} }
if len(failures) > 0 { if len(failures) > 0 {
@@ -125,6 +154,38 @@ func Refresh(ctx context.Context, st Store, opts RefreshOptions) (RefreshResult,
return result, nil return result, nil
} }
// enrichOG fetches og:image and og:description for each item concurrently.
func enrichOG(ctx context.Context, client *http.Client, items []Item, parallel int, progress io.Writer) {
sem := make(chan struct{}, parallel)
var mu sync.Mutex
var wg sync.WaitGroup
ogClient := &http.Client{Timeout: 8 * time.Second}
if client != nil {
ogClient = &http.Client{Timeout: 8 * time.Second, Transport: client.Transport}
}
for i := range items {
wg.Add(1)
go func(idx int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
imgURL, desc, err := FetchOGMeta(ctx, ogClient, items[idx].URL)
if err != nil {
progressf(progress, "news: og fetch failed %s: %v\n", items[idx].URL, err)
return
}
mu.Lock()
items[idx].OGImageURL = imgURL
items[idx].OGDescription = desc
mu.Unlock()
}(i)
}
wg.Wait()
}
func progressf(w io.Writer, format string, args ...any) { func progressf(w io.Writer, format string, args ...any) {
if w == nil { if w == nil {
return return

View File

@@ -8,13 +8,16 @@ import (
// NewsItem is the frontend-facing cached briefing shape. // NewsItem is the frontend-facing cached briefing shape.
type NewsItem struct { type NewsItem struct {
Source string `json:"source"` Source string `json:"source"`
Title string `json:"title"` Title string `json:"title"`
URL string `json:"url"` URL string `json:"url"`
PublishedAt *time.Time `json:"published_at,omitempty"` PublishedAt *time.Time `json:"published_at,omitempty"`
Summary string `json:"summary,omitempty"` Summary string `json:"summary,omitempty"`
Category string `json:"category,omitempty"` Category string `json:"category,omitempty"`
FetchedAt time.Time `json:"fetched_at"` FetchedAt time.Time `json:"fetched_at"`
OGImageURL string `json:"og_image_url,omitempty"`
OGDescription string `json:"og_description,omitempty"`
ReadAt *time.Time `json:"read_at,omitempty"`
} }
// ListNews returns cached briefing items. // ListNews returns cached briefing items.
@@ -26,26 +29,36 @@ func (s *Service) ListNews(limit int, source string) ([]NewsItem, error) {
out := make([]NewsItem, 0, len(rows)) out := make([]NewsItem, 0, len(rows))
for _, row := range rows { for _, row := range rows {
out = append(out, NewsItem{ out = append(out, NewsItem{
Source: row.Source, Source: row.Source,
Title: row.Title, Title: row.Title,
URL: row.URL, URL: row.URL,
PublishedAt: row.PublishedAt, PublishedAt: row.PublishedAt,
Summary: row.Summary, Summary: row.Summary,
Category: row.Category, Category: row.Category,
FetchedAt: row.FetchedAt, FetchedAt: row.FetchedAt,
OGImageURL: row.OGImageURL,
OGDescription: row.OGDescription,
ReadAt: row.ReadAt,
}) })
} }
return out, nil return out, nil
} }
// MarkNewsRead marks a news item as read by URL.
func (s *Service) MarkNewsRead(url string) error {
return s.store.MarkNewsItemRead(url)
}
func NewsItemToStore(item NewsItem) store.NewsItem { func NewsItemToStore(item NewsItem) store.NewsItem {
return store.NewsItem{ return store.NewsItem{
URL: item.URL, URL: item.URL,
Source: item.Source, Source: item.Source,
Title: item.Title, Title: item.Title,
PublishedAt: item.PublishedAt, PublishedAt: item.PublishedAt,
Summary: item.Summary, Summary: item.Summary,
Category: item.Category, Category: item.Category,
FetchedAt: item.FetchedAt, FetchedAt: item.FetchedAt,
OGImageURL: item.OGImageURL,
OGDescription: item.OGDescription,
} }
} }

View File

@@ -0,0 +1,3 @@
ALTER TABLE news_items ADD COLUMN og_image_url TEXT;
ALTER TABLE news_items ADD COLUMN og_description TEXT;
ALTER TABLE news_items ADD COLUMN read_at INTEGER;

View File

@@ -42,13 +42,16 @@ type NewsSource struct {
// NewsItem stores a normalized feed item deduplicated by URL. // NewsItem stores a normalized feed item deduplicated by URL.
type NewsItem struct { type NewsItem struct {
URL string URL string
Source string Source string
Title string Title string
PublishedAt *time.Time PublishedAt *time.Time
Summary string Summary string
Category string Category string
FetchedAt time.Time FetchedAt time.Time
OGImageURL string
OGDescription string
ReadAt *time.Time
} }
// Meeting is a race weekend record. // Meeting is a race weekend record.

View File

@@ -41,18 +41,23 @@ func (s *Store) UpsertNewsSource(src NewsSource) error {
} }
// UpsertNewsItem inserts or updates a normalized feed item. // UpsertNewsItem inserts or updates a normalized feed item.
// read_at is never overwritten by a re-fetch.
// og_image_url and og_description preserve an existing value when the new one is empty.
func (s *Store) UpsertNewsItem(item NewsItem) error { func (s *Store) UpsertNewsItem(item NewsItem) error {
_, err := s.db.Exec(` _, err := s.db.Exec(`
INSERT INTO news_items ( INSERT INTO news_items (
url, source, title, published_at, summary, category, fetched_at url, source, title, published_at, summary, category, fetched_at,
) VALUES (?, ?, ?, ?, ?, ?, ?) og_image_url, og_description
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(url) DO UPDATE SET ON CONFLICT(url) DO UPDATE SET
source = excluded.source, source = excluded.source,
title = excluded.title, title = excluded.title,
published_at = excluded.published_at, published_at = excluded.published_at,
summary = excluded.summary, summary = excluded.summary,
category = excluded.category, category = excluded.category,
fetched_at = excluded.fetched_at fetched_at = excluded.fetched_at,
og_image_url = COALESCE(excluded.og_image_url, news_items.og_image_url),
og_description= COALESCE(excluded.og_description, news_items.og_description)
`, `,
item.URL, item.URL,
item.Source, item.Source,
@@ -61,6 +66,8 @@ func (s *Store) UpsertNewsItem(item NewsItem) error {
nullString(item.Summary), nullString(item.Summary),
nullString(item.Category), nullString(item.Category),
item.FetchedAt.Unix(), item.FetchedAt.Unix(),
nullString(item.OGImageURL),
nullString(item.OGDescription),
) )
if err != nil { if err != nil {
return fmt.Errorf("upsert news item: %w", err) return fmt.Errorf("upsert news item: %w", err)
@@ -68,6 +75,15 @@ func (s *Store) UpsertNewsItem(item NewsItem) error {
return nil return nil
} }
// MarkNewsItemRead sets read_at to now for the given URL.
func (s *Store) MarkNewsItemRead(url string) error {
_, err := s.db.Exec(
`UPDATE news_items SET read_at = ? WHERE url = ?`,
time.Now().Unix(), url,
)
return err
}
// ListNewsItems returns newest cached news items, optionally filtered by source. // ListNewsItems returns newest cached news items, optionally filtered by source.
func (s *Store) ListNewsItems(limit int, source string) ([]NewsItem, error) { func (s *Store) ListNewsItems(limit int, source string) ([]NewsItem, error) {
if limit <= 0 || limit > 100 { if limit <= 0 || limit > 100 {
@@ -75,7 +91,8 @@ func (s *Store) ListNewsItems(limit int, source string) ([]NewsItem, error) {
} }
query := ` query := `
SELECT url, source, title, published_at, summary, category, fetched_at SELECT url, source, title, published_at, summary, category, fetched_at,
og_image_url, og_description, read_at
FROM news_items FROM news_items
` `
var args []any var args []any
@@ -95,8 +112,8 @@ func (s *Store) ListNewsItems(limit int, source string) ([]NewsItem, error) {
var out []NewsItem var out []NewsItem
for rows.Next() { for rows.Next() {
var item NewsItem var item NewsItem
var published sql.NullInt64 var published, readAt sql.NullInt64
var summary, category sql.NullString var summary, category, ogImage, ogDesc sql.NullString
var fetched int64 var fetched int64
if err := rows.Scan( if err := rows.Scan(
&item.URL, &item.URL,
@@ -106,6 +123,9 @@ func (s *Store) ListNewsItems(limit int, source string) ([]NewsItem, error) {
&summary, &summary,
&category, &category,
&fetched, &fetched,
&ogImage,
&ogDesc,
&readAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -113,6 +133,9 @@ func (s *Store) ListNewsItems(limit int, source string) ([]NewsItem, error) {
item.Summary = summary.String item.Summary = summary.String
item.Category = category.String item.Category = category.String
item.FetchedAt = time.Unix(fetched, 0).UTC() item.FetchedAt = time.Unix(fetched, 0).UTC()
item.OGImageURL = ogImage.String
item.OGDescription = ogDesc.String
item.ReadAt = nullTimePtr(readAt)
out = append(out, item) out = append(out, item)
} }
return out, rows.Err() return out, rows.Err()

View File

@@ -1,9 +1,11 @@
package web package web
import ( import (
"context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"sort" "sort"
"strconv" "strconv"
@@ -11,6 +13,8 @@ import (
"sync" "sync"
"time" "time"
readability "codeberg.org/readeck/go-readability/v2"
"github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/query" "github.com/AmanTahiliani/box-box/internal/query"
) )
@@ -133,6 +137,80 @@ func (s *Server) handleNews(w http.ResponseWriter, r *http.Request) {
writeJSON(w, items) writeJSON(w, items)
} }
// --- /api/v1/news/article ---
type articleResponse struct {
Title string `json:"title"`
Byline string `json:"byline,omitempty"`
Excerpt string `json:"excerpt,omitempty"`
ImageURL string `json:"image_url,omitempty"`
Content string `json:"content"`
SiteName string `json:"site_name,omitempty"`
}
func (s *Server) handleNewsArticle(w http.ResponseWriter, r *http.Request) {
rawURL := strings.TrimSpace(r.URL.Query().Get("url"))
if rawURL == "" {
http.Error(w, "url required", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
withCtx := readability.RequestWith(func(req *http.Request) {
*req = *req.WithContext(ctx)
})
article, err := readability.FromURL(rawURL, 20*time.Second, withCtx)
if err != nil {
writeError(w, fmt.Errorf("article fetch: %w", err), http.StatusBadGateway, false)
return
}
var buf strings.Builder
if article.Node != nil {
if rerr := article.RenderHTML(&buf); rerr != nil {
writeError(w, rerr, http.StatusInternalServerError, false)
return
}
}
writeJSON(w, articleResponse{
Title: article.Title(),
Byline: article.Byline(),
Excerpt: article.Excerpt(),
ImageURL: article.ImageURL(),
Content: buf.String(),
SiteName: article.SiteName(),
})
}
// --- /api/v1/news/read ---
func (s *Server) handleNewsRead(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.hasLocalQuery() {
w.WriteHeader(http.StatusNoContent)
return
}
var body struct {
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.URL == "" {
http.Error(w, "url required", http.StatusBadRequest)
return
}
if err := s.query.MarkNewsRead(body.URL); err != nil {
writeError(w, err, http.StatusInternalServerError, false)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- /api/v1/drivers --- // --- /api/v1/drivers ---
func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) {

View File

@@ -64,6 +64,8 @@ func (s *Server) routes() (http.Handler, error) {
mux.HandleFunc("/api/v1/race-hub", s.handleRaceHub) mux.HandleFunc("/api/v1/race-hub", s.handleRaceHub)
mux.HandleFunc("/api/v1/seasons", s.handleSeasons) mux.HandleFunc("/api/v1/seasons", s.handleSeasons)
mux.HandleFunc("/api/v1/weekend", s.handleWeekend) mux.HandleFunc("/api/v1/weekend", s.handleWeekend)
mux.HandleFunc("/api/v1/news/article", s.handleNewsArticle)
mux.HandleFunc("/api/v1/news/read", s.handleNewsRead)
mux.HandleFunc("/api/v1/news", s.handleNews) mux.HandleFunc("/api/v1/news", s.handleNews)
mux.HandleFunc("/api/v1/meetings", s.handleMeetings) mux.HandleFunc("/api/v1/meetings", s.handleMeetings)
mux.HandleFunc("/api/v1/sessions", s.handleSessions) mux.HandleFunc("/api/v1/sessions", s.handleSessions)