Bypass-UA retry (Googlebot + Google referer) for soft paywalls, JSON-LD gating scoped to Article-typed nodes, HTTP 402 treated as explicit paywall, Wayback freshness filter (30d cap), archive.today as secondary archive fallback, and transport failures no longer trigger snapshot swaps. When gating is detected and no archive workaround succeeds, the story is stored with paywalled=1 and the web card renders a diagonal red rubber-stamp overlay so readers know the link is gated.
289 lines
6.6 KiB
Go
289 lines
6.6 KiB
Go
package web
|
|
|
|
import (
|
|
"fmt"
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"pete/internal/storage"
|
|
)
|
|
|
|
const pageSize = 24
|
|
|
|
// StoryView is the trimmed-down record used in templates.
|
|
type StoryView struct {
|
|
Headline string
|
|
Lede string
|
|
ImageURL string
|
|
ArticleURL string
|
|
Source string
|
|
SeenAt time.Time
|
|
Posted bool
|
|
Paywalled bool // source is gated and no archive workaround succeeded
|
|
Channel string // channel slug; also the theme key
|
|
}
|
|
|
|
func toView(s storage.Story) StoryView {
|
|
return StoryView{
|
|
Headline: s.Headline,
|
|
Lede: s.Lede,
|
|
ImageURL: s.ImageURL,
|
|
ArticleURL: s.ArticleURL,
|
|
Source: s.Source,
|
|
SeenAt: time.Unix(s.SeenAt, 0),
|
|
Posted: s.Posted,
|
|
Paywalled: s.Paywalled,
|
|
Channel: s.Channel,
|
|
}
|
|
}
|
|
|
|
type pageData struct {
|
|
SiteTitle string
|
|
Channels []Channel
|
|
Active string // slug of active channel, "" for landing
|
|
Weather Weather
|
|
Phase string // when set, forces day/dawn/dusk/night and disables the clock-driven phase JS
|
|
}
|
|
|
|
type channelPage struct {
|
|
pageData
|
|
Channel Channel
|
|
Stories []StoryView
|
|
Page int
|
|
HasPrev bool
|
|
HasNext bool
|
|
PrevURL string
|
|
NextURL string
|
|
Total int
|
|
}
|
|
|
|
type indexPage struct {
|
|
pageData
|
|
JustPosted []StoryView
|
|
Stats []channelStat
|
|
Latest []StoryView
|
|
}
|
|
|
|
type channelStat struct {
|
|
Channel Channel
|
|
LastPostedAt time.Time // zero if never posted
|
|
HasLastPosted bool
|
|
PostsToday int
|
|
Total int
|
|
}
|
|
|
|
func (s *Server) base() pageData {
|
|
return pageData{
|
|
SiteTitle: s.cfg.SiteTitle,
|
|
Channels: channels,
|
|
Weather: currentWeather(time.Now()),
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleIndex(w http.ResponseWriter, _ *http.Request) {
|
|
const (
|
|
justPostedLimit = 6
|
|
latestLimit = 16
|
|
)
|
|
|
|
postedRows, err := storage.ListRecentlyPosted(justPostedLimit)
|
|
if err != nil {
|
|
slog.Error("web: list recently posted failed", "err", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
justPosted := make([]StoryView, 0, len(postedRows))
|
|
for _, r := range postedRows {
|
|
justPosted = append(justPosted, toView(r))
|
|
}
|
|
|
|
latestRows, err := storage.ListAllClassified(latestLimit, 0)
|
|
if err != nil {
|
|
slog.Error("web: list all classified failed", "err", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
latest := make([]StoryView, 0, len(latestRows))
|
|
for _, r := range latestRows {
|
|
latest = append(latest, toView(r))
|
|
}
|
|
|
|
dayStart := time.Now().Add(-24 * time.Hour).Unix()
|
|
stats := make([]channelStat, 0, len(channels))
|
|
for _, ch := range channels {
|
|
total, _ := storage.CountClassifiedByChannel(ch.Slug)
|
|
lastTs := storage.GetLastPostTime(ch.Slug)
|
|
stat := channelStat{
|
|
Channel: ch,
|
|
PostsToday: storage.CountPostsInWindow(ch.Slug, dayStart),
|
|
Total: total,
|
|
}
|
|
if lastTs > 0 {
|
|
stat.LastPostedAt = time.Unix(lastTs, 0)
|
|
stat.HasLastPosted = true
|
|
}
|
|
stats = append(stats, stat)
|
|
}
|
|
|
|
data := indexPage{
|
|
pageData: s.base(),
|
|
JustPosted: justPosted,
|
|
Stats: stats,
|
|
Latest: latest,
|
|
}
|
|
s.render(w, "index", data)
|
|
}
|
|
|
|
func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channel) {
|
|
page := 1
|
|
if p := r.URL.Query().Get("page"); p != "" {
|
|
if n, err := strconv.Atoi(p); err == nil && n > 0 {
|
|
page = n
|
|
}
|
|
}
|
|
offset := (page - 1) * pageSize
|
|
rows, err := storage.ListClassifiedByChannel(ch.Slug, pageSize+1, offset)
|
|
if err != nil {
|
|
slog.Error("web: list failed", "channel", ch.Slug, "err", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
hasNext := len(rows) > pageSize
|
|
if hasNext {
|
|
rows = rows[:pageSize]
|
|
}
|
|
views := make([]StoryView, 0, len(rows))
|
|
for _, r := range rows {
|
|
views = append(views, toView(r))
|
|
}
|
|
total, _ := storage.CountClassifiedByChannel(ch.Slug)
|
|
|
|
base := s.base()
|
|
base.Active = ch.Slug
|
|
data := channelPage{
|
|
pageData: base,
|
|
Channel: ch,
|
|
Stories: views,
|
|
Page: page,
|
|
HasPrev: page > 1,
|
|
HasNext: hasNext,
|
|
PrevURL: fmt.Sprintf("/%s?page=%d", ch.Slug, page-1),
|
|
NextURL: fmt.Sprintf("/%s?page=%d", ch.Slug, page+1),
|
|
Total: total,
|
|
}
|
|
s.render(w, "channel", data)
|
|
}
|
|
|
|
var (
|
|
demoVariants = []string{"rain", "petals", "jacaranda", "motes", "leaves"}
|
|
demoIntensities = []string{"light", "medium", "heavy"}
|
|
demoPhases = []string{"day", "dawn", "dusk", "night"}
|
|
)
|
|
|
|
type weatherDemoPage struct {
|
|
pageData
|
|
Variants []string
|
|
Intensities []string
|
|
Phases []string
|
|
Variant string
|
|
Intensity string
|
|
PhaseSel string
|
|
}
|
|
|
|
func pickOr(v string, allowed []string, fallback string) string {
|
|
for _, a := range allowed {
|
|
if v == a {
|
|
return v
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func seasonForVariant(v string) string {
|
|
switch v {
|
|
case "rain":
|
|
return "winter"
|
|
case "petals", "jacaranda":
|
|
return "spring"
|
|
case "motes":
|
|
return "summer"
|
|
case "leaves":
|
|
return "autumn"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *Server) handleWeatherDemo(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
variant := pickOr(q.Get("variant"), demoVariants, "jacaranda")
|
|
intensity := pickOr(q.Get("intensity"), demoIntensities, "heavy")
|
|
phase := pickOr(q.Get("phase"), demoPhases, "day")
|
|
|
|
base := s.base()
|
|
base.Weather = Weather{Season: seasonForVariant(variant), Variant: variant, Intensity: intensity}
|
|
base.Phase = phase
|
|
|
|
s.render(w, "weather", weatherDemoPage{
|
|
pageData: base,
|
|
Variants: demoVariants,
|
|
Intensities: demoIntensities,
|
|
Phases: demoPhases,
|
|
Variant: variant,
|
|
Intensity: intensity,
|
|
PhaseSel: phase,
|
|
})
|
|
}
|
|
|
|
func (s *Server) render(w http.ResponseWriter, page string, data any) {
|
|
tpl, ok := s.tpls[page]
|
|
if !ok {
|
|
http.Error(w, "unknown page", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := tpl.ExecuteTemplate(w, "layout", data); err != nil {
|
|
slog.Error("web: template render failed", "page", page, "err", err)
|
|
}
|
|
}
|
|
|
|
var funcs = template.FuncMap{
|
|
"thumb": thumbURL,
|
|
"channel": func(slug string) Channel {
|
|
for _, ch := range channels {
|
|
if ch.Slug == slug {
|
|
return ch
|
|
}
|
|
}
|
|
return Channel{Slug: slug, Title: slug, Theme: slug}
|
|
},
|
|
"dict": func(pairs ...any) map[string]any {
|
|
m := make(map[string]any, len(pairs)/2)
|
|
for i := 0; i+1 < len(pairs); i += 2 {
|
|
key, ok := pairs[i].(string)
|
|
if !ok {
|
|
continue
|
|
}
|
|
m[key] = pairs[i+1]
|
|
}
|
|
return m
|
|
},
|
|
"timeAgo": func(t time.Time) string {
|
|
d := time.Since(t)
|
|
switch {
|
|
case d < time.Minute:
|
|
return "just now"
|
|
case d < time.Hour:
|
|
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
|
case d < 24*time.Hour:
|
|
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
|
case d < 7*24*time.Hour:
|
|
return fmt.Sprintf("%dd ago", int(d.Hours())/24)
|
|
default:
|
|
return t.Format("Jan 2")
|
|
}
|
|
},
|
|
}
|