feat(latest): prefetch kagane covers during polling
Add an optional cover fetcher to the latest-chapter poller and wire the shared browser into it. Each due Kagane series now checks persistent storage, fetches a missing cover once, and keeps chapter polling and cooldown bookkeeping independent when cover work fails.\n\nCentralize Kagane image-id and content-type validation so prefetched bytes cannot poison the on-first-view fallback, and cover the missing, cached, failure, nil-fetcher, invalid-type, and non-Kagane paths with deterministic poll-cycle tests.\n\nRefs #45
This commit is contained in:
@@ -16,6 +16,11 @@ type Fetcher interface {
|
||||
Get(ctx context.Context, url string) (body string, status int, err error)
|
||||
}
|
||||
|
||||
// CoverFetcher retrieves one kagane cover through the browser-backed path.
|
||||
type CoverFetcher interface {
|
||||
Image(ctx context.Context, imageID string) (body []byte, contentType string, err error)
|
||||
}
|
||||
|
||||
// Poller re-checks each bookmarked series' newest published chapter on a
|
||||
// schedule, independent of the userscript's own in-browser checks. The two run
|
||||
// in parallel and report the same observable fact, so whichever writes last wins
|
||||
@@ -36,7 +41,10 @@ type Poller struct {
|
||||
// BrowserFetch handles sites behind a JavaScript challenge that Fetch
|
||||
// cannot clear. Nil disables those sites entirely rather than falling back
|
||||
// to Fetch, which would only ever retrieve a challenge page.
|
||||
BrowserFetch Fetcher
|
||||
BrowserFetch Fetcher
|
||||
// CoverFetch is optional: it prefetches kagane covers without changing the
|
||||
// chapter poll's result when the browser or persistence fails.
|
||||
CoverFetch CoverFetcher
|
||||
Now func() time.Time // injected so tests can freeze it
|
||||
Cooldown time.Duration
|
||||
BrowserCooldown time.Duration
|
||||
@@ -47,6 +55,32 @@ type Poller struct {
|
||||
|
||||
var browserBackedSites = []string{"kagane", "novelfull"}
|
||||
|
||||
func (p *Poller) prefetchCover(ctx context.Context, sr store.Series) {
|
||||
if sr.Site != "kagane" || p.CoverFetch == nil {
|
||||
return
|
||||
}
|
||||
imageID, ok := store.KaganeImageID(sr.Cover)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_, _, found, err := p.Store.GetKaganeCover(imageID)
|
||||
if err != nil {
|
||||
log.Printf("latest poll %q: read cover: %v", sr.Key(), err)
|
||||
return
|
||||
}
|
||||
if found {
|
||||
return
|
||||
}
|
||||
body, contentType, err := p.CoverFetch.Image(ctx, imageID)
|
||||
if err != nil {
|
||||
log.Printf("latest poll %q: fetch cover: %v", sr.Key(), err)
|
||||
return
|
||||
}
|
||||
if err := p.Store.PutKaganeCover(imageID, body, contentType); err != nil {
|
||||
log.Printf("latest poll %q: persist cover: %v", sr.Key(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// fetcherFor returns the fetcher a site needs, or nil when the site cannot be
|
||||
// fetched at all right now. kagane and novelfull both sit behind a Cloudflare
|
||||
// JavaScript challenge that no TLS fingerprint clears — kagane verified
|
||||
@@ -157,6 +191,7 @@ func (p *Poller) checkOne(ctx context.Context, sr store.Series) {
|
||||
log.Printf("latest poll %q: no fetcher for site %q", sr.Key(), sr.Site)
|
||||
return
|
||||
}
|
||||
p.prefetchCover(ctx, sr)
|
||||
|
||||
body, status, err := f.Get(ctx, sr.SeriesURL)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user