ba23411a74
Chained defects made the kagane browser-fetch path completely non-functional in Docker Compose: headless-shell's compose command re-declared --remote-debugging-port, colliding with the image's own entrypoint/socat proxy (EOF on every dial); the sidecar was then only reachable by Docker DNS name, which Chrome's DevTools HTTP handler rejects with a 500 (Host-header/DNS-rebinding check); and NewBrowserFetcher's NoModifyURL option skipped /json/version discovery entirely, dialing a bare host:port that Chrome 404s since /devtools/browser/<uuid> is minted fresh per Chrome start. Fixed by trimming the redundant command flags, pinning headless-shell to a static IP so BROWSER_WS_URL can name it directly, and removing NoModifyURL so chromedp's discovery (which echoes the request's Host back into webSocketDebuggerUrl) does the right thing on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
155 lines
5.6 KiB
Go
155 lines
5.6 KiB
Go
package latest
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"regexp"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/chromedp/cdproto/runtime"
|
|
"github.com/chromedp/chromedp"
|
|
)
|
|
|
|
// challengeTimeout bounds one navigate-and-solve. A Cloudflare managed
|
|
// challenge clears in a few seconds when it clears at all; anything longer is a
|
|
// challenge that is not going to pass, and the caller's cooldown was already
|
|
// stamped before this ran.
|
|
const challengeTimeout = 45 * time.Second
|
|
|
|
var kaganeSeriesRe = regexp.MustCompile(`^/series/([0-9a-f-]{36})/?$`)
|
|
|
|
// BrowserFetcher retrieves pages through a remote headless Chrome over the
|
|
// DevTools Protocol.
|
|
//
|
|
// It exists for one reason: kagane.to sits behind a Cloudflare JavaScript
|
|
// challenge. Verified 2026-08-03 from the deployment host, plain HTTP and
|
|
// bogdanfinn/tls-client with a Chrome_133 profile both get 403 with
|
|
// cf-mitigated: challenge on every path, including the API, robots.txt and
|
|
// images. Clearing it requires executing the challenge script, which only a
|
|
// real browser does.
|
|
//
|
|
// The request is made *inside* the page rather than by extracting cf_clearance
|
|
// and replaying it through TLSFetcher. That cookie is bound to IP, User-Agent
|
|
// and often the TLS fingerprint, so replaying it means keeping three things in
|
|
// sync that break silently and separately. The browser's own cookie jar
|
|
// persists across polls, so the challenge is solved once every few hours.
|
|
type BrowserFetcher struct {
|
|
allocCtx context.Context
|
|
cancel context.CancelFunc
|
|
// One page at a time: caps the sidecar's memory and keeps series from
|
|
// sharing page state.
|
|
mu sync.Mutex
|
|
}
|
|
|
|
var _ Fetcher = (*BrowserFetcher)(nil)
|
|
|
|
// NewBrowserFetcher connects to a headless-shell over CDP. wsURL must name the
|
|
// sidecar by IP, e.g. ws://172.28.0.10:9222 — not by Docker DNS name. Chrome's
|
|
// DevTools HTTP handler 500s any /json/version request whose Host header
|
|
// isn't an IP or "localhost" (confirmed 2026-08-03 against
|
|
// chromedp/headless-shell:stable), so the compose network pins the sidecar's
|
|
// address for this to resolve at all.
|
|
//
|
|
// Do not add chromedp.NoModifyURL here: that option skips the /json/version
|
|
// discovery request entirely and dials wsURL as if it were already the full
|
|
// debugger endpoint, but Chrome only accepts connections at
|
|
// /devtools/browser/<uuid>, a path chosen fresh at every Chrome start — dialing
|
|
// the bare host:port 404s. The default (discovery) path works precisely
|
|
// because Chrome's /json/version response echoes back the Host header of the
|
|
// discovery request in webSocketDebuggerUrl, so as long as wsURL is a
|
|
// container-reachable IP, the URL chromedp gets back already points at it.
|
|
func NewBrowserFetcher(wsURL string) (*BrowserFetcher, error) {
|
|
if wsURL == "" {
|
|
return nil, fmt.Errorf("empty browser websocket url")
|
|
}
|
|
ctx, cancel := chromedp.NewRemoteAllocator(context.Background(), wsURL)
|
|
return &BrowserFetcher{allocCtx: ctx, cancel: cancel}, nil
|
|
}
|
|
|
|
func (f *BrowserFetcher) Close() {
|
|
f.cancel()
|
|
}
|
|
|
|
// Get navigates to seriesURL, lets any challenge resolve, then reads the site's
|
|
// JSON API from inside the page so the request carries the clearance cookie.
|
|
// The returned body is API JSON, which is what latestChapterFrom's kagane case
|
|
// expects — it is not HTML.
|
|
func (f *BrowserFetcher) Get(ctx context.Context, seriesURL string) (string, int, error) {
|
|
apiURL, ok := kaganeAPIURL(seriesURL)
|
|
if !ok {
|
|
return "", 0, fmt.Errorf("not a fetchable kagane series url: %q", seriesURL)
|
|
}
|
|
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, challengeTimeout)
|
|
defer cancel()
|
|
// A fresh tab per fetch, closed on return, so one wedged page cannot
|
|
// poison later polls.
|
|
tabCtx, cancelTab := chromedp.NewContext(f.allocCtx)
|
|
defer cancelTab()
|
|
// Bind the caller's deadline to the tab.
|
|
tabCtx, cancelDeadline := context.WithCancel(tabCtx)
|
|
defer cancelDeadline()
|
|
go func() {
|
|
<-ctx.Done()
|
|
cancelDeadline()
|
|
}()
|
|
|
|
var body string
|
|
err := chromedp.Run(tabCtx,
|
|
chromedp.Navigate(seriesURL),
|
|
// The challenge reloads the page itself when it passes; waiting for the
|
|
// site's own root element is what tells us we are through it.
|
|
chromedp.WaitReady("body", chromedp.ByQuery),
|
|
chromedp.Evaluate(
|
|
`fetch(`+jsString(apiURL)+`).then(r => r.ok ? r.text() : "")`,
|
|
&body,
|
|
awaitPromise,
|
|
),
|
|
)
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("browser fetch %q: %w", seriesURL, err)
|
|
}
|
|
if body == "" {
|
|
// Challenge still up, or the API refused. Indistinguishable from here
|
|
// and handled identically by the caller.
|
|
return "", 403, nil
|
|
}
|
|
return body, 200, nil
|
|
}
|
|
|
|
// kaganeAPIURL maps a stored series_url to the JSON endpoint carrying its
|
|
// chapter list. Returning false for anything else is a second line of defence
|
|
// behind fetchableSeriesURL: a headless browser is a strong SSRF primitive and
|
|
// series_url is client-supplied, so the host is pinned here too.
|
|
func kaganeAPIURL(seriesURL string) (string, bool) {
|
|
u, err := url.Parse(seriesURL)
|
|
if err != nil || u.Scheme != "https" || u.Hostname() != "kagane.to" {
|
|
return "", false
|
|
}
|
|
m := kaganeSeriesRe.FindStringSubmatch(u.Path)
|
|
if m == nil {
|
|
return "", false
|
|
}
|
|
return "https://kagane.to/api/v2/series/" + m[1], true
|
|
}
|
|
|
|
// awaitPromise makes Evaluate resolve the promise rather than returning a
|
|
// serialised Promise object.
|
|
func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams {
|
|
return p.WithAwaitPromise(true)
|
|
}
|
|
|
|
// jsString renders s as a JavaScript string literal for embedding in an
|
|
// Evaluate expression. The URL is host-pinned by kaganeAPIURL before it gets
|
|
// here, but quoting it properly is what keeps that guarantee intact.
|
|
func jsString(s string) string {
|
|
b, _ := json.Marshal(s)
|
|
return string(b)
|
|
}
|