f2151309f7
Extract gate, route, fetch and both parses into readSeriesPage, which returns facts only; checkOne and acquire keep their scheduling, stamp order and persistence policies. The poller also stops logging the fetched body length, which only made sense while the body lived at the call site.
147 lines
5.5 KiB
Go
147 lines
5.5 KiB
Go
package latest
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log"
|
|
"sync"
|
|
"time"
|
|
|
|
"bookmarkmanager/backend/internal/store"
|
|
)
|
|
|
|
// acquireTimeout bounds one creation-time acquisition end to end: the series
|
|
// page plus the cover bytes. Nothing is waiting on it — the Reader's write has
|
|
// already returned — so this only stops a stalled Site from holding a
|
|
// goroutine and a connection open forever.
|
|
const acquireTimeout = 45 * time.Second
|
|
|
|
// Acquirer gives a Series its Latest Chapter and its Cover the moment the
|
|
// first Bookmark creates it, instead of leaving the Reader to wait out the
|
|
// poll queue — which is ordered by Reader count, so a Series with one Reader
|
|
// sits behind every popular one (ADR-0007).
|
|
//
|
|
// Both facts come from a single series-page fetch, which is also why no
|
|
// client-supplied cover hint is worth accepting: the page has to be fetched
|
|
// for the chapter signal regardless, so a hint would save no request while
|
|
// adding a client-controlled input to a server-side fetch.
|
|
//
|
|
// Every failure path is "log and move on". The Bookmark, its progress and its
|
|
// Latest Chapter are already committed; a Site that is down or a Cover that
|
|
// cannot be produced must not disturb any of them, and the Series is simply
|
|
// left blank until the poll's own cover pass (#61) fills it.
|
|
type Acquirer struct {
|
|
Store *store.Store
|
|
// Fetch retrieves the series page over plain TLS. Nil with a nil
|
|
// BrowserFetch disables acquisition entirely.
|
|
Fetch Fetcher
|
|
// BrowserFetch retrieves kagane and novelfull pages through the browser
|
|
// sidecar, the only thing that clears their Cloudflare challenge. The
|
|
// per-site fallback policy lives in fetcherFor. Nil leaves those Sites
|
|
// unacquired when no fallback applies.
|
|
BrowserFetch Fetcher
|
|
// Covers retrieves the cover bytes. Nil leaves the Cover blank and the
|
|
// chapter half working.
|
|
Covers CoverBytesFetcher
|
|
// BrowserCoverFetch retrieves browser-claimed cover bytes through the
|
|
// sidecar. Nil leaves those Covers blank; nothing falls back to a plain
|
|
// fetch, which would only ever retrieve a challenge page.
|
|
BrowserCoverFetch BrowserCoverFetcher
|
|
// Ctx cancels in-flight acquisitions at shutdown. A hook signature has
|
|
// nowhere to pass one, so it lives here; nil means context.Background.
|
|
Ctx context.Context
|
|
|
|
inflight sync.WaitGroup
|
|
}
|
|
|
|
// acquireSlots caps how many creation-time fetches run at once. A Reader whose
|
|
// userscript bulk-syncs creates many Series at once, and a burst of
|
|
// simultaneous requests from one server IP is the traffic shape most likely to
|
|
// move that IP's bot score — the same reason the poller staggers its batch.
|
|
var acquireSlots = make(chan struct{}, 2)
|
|
|
|
// Acquire starts one acquisition and returns immediately: a Reader's bookmark
|
|
// action may not block on a third-party Site's latency, nor fail with it. It
|
|
// is the store's OnSeriesCreated hook, so it only ever runs for a Series no
|
|
// Reader had bookmarked before.
|
|
func (a *Acquirer) Acquire(sr store.Series) {
|
|
a.inflight.Add(1)
|
|
go func() {
|
|
defer a.inflight.Done()
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("acquire %q: recovered from panic: %v", sr.Key(), r)
|
|
}
|
|
}()
|
|
parent := a.Ctx
|
|
if parent == nil {
|
|
parent = context.Background()
|
|
}
|
|
select {
|
|
case acquireSlots <- struct{}{}:
|
|
defer func() { <-acquireSlots }()
|
|
case <-parent.Done():
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(parent, acquireTimeout)
|
|
defer cancel()
|
|
a.acquire(ctx, sr)
|
|
}()
|
|
}
|
|
|
|
// Wait blocks until every started acquisition has finished. It exists for
|
|
// tests: an asynchronous side effect is otherwise unobservable without
|
|
// polling for it.
|
|
func (a *Acquirer) Wait() { a.inflight.Wait() }
|
|
|
|
func (a *Acquirer) acquire(ctx context.Context, sr store.Series) {
|
|
if a.Fetch == nil && a.BrowserFetch == nil {
|
|
return
|
|
}
|
|
facts, err := readSeriesPage(ctx, sr.Site, sr.SeriesURL, a.BrowserFetch, a.Fetch)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, errNotFetchable):
|
|
// series_url arrives in a client-supplied PUT body, so without the
|
|
// gate a token-holder chooses what the server fetches from its own
|
|
// network position.
|
|
log.Printf("acquire %q: not fetchable: site=%q url=%q", sr.Key(), sr.Site, sr.SeriesURL)
|
|
case errors.Is(err, errNoFetcher):
|
|
log.Printf("acquire %q: no fetcher for site %q", sr.Key(), sr.Site)
|
|
default:
|
|
log.Printf("acquire %q: %v", sr.Key(), err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// This page just served the same purpose a poll tick would have; without
|
|
// the stamp the row stays due and the poller refetches it immediately.
|
|
//
|
|
// Stamped after success — the reverse of the poller, which stamps before
|
|
// the fetch: the Reader is here, watching the Series they just created, so
|
|
// a failed acquisition must leave the row due for a fast retry rather than
|
|
// consuming the cooldown. The stamp happens even when the page read
|
|
// succeeded but produced no facts to persist.
|
|
if err := a.Store.MarkLatestChecked(sr.Site, sr.SeriesID, time.Now().UnixMilli()); err != nil {
|
|
log.Printf("acquire %q: mark checked: %v", sr.Key(), err)
|
|
}
|
|
|
|
if facts.HasLatest {
|
|
if err := a.Store.SetLatestChapter(sr.Site, sr.SeriesID, facts.Latest.Label, facts.Latest.Num); err != nil {
|
|
log.Printf("acquire %q: set latest chapter: %v", sr.Key(), err)
|
|
}
|
|
}
|
|
|
|
if !facts.HasCover {
|
|
return
|
|
}
|
|
bytes, contentType, err := fetchCoverBytes(ctx, facts.Cover, a.BrowserCoverFetch, a.Covers)
|
|
if err != nil {
|
|
log.Printf("acquire %q: fetch cover %s: %v", sr.Key(), facts.Cover, err)
|
|
return
|
|
}
|
|
if err := a.Store.SetSeriesCover(sr.Site, sr.SeriesID, facts.Cover, bytes, contentType); err != nil {
|
|
log.Printf("acquire %q: persist cover: %v", sr.Key(), err)
|
|
}
|
|
}
|