package latest import ( "context" "errors" "fmt" ) // seriesRead carries the facts the poll and the acquirer both extract from a // series page. Persistence, stamps and scheduling stay with the callers, so // the policies that keep the two flows distinct (stamp order, rests) are not // swallowed by the module. type seriesRead struct { Latest latestChapter HasLatest bool Cover string HasCover bool // SiteCompleted is whether the Site's own completed value was on the page. // A challenge body and a redesign both read false — an absent hint, never // a claim (issue #168). SiteCompleted bool // BodyLen is the fetched body's length, surfaced because the no-chapter // log uses it to tell a markup change from a body the size cap cut short. BodyLen int } // errNotFetchable and errNoFetcher separate the gate and the route from fetch // failures so each caller keeps its own distinct log line for all three. // errChallengeHeld (browser.go) is the outcome of a Site that answered with // its interstitial — status 403 (cf-mitigated) or a challenge page body — and // is how a Lane tells a refusal from an ordinary failure (issue #100). // errNotFound marks any other 4xx: the page is gone — a fact the owner can act // on — as distinct from a Site or database that is merely unwell (issue #164). var ( errNotFetchable = errors.New("series url not fetchable") errNoFetcher = errors.New("no fetcher for site") errNotFound = errors.New("series page not found") ) // readSeriesPage performs the series-page read the poll and the acquirer have // in common: gate the address, choose the route, fetch the page, extract the // Latest Chapter, the Cover address and the Site's completed value. It // persists nothing and stamps nothing. // // series_url arrives in a client-supplied PUT body (PUT /bookmarks/{key} // accepts any string), so the gate is not an optimisation against burning a // request on an unknown site: without it, the server would issue a GET from // its own network position to whatever URL a token-holder writes, including // link-local/internal addresses or non-https schemes. func readSeriesPage(ctx context.Context, site, seriesURL string, browser, tls Fetcher) (seriesRead, error) { if !FetchableSeriesURL(site, seriesURL) { return seriesRead{}, fmt.Errorf("%w: site=%q url=%q", errNotFetchable, site, seriesURL) } f := fetcherFor(site, browser, tls) if f == nil { return seriesRead{}, fmt.Errorf("%w: site %q", errNoFetcher, site) } body, status, err := f.Get(ctx, seriesURL) if err != nil { return seriesRead{}, fmt.Errorf("fetch %s: %w", seriesURL, err) } if status == 403 { // Cloudflare's challenge response for these Sites (cf-mitigated). The // browser fetcher returns exactly this on a held interstitial, and a // plain-TLS 403 means the same: the Site is refusing. return seriesRead{}, fmt.Errorf("%w: fetch %s: status %d", errChallengeHeld, seriesURL, status) } if status != 200 { if status >= 400 && status < 500 { return seriesRead{}, fmt.Errorf("%w: fetch %s: status %d", errNotFound, seriesURL, status) } return seriesRead{}, fmt.Errorf("fetch %s: status %d", seriesURL, status) } if isInterstitial(body) { // A 200 that is the challenge page, not the payload: the TLS route can // receive this where the browser would have kept re-reading. Same // refusal as the 403. return seriesRead{}, fmt.Errorf("%w: fetch %s: interstitial body", errChallengeHeld, seriesURL) } latest, hasLatest := latestChapterFrom(site, seriesURL, body) cover, hasCover := coverFrom(site, seriesURL, body) return seriesRead{ Latest: latest, HasLatest: hasLatest, Cover: cover, HasCover: hasCover, SiteCompleted: siteCompletedFrom(site, seriesURL, body), BodyLen: len(body), }, nil }