194 lines
7.1 KiB
Go
194 lines
7.1 KiB
Go
package fa
|
|
|
|
import (
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
// Cookies are FA's session cookies as they appear in a browser's storage.
|
|
// Both A and B are required for authenticated requests; either may be empty
|
|
// for unauthenticated browsing of public pages.
|
|
type Cookies struct {
|
|
A string
|
|
B string
|
|
}
|
|
|
|
// CFCookies carries the Cloudflare clearance cookie obtained from a real
|
|
// browser. Cloudflare binds Clearance to the exact User-Agent string that
|
|
// produced it, so callers must also pass [WithUserAgent] with that UA.
|
|
type CFCookies struct {
|
|
Clearance string
|
|
}
|
|
|
|
// SFWMode controls whether FA serves the "safe for work" filtered view
|
|
// that hides mature and adult submission thumbnails. The site exposes this
|
|
// as a slider in the navbar; under the hood it writes a single `sfw`
|
|
// cookie that the page-render code reads server-side.
|
|
//
|
|
// Note: SFW mode does not change which submissions appear in listings —
|
|
// adult items still show up, but their thumbnails are replaced with a
|
|
// "blocked content" placeholder and the file URL is hidden. To filter
|
|
// listings by rating, use the browse/search rating filters (M4).
|
|
type SFWMode int
|
|
|
|
const (
|
|
// SFWAuto leaves the cookie alone. FA falls back to the account's
|
|
// saved preference (or, for anonymous clients, defaults to NSFW
|
|
// visible). Use this when you don't want the SDK to override what the
|
|
// user clicked in the browser.
|
|
SFWAuto SFWMode = iota
|
|
|
|
// SFWOn sets `sfw=1` mature and adult content is blocked from
|
|
// rendering on all pages this client fetches.
|
|
SFWOn
|
|
|
|
// SFWOff sets `sfw=0` mature and adult content renders fully. This
|
|
// is FA's default for logged-in adult-verified accounts.
|
|
SFWOff
|
|
)
|
|
|
|
// Option configures a [Client] at construction time.
|
|
type Option func(*config)
|
|
|
|
// config is the internal, fully-resolved client configuration. Defaults are
|
|
// applied in New before options run.
|
|
type config struct {
|
|
cookies Cookies
|
|
cf CFCookies
|
|
sfw SFWMode
|
|
userAgent string
|
|
rateInterval time.Duration
|
|
rateBurst int
|
|
logger *slog.Logger
|
|
httpClient *http.Client
|
|
maxRetries int
|
|
jsonListings bool
|
|
priorityRL bool
|
|
}
|
|
|
|
// defaultUserAgent identifies this SDK. Callers should override it with
|
|
// WithUserAgent most importantly so it matches the UA that produced their
|
|
// cf_clearance cookie.
|
|
const defaultUserAgent = "go-fa-api/0.1 (+https://git.anthrove.art/public/go-fa-api)"
|
|
|
|
// WithCookies sets the FA session cookies (a, b). Without these, requests to
|
|
// authenticated endpoints will surface as [ErrUnauthorized].
|
|
func WithCookies(c Cookies) Option {
|
|
return func(cfg *config) { cfg.cookies = c }
|
|
}
|
|
|
|
// WithCloudflare sets the cf_clearance cookie used to satisfy Cloudflare's
|
|
// challenges. Must be paired with [WithUserAgent] using the same UA the
|
|
// cookie was issued under.
|
|
func WithCloudflare(c CFCookies) Option {
|
|
return func(cfg *config) { cfg.cf = c }
|
|
}
|
|
|
|
// WithSFW overrides the account's saved SFW preference for this Client.
|
|
// Pass [SFWOn] to force the SFW filter on, [SFWOff] to force it off, or
|
|
// [SFWAuto] (the default) to leave it unset so the account or anonymous
|
|
// default takes effect.
|
|
func WithSFW(mode SFWMode) Option {
|
|
return func(cfg *config) { cfg.sfw = mode }
|
|
}
|
|
|
|
// WithUserAgent overrides the default User-Agent header. When using a
|
|
// cf_clearance cookie, this must match the browser UA the cookie came from.
|
|
func WithUserAgent(ua string) Option {
|
|
return func(cfg *config) { cfg.userAgent = ua }
|
|
}
|
|
|
|
// WithRateLimit sets the request interval and burst size for the token bucket
|
|
// that guards every HTTP request the SDK makes. The default (1s, burst 1) is
|
|
// the safest setting for FurAffinity; tightening it risks Cloudflare bans.
|
|
func WithRateLimit(interval time.Duration, burst int) Option {
|
|
return func(cfg *config) {
|
|
cfg.rateInterval = interval
|
|
cfg.rateBurst = burst
|
|
}
|
|
}
|
|
|
|
// WithRequestsPerSecond is a shorthand for [WithRateLimit] when you think in
|
|
// terms of throughput. A value of 0.5 means one request every two seconds.
|
|
func WithRequestsPerSecond(rps float64) Option {
|
|
return func(cfg *config) {
|
|
if rps <= 0 {
|
|
return
|
|
}
|
|
cfg.rateInterval = time.Duration(float64(time.Second) / rps)
|
|
cfg.rateBurst = 1
|
|
}
|
|
}
|
|
|
|
// WithLogger attaches a structured logger. The SDK emits debug records for
|
|
// retries and rate-limit waits; nothing is logged at info level.
|
|
func WithLogger(l *slog.Logger) Option {
|
|
return func(cfg *config) {
|
|
if l != nil {
|
|
cfg.logger = l
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithHTTPClient lets callers supply a fully constructed *http.Client. Useful
|
|
// for tests (httptest.Server) and for plugging in custom transports such as
|
|
// uTLS. The SDK still wraps the client's Transport with its rate-limited
|
|
// transport supply http.DefaultTransport for the default behaviour.
|
|
func WithHTTPClient(hc *http.Client) Option {
|
|
return func(cfg *config) { cfg.httpClient = hc }
|
|
}
|
|
|
|
// WithMaxRetries caps the number of automatic retry attempts on 429/5xx.
|
|
// Defaults to 3. Set to 0 to disable retries entirely.
|
|
func WithMaxRetries(n int) Option {
|
|
return func(cfg *config) {
|
|
if n >= 0 {
|
|
cfg.maxRetries = n
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithExperimentalJSONListings opts into the JSON-first merge strategy for
|
|
// listing-page parsers (Gallery / Scraps / Favorites / Browse / Search /
|
|
// SubmissionInbox).
|
|
//
|
|
// When enabled, the parser first reads the <script id="js-submissionData">
|
|
// blob FA embeds on every listing page and uses it as the primary source
|
|
// for title, author display name, URL-safe login, and avatar URL. HTML
|
|
// scraping fills in fields the JSON doesn't carry (ID, rating, thumbnail).
|
|
// If the script is absent or malformed on a given page, the parser
|
|
// transparently falls back to pure HTML scraping caller sees no error.
|
|
//
|
|
// This is gated as "experimental" because: (1) the JSON's description
|
|
// field carries BBCode rather than HTML and is occasionally truncated, so
|
|
// we intentionally don't use it; (2) FA could remove the script tag at
|
|
// any time, in which case the fallback path is what actually runs.
|
|
//
|
|
// Defaults to false. Most callers should leave it off until selector
|
|
// drift becomes a real problem.
|
|
func WithExperimentalJSONListings(enabled bool) Option {
|
|
return func(cfg *config) { cfg.jsonListings = enabled }
|
|
}
|
|
|
|
// WithPrioritizedRateLimiting enables multi-level priority on the client's
|
|
// rate limiter. It is an opt-in feature; by default the limiter serves every
|
|
// request in plain FIFO order.
|
|
//
|
|
// When enabled, each request is scheduled according to the [Priority] marker
|
|
// on its context (see [WithPriority] and [WithBackgroundPriority]): the
|
|
// limiter serves a higher-priority request before a lower-priority one. A
|
|
// request with no marker is [PriorityNormal]. The overall pace is unchanged —
|
|
// there is still a single global token bucket, since FA rate-limits per
|
|
// account only the order in which competing requests are served.
|
|
//
|
|
// Enable this when the application mixes user-driven requests with
|
|
// best-effort background work (preloading, crawling) and wants the former to
|
|
// never wait behind the latter. With the feature disabled, [Priority] markers
|
|
// are inert.
|
|
//
|
|
// Defaults to false.
|
|
func WithPrioritizedRateLimiting(enabled bool) Option {
|
|
return func(cfg *config) { cfg.priorityRL = enabled }
|
|
}
|