180ee78b1f
Tracks read progress on comix.to and kagane.to alongside asura and demonic, in both the userscript and the backend. Implements `docs/superpowers/plans/2026-08-03-comix-kagane-support.md`. ## Userscript - `comix` adapter — `/title/<id>-<slug>`; only the id prefix is identity (the slug follows the title). No `og:image`, so the cover is matched by `alt`. - `kagane` adapter — reader URLs are uuids with no chapter number, so it comes out of `og:title`; anchor scanning is structurally impossible, replaced by `latestChapterFromApi` against kagane's same-origin JSON API. - `seriesId` threaded through `latestChapterFromAnchors` so comix can scope its scan to its own series and a recommendation strip cannot win the maximum. - `@match` for both hosts, panel chips, v1.6.0. ## Backend - `latestChapterFrom` cases: comix parses the SSR JSON state blob (`latestChapterUrl`, scoped to the series id); kagane parses API JSON (`chapter_no`). - Poller allowlist extended; `Poller.BrowserFetch` with `fetcherFor(site)` routes kagane to a browser fetcher. Nil means kagane is not polled at all — never a fallback to the TLS fetcher, which would only ever retrieve a challenge page. - `BrowserFetcher`: chromedp against a `headless-shell` sidecar. kagane sits behind a Cloudflare JS challenge that no TLS fingerprint clears, and the request is made inside the page rather than by replaying `cf_clearance`. - `BROWSER_WS_URL` wiring, sidecar in both compose files (no `ports:`, dedicated non-external network), Dockerfile on `golang:1.26-alpine` — chromedp requires go 1.26. - Web UI `--comix` / `--kagane` tokens in both colour branches. ## Notes for review - `series_url` is client-supplied and a headless browser is a strong SSRF primitive, so kagane's host is pinned twice: in `fetchableSeriesURL` and again in `kaganeAPIURL`. - Three chained defects found during verification made the browser path dead under Compose (sidecar flag collision, Chrome's Host-header DNS-rebinding check, the wrong chromedp option). Fixed; the compose comments record the wrong configurations too, so they don't get "simplified" back. - `ALLOWED_ORIGINS` now includes both new origins. Without it every write from comix/kagane silently fails CORS preflight, parks in the retry queue, and drops at the cap. ## Verification 221 backend tests, 32 userscript tests, static `CGO_ENABLED=0` build, both compose configs. Two gaps, both real: 1. The userscript on live pages via Violentmonkey needs a human browser profile — not run. Check: comix series page (title/cover, no chapter), comix chapter page (records the number; an *older* chapter must not regress it), comix SPA navigation without reload, kagane series page (og:image cover), kagane reader (number from `og:title`), both chips opening the right sites. 2. The kagane browser path has not completed end-to-end anywhere. Dial/navigate/fetch is confirmed, but Cloudflare 403'd headless-shell's Chrome on every attempt from the dev sandbox, and comix's poll-through-Docker was blocked by that environment's TLS interception. Both environment-dependent rather than branch defects — the first real deploy is the actual verification. Reviewed-on: #13 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
77 lines
2.6 KiB
Go
77 lines
2.6 KiB
Go
package latest
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
|
|
fhttp "github.com/bogdanfinn/fhttp"
|
|
tls_client "github.com/bogdanfinn/tls-client"
|
|
"github.com/bogdanfinn/tls-client/profiles"
|
|
)
|
|
|
|
// maxBodyBytes caps what a single series page can cost in memory. Real pages
|
|
// measured 100-400 KB on 2026-07-26, so this is roughly 10x headroom and mostly
|
|
// guards against a proxy handing back something enormous.
|
|
const maxBodyBytes = 4 << 20
|
|
|
|
// chromeUA matches the client profile below. A Chrome fingerprint paired with a
|
|
// non-Chrome user agent is itself a signal.
|
|
const chromeUA = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 " +
|
|
"(KHTML, like Gecko) Chrome/133.0.0.0 Mobile Safari/537.36"
|
|
|
|
// TLSFetcher fetches series pages with a Chrome TLS fingerprint.
|
|
//
|
|
// Plain net/http was verified working against both sites on 2026-07-26, so this
|
|
// is not fixing an observed block — it is deliberate defence-in-depth against a
|
|
// future fingerprint-based one, chosen up front rather than reacted to later.
|
|
// The library is pure Go, so CGO_ENABLED=0, the static binary, and the
|
|
// distroless image are all unaffected.
|
|
type TLSFetcher struct {
|
|
client tls_client.HttpClient
|
|
}
|
|
|
|
var _ Fetcher = (*TLSFetcher)(nil)
|
|
|
|
func NewTLSFetcher() (*TLSFetcher, error) {
|
|
c, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(),
|
|
tls_client.WithTimeoutSeconds(30),
|
|
tls_client.WithClientProfile(profiles.Chrome_133),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("new tls client: %w", err)
|
|
}
|
|
return &TLSFetcher{client: c}, nil
|
|
}
|
|
|
|
// Get fetches url and returns the body and status. Redirects are followed: the
|
|
// demonic chapter anchors are a redirect form, and asura has moved domains
|
|
// before.
|
|
func (f *TLSFetcher) Get(ctx context.Context, url string) (string, int, error) {
|
|
req, err := fhttp.NewRequest(fhttp.MethodGet, url, nil)
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("build request %q: %w", url, err)
|
|
}
|
|
req = req.WithContext(ctx)
|
|
// Header order is part of what is being fingerprinted, so it is stated
|
|
// explicitly instead of left to Go's map iteration order.
|
|
req.Header = fhttp.Header{
|
|
"user-agent": {chromeUA},
|
|
"accept": {"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"},
|
|
"accept-language": {"en-US,en;q=0.9"},
|
|
fhttp.HeaderOrderKey: {"user-agent", "accept", "accept-language"},
|
|
}
|
|
|
|
resp, err := f.client.Do(req)
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("get %q: %w", url, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
|
|
if err != nil {
|
|
return "", resp.StatusCode, fmt.Errorf("read %q: %w", url, err)
|
|
}
|
|
return string(body), resp.StatusCode, nil
|
|
}
|