mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
feat(paddock): initial paddock briefing pages and store changes
This commit is contained in:
@@ -10,30 +10,35 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
const UserAgent = "box-box/phase-19b-rss-spike"
|
||||
|
||||
// Source describes a feed that can be fetched and normalized.
|
||||
type Source struct {
|
||||
ID string
|
||||
Name string
|
||||
URL string
|
||||
Category string
|
||||
ID string
|
||||
Name string
|
||||
URL 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.
|
||||
type Item struct {
|
||||
Source string
|
||||
Title string
|
||||
URL string
|
||||
PublishedAt time.Time
|
||||
Summary string
|
||||
Category string
|
||||
FetchedAt time.Time
|
||||
Source string
|
||||
Title string
|
||||
URL string
|
||||
PublishedAt time.Time
|
||||
Summary string
|
||||
Category string
|
||||
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{
|
||||
{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"},
|
||||
@@ -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: "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: "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.
|
||||
@@ -88,6 +93,83 @@ func Parse(source Source, r io.Reader, fetchedAt time.Time) ([]Item, error) {
|
||||
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.
|
||||
func DeduplicateByURL(items []Item) []Item {
|
||||
byURL := make(map[string]Item, len(items))
|
||||
@@ -157,12 +239,16 @@ func normalizeRSS(source Source, raw []rssItem, fetchedAt time.Time) []Item {
|
||||
if link == "" {
|
||||
link = strings.TrimSpace(entry.GUID)
|
||||
}
|
||||
summary := ""
|
||||
if !source.SkipSummary {
|
||||
summary = stripHTML(entry.Description)
|
||||
}
|
||||
items = append(items, Item{
|
||||
Source: source.ID,
|
||||
Title: cleanText(entry.Title),
|
||||
URL: link,
|
||||
PublishedAt: parseFeedTime(entry.PubDate),
|
||||
Summary: cleanText(entry.Description),
|
||||
Summary: summary,
|
||||
Category: firstNonEmpty(entry.Categories, source.Category),
|
||||
FetchedAt: fetchedAt,
|
||||
})
|
||||
@@ -177,12 +263,17 @@ func normalizeAtom(source Source, raw []atomEntry, fetchedAt time.Time) []Item {
|
||||
if len(entry.Categories) > 0 {
|
||||
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{
|
||||
Source: source.ID,
|
||||
Title: cleanText(entry.Title),
|
||||
URL: atomEntryURL(entry),
|
||||
PublishedAt: parseFeedTime(firstNonEmpty([]string{entry.Published, entry.Updated}, "")),
|
||||
Summary: cleanText(firstNonEmpty([]string{entry.Summary, entry.Content}, "")),
|
||||
Summary: summary,
|
||||
Category: category,
|
||||
FetchedAt: fetchedAt,
|
||||
})
|
||||
@@ -223,6 +314,33 @@ func parseFeedTime(value string) 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 {
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.ReplaceAll(value, "\n", " ")
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/store"
|
||||
@@ -22,12 +23,14 @@ type Store interface {
|
||||
|
||||
// RefreshOptions configures one local news refresh run.
|
||||
type RefreshOptions struct {
|
||||
Sources []Source
|
||||
Client *http.Client
|
||||
TTL time.Duration
|
||||
DryRun bool
|
||||
Now func() time.Time
|
||||
Progress io.Writer
|
||||
Sources []Source
|
||||
Client *http.Client
|
||||
TTL time.Duration
|
||||
DryRun bool
|
||||
Now func() time.Time
|
||||
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.
|
||||
@@ -52,6 +55,9 @@ func Refresh(ctx context.Context, st Store, opts RefreshOptions) (RefreshResult,
|
||||
if opts.TTL <= 0 {
|
||||
opts.TTL = DefaultTTL
|
||||
}
|
||||
if opts.OGParallel <= 0 {
|
||||
opts.OGParallel = 5
|
||||
}
|
||||
now := func() time.Time { return time.Now().UTC() }
|
||||
if opts.Now != nil {
|
||||
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 failures []string
|
||||
|
||||
// Collect all items across sources for OG enrichment.
|
||||
var allItems []Item
|
||||
|
||||
for _, source := range opts.Sources {
|
||||
fetchedAt := now()
|
||||
expiresAt := fetchedAt.Add(opts.TTL)
|
||||
@@ -86,6 +96,7 @@ func Refresh(ctx context.Context, st Store, opts RefreshOptions) (RefreshResult,
|
||||
result.ItemsFetched += len(items)
|
||||
progressf(opts.Progress, "news: %s fetched %d items\n", source.ID, len(items))
|
||||
if opts.DryRun {
|
||||
allItems = append(allItems, items...)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -101,22 +112,40 @@ func Refresh(ctx context.Context, st Store, opts RefreshOptions) (RefreshResult,
|
||||
}); err != nil {
|
||||
return result, err
|
||||
}
|
||||
for _, item := range items {
|
||||
item.FetchedAt = fetchedAt
|
||||
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,
|
||||
}); err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.ItemsUpserted++
|
||||
|
||||
for i := range items {
|
||||
items[i].FetchedAt = fetchedAt
|
||||
}
|
||||
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 {
|
||||
@@ -125,6 +154,38 @@ func Refresh(ctx context.Context, st Store, opts RefreshOptions) (RefreshResult,
|
||||
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) {
|
||||
if w == nil {
|
||||
return
|
||||
|
||||
@@ -8,13 +8,16 @@ import (
|
||||
|
||||
// NewsItem is the frontend-facing cached briefing shape.
|
||||
type NewsItem struct {
|
||||
Source string `json:"source"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
PublishedAt *time.Time `json:"published_at,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
FetchedAt time.Time `json:"fetched_at"`
|
||||
Source string `json:"source"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
PublishedAt *time.Time `json:"published_at,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
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.
|
||||
@@ -26,26 +29,36 @@ func (s *Service) ListNews(limit int, source string) ([]NewsItem, error) {
|
||||
out := make([]NewsItem, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, NewsItem{
|
||||
Source: row.Source,
|
||||
Title: row.Title,
|
||||
URL: row.URL,
|
||||
PublishedAt: row.PublishedAt,
|
||||
Summary: row.Summary,
|
||||
Category: row.Category,
|
||||
FetchedAt: row.FetchedAt,
|
||||
Source: row.Source,
|
||||
Title: row.Title,
|
||||
URL: row.URL,
|
||||
PublishedAt: row.PublishedAt,
|
||||
Summary: row.Summary,
|
||||
Category: row.Category,
|
||||
FetchedAt: row.FetchedAt,
|
||||
OGImageURL: row.OGImageURL,
|
||||
OGDescription: row.OGDescription,
|
||||
ReadAt: row.ReadAt,
|
||||
})
|
||||
}
|
||||
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 {
|
||||
return store.NewsItem{
|
||||
URL: item.URL,
|
||||
Source: item.Source,
|
||||
Title: item.Title,
|
||||
PublishedAt: item.PublishedAt,
|
||||
Summary: item.Summary,
|
||||
Category: item.Category,
|
||||
FetchedAt: item.FetchedAt,
|
||||
URL: item.URL,
|
||||
Source: item.Source,
|
||||
Title: item.Title,
|
||||
PublishedAt: item.PublishedAt,
|
||||
Summary: item.Summary,
|
||||
Category: item.Category,
|
||||
FetchedAt: item.FetchedAt,
|
||||
OGImageURL: item.OGImageURL,
|
||||
OGDescription: item.OGDescription,
|
||||
}
|
||||
}
|
||||
|
||||
3
internal/store/migrations/005_news_enriched.sql
Normal file
3
internal/store/migrations/005_news_enriched.sql
Normal 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;
|
||||
@@ -42,13 +42,16 @@ type NewsSource struct {
|
||||
|
||||
// NewsItem stores a normalized feed item deduplicated by URL.
|
||||
type NewsItem struct {
|
||||
URL string
|
||||
Source string
|
||||
Title string
|
||||
PublishedAt *time.Time
|
||||
Summary string
|
||||
Category string
|
||||
FetchedAt time.Time
|
||||
URL string
|
||||
Source string
|
||||
Title string
|
||||
PublishedAt *time.Time
|
||||
Summary string
|
||||
Category string
|
||||
FetchedAt time.Time
|
||||
OGImageURL string
|
||||
OGDescription string
|
||||
ReadAt *time.Time
|
||||
}
|
||||
|
||||
// Meeting is a race weekend record.
|
||||
|
||||
@@ -41,18 +41,23 @@ func (s *Store) UpsertNewsSource(src NewsSource) error {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO news_items (
|
||||
url, source, title, published_at, summary, category, fetched_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
url, source, title, published_at, summary, category, fetched_at,
|
||||
og_image_url, og_description
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(url) DO UPDATE SET
|
||||
source = excluded.source,
|
||||
title = excluded.title,
|
||||
published_at = excluded.published_at,
|
||||
summary = excluded.summary,
|
||||
category = excluded.category,
|
||||
fetched_at = excluded.fetched_at
|
||||
source = excluded.source,
|
||||
title = excluded.title,
|
||||
published_at = excluded.published_at,
|
||||
summary = excluded.summary,
|
||||
category = excluded.category,
|
||||
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.Source,
|
||||
@@ -61,6 +66,8 @@ func (s *Store) UpsertNewsItem(item NewsItem) error {
|
||||
nullString(item.Summary),
|
||||
nullString(item.Category),
|
||||
item.FetchedAt.Unix(),
|
||||
nullString(item.OGImageURL),
|
||||
nullString(item.OGDescription),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert news item: %w", err)
|
||||
@@ -68,6 +75,15 @@ func (s *Store) UpsertNewsItem(item NewsItem) error {
|
||||
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.
|
||||
func (s *Store) ListNewsItems(limit int, source string) ([]NewsItem, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
@@ -75,7 +91,8 @@ func (s *Store) ListNewsItems(limit int, source string) ([]NewsItem, error) {
|
||||
}
|
||||
|
||||
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
|
||||
`
|
||||
var args []any
|
||||
@@ -95,8 +112,8 @@ func (s *Store) ListNewsItems(limit int, source string) ([]NewsItem, error) {
|
||||
var out []NewsItem
|
||||
for rows.Next() {
|
||||
var item NewsItem
|
||||
var published sql.NullInt64
|
||||
var summary, category sql.NullString
|
||||
var published, readAt sql.NullInt64
|
||||
var summary, category, ogImage, ogDesc sql.NullString
|
||||
var fetched int64
|
||||
if err := rows.Scan(
|
||||
&item.URL,
|
||||
@@ -106,6 +123,9 @@ func (s *Store) ListNewsItems(limit int, source string) ([]NewsItem, error) {
|
||||
&summary,
|
||||
&category,
|
||||
&fetched,
|
||||
&ogImage,
|
||||
&ogDesc,
|
||||
&readAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -113,6 +133,9 @@ func (s *Store) ListNewsItems(limit int, source string) ([]NewsItem, error) {
|
||||
item.Summary = summary.String
|
||||
item.Category = category.String
|
||||
item.FetchedAt = time.Unix(fetched, 0).UTC()
|
||||
item.OGImageURL = ogImage.String
|
||||
item.OGDescription = ogDesc.String
|
||||
item.ReadAt = nullTimePtr(readAt)
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -11,6 +13,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
readability "codeberg.org/readeck/go-readability/v2"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
"github.com/AmanTahiliani/box-box/internal/query"
|
||||
)
|
||||
@@ -133,6 +137,80 @@ func (s *Server) handleNews(w http.ResponseWriter, r *http.Request) {
|
||||
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 ---
|
||||
|
||||
func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -64,6 +64,8 @@ func (s *Server) routes() (http.Handler, error) {
|
||||
mux.HandleFunc("/api/v1/race-hub", s.handleRaceHub)
|
||||
mux.HandleFunc("/api/v1/seasons", s.handleSeasons)
|
||||
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/meetings", s.handleMeetings)
|
||||
mux.HandleFunc("/api/v1/sessions", s.handleSessions)
|
||||
|
||||
Reference in New Issue
Block a user