e93e79c1bb
Implements spec #137 (spec 4 of 4 from wayfinder map #114). Closes #137. Tickets: #164, #165, #166, #167, #168, #169, #170, #171, #172, #173 — all closed, landed on this branch. ## Summary - #164/#168: sixth outcome word `not_found`; per-Site completed marker predicate. - #165/#169: `poll_failures` row is the failure state; the pass remembers a Site-reported completion. - #166/#171: two new admin filters (`failing`, `unverified`); outbound owner notification + stall condition. - #167/#170: a failure names itself on the Series page; the completion hint reaches the owner and decides nothing. - #172: the other three fault conditions (no-browser-route, sidecar-down, adapter-broken) feeding the notifier. - #173: the landing verdict line shares the same `latest.FaultsFrom` judgement the notifier uses, so the page and the push cannot disagree. `cd backend && go test ./...` green on the merged branch (8 packages). Reviewed-on: #174 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
91 lines
3.7 KiB
Go
91 lines
3.7 KiB
Go
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
|
|
}
|