inital commit
This commit is contained in:
189
.gitingore
Normal file
189
.gitingore
Normal file
@@ -0,0 +1,189 @@
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/go,goland+all,linux,windows,macos
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=go,goland+all,linux,windows,macos
|
||||
|
||||
### Go ###
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
### GoLand+all ###
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
|
||||
# AWS User-specific
|
||||
.idea/**/aws.xml
|
||||
|
||||
# Generated files
|
||||
.idea/**/contentModel.xml
|
||||
|
||||
# Sensitive or high-churn files
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
|
||||
# Gradle
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
|
||||
# Gradle and Maven with auto-import
|
||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||
# since they will be recreated, and may cause churn. Uncomment if using
|
||||
# auto-import.
|
||||
# .idea/artifacts
|
||||
# .idea/compiler.xml
|
||||
# .idea/jarRepositories.xml
|
||||
# .idea/modules.xml
|
||||
# .idea/*.iml
|
||||
# .idea/modules
|
||||
# *.iml
|
||||
# *.ipr
|
||||
|
||||
# CMake
|
||||
cmake-build-*/
|
||||
|
||||
# Mongo Explorer plugin
|
||||
.idea/**/mongoSettings.xml
|
||||
|
||||
# File-based project format
|
||||
*.iws
|
||||
|
||||
# IntelliJ
|
||||
out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Cursive Clojure plugin
|
||||
.idea/replstate.xml
|
||||
|
||||
# SonarLint plugin
|
||||
.idea/sonarlint/
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
# Editor-based Rest Client
|
||||
.idea/httpRequests
|
||||
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
### GoLand+all Patch ###
|
||||
# Ignore everything but code style settings and run configurations
|
||||
# that are supposed to be shared within teams.
|
||||
|
||||
.idea/*
|
||||
|
||||
!.idea/codeStyles
|
||||
!.idea/runConfigurations
|
||||
|
||||
### Linux ###
|
||||
*~
|
||||
|
||||
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||
.fuse_hidden*
|
||||
|
||||
# KDE directory preferences
|
||||
.directory
|
||||
|
||||
# Linux trash folder which might appear on any partition or disk
|
||||
.Trash-*
|
||||
|
||||
# .nfs files are created when an open file is removed but is still being accessed
|
||||
.nfs*
|
||||
|
||||
### macOS ###
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
### macOS Patch ###
|
||||
# iCloud generated files
|
||||
*.icloud
|
||||
|
||||
### Windows ###
|
||||
# Windows thumbnail cache files
|
||||
Thumbs.db
|
||||
Thumbs.db:encryptable
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
|
||||
# Dump file
|
||||
*.stackdump
|
||||
|
||||
# Folder config file
|
||||
[Dd]esktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/go,goland+all,linux,windows,macos
|
||||
34
actions.go
34
actions.go
@@ -20,20 +20,20 @@ import (
|
||||
// on the "+Fav" anchor on the submission page. We fetch the submission to
|
||||
// scrape the key, then follow the link. The whole exchange happens
|
||||
// through the rate-limited transport.
|
||||
func (c *Client) Fav(ctx context.Context, id SubmissionID) error {
|
||||
return c.toggleFavorite(ctx, id, true)
|
||||
func (c *Client) Fav(ctx context.Context, id SubmissionID, opts ...Option) error {
|
||||
return c.toggleFavorite(ctx, id, true, opts)
|
||||
}
|
||||
|
||||
// Unfav removes a submission from the logged-in user's favorites.
|
||||
// Idempotent: if not favorited, no-op.
|
||||
func (c *Client) Unfav(ctx context.Context, id SubmissionID) error {
|
||||
return c.toggleFavorite(ctx, id, false)
|
||||
func (c *Client) Unfav(ctx context.Context, id SubmissionID, opts ...Option) error {
|
||||
return c.toggleFavorite(ctx, id, false, opts)
|
||||
}
|
||||
|
||||
// toggleFavorite implements both Fav and Unfav: scrape the per-state link
|
||||
// off the submission page and follow it. wantFav=true means "fav if not
|
||||
// already faved"; wantFav=false means "unfav if currently faved".
|
||||
func (c *Client) toggleFavorite(ctx context.Context, id SubmissionID, wantFav bool) error {
|
||||
func (c *Client) toggleFavorite(ctx context.Context, id SubmissionID, wantFav bool, reqOpts []Option) error {
|
||||
if id <= 0 {
|
||||
return fmt.Errorf("fa: toggleFavorite: id must be > 0")
|
||||
}
|
||||
@@ -54,30 +54,30 @@ func (c *Client) toggleFavorite(ctx context.Context, id SubmissionID, wantFav bo
|
||||
return fmt.Errorf("%w: submission %d: no fav/unfav link on page (not logged in?)", ErrUnauthorized, id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}, reqOpts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadyInDesiredState {
|
||||
return nil
|
||||
}
|
||||
return c.followAction(ctx, actionURL)
|
||||
return c.followAction(ctx, actionURL, reqOpts...)
|
||||
}
|
||||
|
||||
// Watch starts watching a user. Idempotent: if already watching, no-op.
|
||||
// The key + endpoint live on a button on the user's profile (or any page
|
||||
// that user is the "owner" of, like a journal). We scrape the profile.
|
||||
func (c *Client) Watch(ctx context.Context, name string) error {
|
||||
return c.toggleWatch(ctx, name, true)
|
||||
func (c *Client) Watch(ctx context.Context, name string, opts ...Option) error {
|
||||
return c.toggleWatch(ctx, name, true, opts)
|
||||
}
|
||||
|
||||
// Unwatch stops watching a user. Idempotent.
|
||||
func (c *Client) Unwatch(ctx context.Context, name string) error {
|
||||
return c.toggleWatch(ctx, name, false)
|
||||
func (c *Client) Unwatch(ctx context.Context, name string, opts ...Option) error {
|
||||
return c.toggleWatch(ctx, name, false, opts)
|
||||
}
|
||||
|
||||
// toggleWatch fetches the user page, picks watch or unwatch link by state.
|
||||
func (c *Client) toggleWatch(ctx context.Context, name string, wantWatch bool) error {
|
||||
func (c *Client) toggleWatch(ctx context.Context, name string, wantWatch bool, reqOpts []Option) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return fmt.Errorf("fa: toggleWatch: empty name")
|
||||
@@ -99,14 +99,14 @@ func (c *Client) toggleWatch(ctx context.Context, name string, wantWatch bool) e
|
||||
return fmt.Errorf("%w: user %q: no watch/unwatch link on page", ErrUnauthorized, name)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}, reqOpts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadyInDesiredState {
|
||||
return nil
|
||||
}
|
||||
return c.followAction(ctx, actionURL)
|
||||
return c.followAction(ctx, actionURL, reqOpts...)
|
||||
}
|
||||
|
||||
// findFavLinks scans a submission view page for the "+Fav" and "−Fav"
|
||||
@@ -155,10 +155,11 @@ func findWatchLinks(doc *goquery.Document, name string) (watchURL, unwatchURL st
|
||||
// the transport+classifier; 5xx becomes HTTPError. We don't parse the
|
||||
// response body FA's success states are too varied to verify reliably
|
||||
// from HTML alone.
|
||||
func (c *Client) followAction(ctx context.Context, actionURL string) error {
|
||||
func (c *Client) followAction(ctx context.Context, actionURL string, opts ...Option) error {
|
||||
if actionURL == "" {
|
||||
return fmt.Errorf("fa: followAction: empty URL")
|
||||
}
|
||||
ctx = c.applyRequestOptions(ctx, opts)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, actionURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -192,7 +193,8 @@ func (c *Client) followAction(ctx context.Context, actionURL string) error {
|
||||
//
|
||||
// Returns the response body for callers that need to parse a confirmation
|
||||
// (e.g., to extract a newly-posted comment's ID).
|
||||
func (c *Client) postForm(ctx context.Context, rawURL string, form url.Values) ([]byte, error) {
|
||||
func (c *Client) postForm(ctx context.Context, rawURL string, form url.Values, opts ...Option) ([]byte, error) {
|
||||
ctx = c.applyRequestOptions(ctx, opts)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -62,7 +62,7 @@ type BrowseOptions struct {
|
||||
// instead uses GET with ?page=N, which is honoured for the rendered HTML.
|
||||
// "Next page exists?" is inferred from item count: a full page
|
||||
// (browsePerPage items) means we keep going; anything less is the tail.
|
||||
func (c *Client) Browse(ctx context.Context, opts BrowseOptions) iter.Seq2[*Submission, error] {
|
||||
func (c *Client) Browse(ctx context.Context, opts BrowseOptions, reqOpts ...Option) iter.Seq2[*Submission, error] {
|
||||
return func(yield func(*Submission, error) bool) {
|
||||
page := opts.StartPage
|
||||
if page < 1 {
|
||||
@@ -78,7 +78,7 @@ func (c *Client) Browse(ctx context.Context, opts BrowseOptions) iter.Seq2[*Subm
|
||||
err := c.fetch(ctx, pageURL, func(doc *goquery.Document) error {
|
||||
items, _ = parseGalleryPage(doc, c.cfg.jsonListings)
|
||||
return nil
|
||||
})
|
||||
}, reqOpts...)
|
||||
if err != nil {
|
||||
yield(nil, err)
|
||||
return
|
||||
|
||||
@@ -142,7 +142,8 @@ func seedJar(jar http.CookieJar, fa Cookies, cf CFCookies, sfw SFWMode) {
|
||||
// Context cancellation propagates through the http.Request and the rate
|
||||
// limiter a cancelled ctx surfaces from Wait or from the underlying
|
||||
// transport, depending on which phase the request is in.
|
||||
func (c *Client) fetch(ctx context.Context, rawURL string, parse func(doc *goquery.Document) error) error {
|
||||
func (c *Client) fetch(ctx context.Context, rawURL string, parse func(doc *goquery.Document) error, opts ...Option) error {
|
||||
ctx = c.applyRequestOptions(ctx, opts)
|
||||
clone := c.collector.Clone()
|
||||
clone.SetClient(c.http)
|
||||
clone.SetCookieJar(c.jar)
|
||||
|
||||
12
comment.go
12
comment.go
@@ -28,25 +28,25 @@ type Comment struct {
|
||||
// Comments aren't paginated, so this iterator performs one fetch and then
|
||||
// yields each comment in document order; early termination still avoids
|
||||
// processing the rest of the slice.
|
||||
func (c *Client) SubmissionComments(ctx context.Context, id SubmissionID) iter.Seq2[*Comment, error] {
|
||||
return c.yieldComments(ctx, urls.Submission(int64(id)))
|
||||
func (c *Client) SubmissionComments(ctx context.Context, id SubmissionID, opts ...Option) iter.Seq2[*Comment, error] {
|
||||
return c.yieldComments(ctx, urls.Submission(int64(id)), opts)
|
||||
}
|
||||
|
||||
// JournalComments yields every comment on a journal page. Same iteration
|
||||
// shape as [Client.SubmissionComments].
|
||||
func (c *Client) JournalComments(ctx context.Context, id JournalID) iter.Seq2[*Comment, error] {
|
||||
return c.yieldComments(ctx, urls.Journal(int64(id)))
|
||||
func (c *Client) JournalComments(ctx context.Context, id JournalID, opts ...Option) iter.Seq2[*Comment, error] {
|
||||
return c.yieldComments(ctx, urls.Journal(int64(id)), opts)
|
||||
}
|
||||
|
||||
// yieldComments performs the single fetch shared by submission and journal
|
||||
// comment iterators, then yields parsed comments to the caller.
|
||||
func (c *Client) yieldComments(ctx context.Context, pageURL string) iter.Seq2[*Comment, error] {
|
||||
func (c *Client) yieldComments(ctx context.Context, pageURL string, opts []Option) iter.Seq2[*Comment, error] {
|
||||
return func(yield func(*Comment, error) bool) {
|
||||
var comments []*Comment
|
||||
err := c.fetch(ctx, pageURL, func(doc *goquery.Document) error {
|
||||
comments = parseComments(doc)
|
||||
return nil
|
||||
})
|
||||
}, opts...)
|
||||
if err != nil {
|
||||
yield(nil, err)
|
||||
return
|
||||
|
||||
@@ -24,26 +24,26 @@ type PostCommentOptions struct {
|
||||
// URL with fields `action=reply`, `replyto=<parent>`, `reply=<body>`.
|
||||
// There is no separate per-form CSRF key auth cookies + Cloudflare
|
||||
// clearance are the only gates.
|
||||
func (c *Client) PostSubmissionComment(ctx context.Context, id SubmissionID, body string, opts PostCommentOptions) error {
|
||||
func (c *Client) PostSubmissionComment(ctx context.Context, id SubmissionID, body string, opts PostCommentOptions, reqOpts ...Option) error {
|
||||
if id <= 0 {
|
||||
return fmt.Errorf("fa: PostSubmissionComment: id must be > 0")
|
||||
}
|
||||
return c.postCommentForm(ctx, urls.Submission(int64(id)), body, opts)
|
||||
return c.postCommentForm(ctx, urls.Submission(int64(id)), body, opts, reqOpts)
|
||||
}
|
||||
|
||||
// PostJournalComment posts a comment on a journal. Same form shape as
|
||||
// submissions; the form action just points at /journal/{id}/.
|
||||
func (c *Client) PostJournalComment(ctx context.Context, id JournalID, body string, opts PostCommentOptions) error {
|
||||
func (c *Client) PostJournalComment(ctx context.Context, id JournalID, body string, opts PostCommentOptions, reqOpts ...Option) error {
|
||||
if id <= 0 {
|
||||
return fmt.Errorf("fa: PostJournalComment: id must be > 0")
|
||||
}
|
||||
return c.postCommentForm(ctx, urls.Journal(int64(id)), body, opts)
|
||||
return c.postCommentForm(ctx, urls.Journal(int64(id)), body, opts, reqOpts)
|
||||
}
|
||||
|
||||
// postCommentForm builds the field set #add_comment_form sends. Shared
|
||||
// between submission and journal comment posting because FA renders an
|
||||
// identical form on both pages.
|
||||
func (c *Client) postCommentForm(ctx context.Context, pageURL, body string, opts PostCommentOptions) error {
|
||||
func (c *Client) postCommentForm(ctx context.Context, pageURL, body string, opts PostCommentOptions, reqOpts []Option) error {
|
||||
if body == "" {
|
||||
return fmt.Errorf("fa: PostComment: empty body")
|
||||
}
|
||||
@@ -57,6 +57,6 @@ func (c *Client) postCommentForm(ctx context.Context, pageURL, body string, opts
|
||||
}
|
||||
v.Set("reply", body)
|
||||
v.Set("submit", "Post Comment")
|
||||
_, err := c.postForm(ctx, pageURL, v)
|
||||
_, err := c.postForm(ctx, pageURL, v, reqOpts...)
|
||||
return err
|
||||
}
|
||||
|
||||
113
examples/multiuser/main.go
Normal file
113
examples/multiuser/main.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// multiuser demonstrates the per-request credentials pattern: a single
|
||||
// fa.Client (one IP, one shared rate limiter) servicing many end users by
|
||||
// passing their cookies as per-call Options that override the client's
|
||||
// defaults.
|
||||
//
|
||||
// Run with one or more FA accounts encoded as comma-separated A:B:CF
|
||||
// tuples, e.g.
|
||||
//
|
||||
// FA_USERS="aCookieA:bCookieA:cfClearanceA,aCookieB:bCookieB:" \
|
||||
// FA_UA="Mozilla/5.0 ..." \
|
||||
// go run ./examples/multiuser 12345678
|
||||
//
|
||||
// The CF clearance is optional per user (empty third field is fine). FA_UA
|
||||
// must match the UA the cf_clearance cookies were issued under.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
fa "git.anthrove.art/public/go-fa-api"
|
||||
)
|
||||
|
||||
// UserCreds is whatever your storage layer hands back per end user.
|
||||
type UserCreds struct {
|
||||
Label string
|
||||
A, B string
|
||||
CFClearance string
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
log.Fatalf("usage: %s <submission-id>", os.Args[0])
|
||||
}
|
||||
id, err := strconv.ParseInt(os.Args[1], 10, 64)
|
||||
if err != nil {
|
||||
log.Fatalf("invalid submission id: %v", err)
|
||||
}
|
||||
|
||||
raw := os.Getenv("FA_USERS")
|
||||
if raw == "" {
|
||||
log.Fatal("FA_USERS must be set (comma-separated A:B:CF tuples)")
|
||||
}
|
||||
users := parseUsers(raw)
|
||||
if len(users) == 0 {
|
||||
log.Fatal("FA_USERS parsed to zero users")
|
||||
}
|
||||
|
||||
ua := envOr("FA_UA", "go-fa-api-example/0.1")
|
||||
|
||||
// One client. Built once at startup. The single rate limiter inside is
|
||||
// what protects the shared egress IP from Cloudflare bans no matter
|
||||
// how many users we add, the combined request rate stays at WithRPS.
|
||||
client := fa.New(
|
||||
fa.WithUserAgent(ua),
|
||||
fa.WithRequestsPerSecond(1),
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
for _, u := range users {
|
||||
start := time.Now()
|
||||
sub, err := client.GetSubmission(ctx, fa.SubmissionID(id),
|
||||
// Per-call overrides win over the client's defaults. The shared
|
||||
// limiter still gates this request, so user B cannot starve user
|
||||
// A and the combined rate never exceeds WithRequestsPerSecond.
|
||||
fa.WithCookies(fa.Cookies{A: u.A, B: u.B}),
|
||||
fa.WithCloudflare(fa.CFCookies{Clearance: u.CFClearance}),
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("[%s] GetSubmission: %v", u.Label, err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("[%s] %s by %s (%s) fetched in %v\n",
|
||||
u.Label, sub.Title, sub.Author.DisplayName, sub.Rating, time.Since(start).Round(time.Millisecond))
|
||||
}
|
||||
}
|
||||
|
||||
func parseUsers(raw string) []UserCreds {
|
||||
var out []UserCreds
|
||||
for i, part := range strings.Split(raw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.SplitN(part, ":", 3)
|
||||
if len(fields) < 2 {
|
||||
log.Printf("FA_USERS[%d]: skipping malformed entry %q", i, part)
|
||||
continue
|
||||
}
|
||||
u := UserCreds{
|
||||
Label: fmt.Sprintf("user%d", i+1),
|
||||
A: strings.TrimSpace(fields[0]),
|
||||
B: strings.TrimSpace(fields[1]),
|
||||
}
|
||||
if len(fields) == 3 {
|
||||
u.CFClearance = strings.TrimSpace(fields[2])
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func main() {
|
||||
fa.WithUserAgent(envOr("FA_UA", "go-fa-api-example/0.1")),
|
||||
)
|
||||
|
||||
n, err := client.Notifications(context.Background())
|
||||
n, err := client.Notifications(context.Background(), fa.NotificationsOptions{})
|
||||
if err != nil {
|
||||
log.Fatalf("Notifications: %v", err)
|
||||
}
|
||||
|
||||
15
gallery.go
15
gallery.go
@@ -14,20 +14,20 @@ import (
|
||||
// Each yielded *Submission carries only the fields visible on the listing
|
||||
// page: ID, Title, Author (for favorites), ThumbURL, and Rating. Call
|
||||
// [Client.GetSubmission] with the ID to load the full record.
|
||||
func (c *Client) Gallery(ctx context.Context, name string, opts ListOptions) iter.Seq2[*Submission, error] {
|
||||
return c.listGallerySection(ctx, name, urls.Gallery, opts)
|
||||
func (c *Client) Gallery(ctx context.Context, name string, opts ListOptions, reqOpts ...Option) iter.Seq2[*Submission, error] {
|
||||
return c.listGallerySection(ctx, name, urls.Gallery, opts, reqOpts)
|
||||
}
|
||||
|
||||
// Scraps iterates the user's scraps folder. Same yield shape as Gallery.
|
||||
func (c *Client) Scraps(ctx context.Context, name string, opts ListOptions) iter.Seq2[*Submission, error] {
|
||||
return c.listGallerySection(ctx, name, urls.Scraps, opts)
|
||||
func (c *Client) Scraps(ctx context.Context, name string, opts ListOptions, reqOpts ...Option) iter.Seq2[*Submission, error] {
|
||||
return c.listGallerySection(ctx, name, urls.Scraps, opts, reqOpts)
|
||||
}
|
||||
|
||||
// Favorites iterates the user's favorited submissions. The yielded
|
||||
// *Submission's Author field reflects the original artist (not the user
|
||||
// whose favorites we are walking).
|
||||
func (c *Client) Favorites(ctx context.Context, name string, opts ListOptions) iter.Seq2[*Submission, error] {
|
||||
return c.listGallerySection(ctx, name, urls.Favorites, opts)
|
||||
func (c *Client) Favorites(ctx context.Context, name string, opts ListOptions, reqOpts ...Option) iter.Seq2[*Submission, error] {
|
||||
return c.listGallerySection(ctx, name, urls.Favorites, opts, reqOpts)
|
||||
}
|
||||
|
||||
// listGallerySection is the shared engine for Gallery / Scraps / Favorites.
|
||||
@@ -38,6 +38,7 @@ func (c *Client) listGallerySection(
|
||||
name string,
|
||||
urlFn func(string, int) string,
|
||||
opts ListOptions,
|
||||
reqOpts []Option,
|
||||
) iter.Seq2[*Submission, error] {
|
||||
return func(yield func(*Submission, error) bool) {
|
||||
page := opts.firstPage()
|
||||
@@ -53,7 +54,7 @@ func (c *Client) listGallerySection(
|
||||
err := c.fetch(ctx, urlFn(name, page), func(doc *goquery.Document) error {
|
||||
items, hasNext = parseGalleryPage(doc, c.cfg.jsonListings)
|
||||
return nil
|
||||
})
|
||||
}, reqOpts...)
|
||||
if err != nil {
|
||||
yield(nil, err)
|
||||
return
|
||||
|
||||
4
inbox.go
4
inbox.go
@@ -34,7 +34,7 @@ import (
|
||||
// ListOptions.StartPage is ignored the inbox is cursor-paginated by
|
||||
// FA (the "Next 72" link encodes a from-id), not page-numbered, so there
|
||||
// is nothing meaningful to start from.
|
||||
func (c *Client) SubmissionInbox(ctx context.Context, opts ListOptions) iter.Seq2[*Submission, error] {
|
||||
func (c *Client) SubmissionInbox(ctx context.Context, opts ListOptions, reqOpts ...Option) iter.Seq2[*Submission, error] {
|
||||
return func(yield func(*Submission, error) bool) {
|
||||
nextURL := urls.MsgSubmissions()
|
||||
pagesFetched := 0
|
||||
@@ -58,7 +58,7 @@ func (c *Client) SubmissionInbox(ctx context.Context, opts ListOptions) iter.Seq
|
||||
err := c.fetch(ctx, nextURL, func(doc *goquery.Document) error {
|
||||
items, next = parseSubmissionInboxPage(doc, c.cfg.jsonListings)
|
||||
return nil
|
||||
})
|
||||
}, reqOpts...)
|
||||
if err != nil {
|
||||
yield(nil, err)
|
||||
return
|
||||
|
||||
@@ -22,7 +22,7 @@ type Journal struct {
|
||||
}
|
||||
|
||||
// GetJournal fetches a journal entry by its numeric ID.
|
||||
func (c *Client) GetJournal(ctx context.Context, id JournalID) (*Journal, error) {
|
||||
func (c *Client) GetJournal(ctx context.Context, id JournalID, opts ...Option) (*Journal, error) {
|
||||
if id <= 0 {
|
||||
return nil, fmt.Errorf("fa: GetJournal: id must be > 0")
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func (c *Client) GetJournal(ctx context.Context, id JournalID) (*Journal, error)
|
||||
}
|
||||
out = j
|
||||
return nil
|
||||
})
|
||||
}, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func (c *Client) GetJournal(ctx context.Context, id JournalID) (*Journal, error)
|
||||
// each [Journal] preview (full body included on FA's listing page).
|
||||
//
|
||||
// Use [ListOptions.MaxPages] to bound the crawl.
|
||||
func (c *Client) UserJournals(ctx context.Context, name string, opts ListOptions) iter.Seq2[*Journal, error] {
|
||||
func (c *Client) UserJournals(ctx context.Context, name string, opts ListOptions, reqOpts ...Option) iter.Seq2[*Journal, error] {
|
||||
return func(yield func(*Journal, error) bool) {
|
||||
page := opts.firstPage()
|
||||
pagesFetched := 0
|
||||
@@ -60,7 +60,7 @@ func (c *Client) UserJournals(ctx context.Context, name string, opts ListOptions
|
||||
err := c.fetch(ctx, urls.UserJournals(name, page), func(doc *goquery.Document) error {
|
||||
items, hasNext = parseUserJournalsPage(doc)
|
||||
return nil
|
||||
})
|
||||
}, reqOpts...)
|
||||
if err != nil {
|
||||
yield(nil, err)
|
||||
return
|
||||
|
||||
8
notes.go
8
notes.go
@@ -51,7 +51,7 @@ type Note struct {
|
||||
// (follow-the-Next-link), not page-numbered fetches.
|
||||
//
|
||||
// Requires a logged-in client.
|
||||
func (c *Client) Notes(ctx context.Context, opts ListOptions) iter.Seq2[*NotePreview, error] {
|
||||
func (c *Client) Notes(ctx context.Context, opts ListOptions, reqOpts ...Option) iter.Seq2[*NotePreview, error] {
|
||||
return func(yield func(*NotePreview, error) bool) {
|
||||
nextURL := urls.MsgPMs()
|
||||
pagesFetched := 0
|
||||
@@ -66,7 +66,7 @@ func (c *Client) Notes(ctx context.Context, opts ListOptions) iter.Seq2[*NotePre
|
||||
err := c.fetch(ctx, nextURL, func(doc *goquery.Document) error {
|
||||
items, next = parseNotesInboxPage(doc)
|
||||
return nil
|
||||
})
|
||||
}, reqOpts...)
|
||||
if err != nil {
|
||||
yield(nil, err)
|
||||
return
|
||||
@@ -87,7 +87,7 @@ func (c *Client) Notes(ctx context.Context, opts ListOptions) iter.Seq2[*NotePre
|
||||
|
||||
// GetNote fetches a single note (private message) by ID. Requires a
|
||||
// logged-in client.
|
||||
func (c *Client) GetNote(ctx context.Context, id NoteID) (*Note, error) {
|
||||
func (c *Client) GetNote(ctx context.Context, id NoteID, opts ...Option) (*Note, error) {
|
||||
if id <= 0 {
|
||||
return nil, fmt.Errorf("fa: GetNote: id must be > 0")
|
||||
}
|
||||
@@ -99,7 +99,7 @@ func (c *Client) GetNote(ctx context.Context, id NoteID) (*Note, error) {
|
||||
}
|
||||
out = n
|
||||
return nil
|
||||
})
|
||||
}, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
// Returns nil on success. ErrUnauthorized when not logged in.
|
||||
// *SystemMessageError when FA rejects the send (recipient blocked, rate
|
||||
// limited, etc.).
|
||||
func (c *Client) SendNote(ctx context.Context, to string, subject string, body string) error {
|
||||
func (c *Client) SendNote(ctx context.Context, to string, subject string, body string, opts ...Option) error {
|
||||
to = strings.TrimSpace(to)
|
||||
if to == "" {
|
||||
return fmt.Errorf("fa: SendNote: empty recipient")
|
||||
@@ -42,7 +42,7 @@ func (c *Client) SendNote(ctx context.Context, to string, subject string, body s
|
||||
return fmt.Errorf("%w: SendNote: could not locate form key on /msg/pms/", ErrUnauthorized)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}, opts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func (c *Client) SendNote(ctx context.Context, to string, subject string, body s
|
||||
v.Set("subject", subject)
|
||||
v.Set("message", body)
|
||||
v.Set("send", "Send Note")
|
||||
_, err = c.postForm(ctx, urls.Host+"/msg/send/", v)
|
||||
_, err = c.postForm(ctx, urls.Host+"/msg/send/", v, opts...)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -69,55 +69,41 @@ type ShoutNotification struct {
|
||||
PostedAt time.Time
|
||||
}
|
||||
|
||||
// NotificationsOption tunes a single [Client.Notifications] call.
|
||||
type NotificationsOption func(*notificationsConfig)
|
||||
// NotificationsOptions tunes a single [Client.Notifications] call. Match
|
||||
// the pattern used by [BrowseOptions] / [SearchOptions] / [ListOptions]: a
|
||||
// plain struct of zero-default fields, kept distinct from the per-request
|
||||
// [Option] values that swap creds.
|
||||
type NotificationsOptions struct {
|
||||
// ResolveAvatars makes [Client.Notifications] fill in author avatar
|
||||
// URLs that FurAffinity omits from the /msg/others/ markup.
|
||||
//
|
||||
// FA does not render an avatar image on every notification row
|
||||
// journal notifications in particular are a purely textual list, so
|
||||
// their author UserRef comes back with AvatarURL == "". When this is
|
||||
// set, the SDK resolves authors by fetching each one's profile page.
|
||||
//
|
||||
// Each distinct author costs one extra HTTP request, deduplicated
|
||||
// within the call and serialized through the client's rate limiter.
|
||||
ResolveAvatars bool
|
||||
|
||||
type notificationsConfig struct {
|
||||
resolveAvatars bool
|
||||
avatarLimit int
|
||||
}
|
||||
|
||||
// WithResolvedAvatars makes [Client.Notifications] fill in author avatar
|
||||
// URLs that FurAffinity omits from the /msg/others/ markup.
|
||||
//
|
||||
// FA does not render an avatar image on every notification row journal
|
||||
// notifications in particular are a purely textual list, so their author
|
||||
// UserRef comes back with AvatarURL == "". When this option is set, the
|
||||
// SDK resolves authors by fetching each one's profile page and reading the
|
||||
// avatar from it.
|
||||
//
|
||||
// Each distinct author costs one extra HTTP request, deduplicated within
|
||||
// the call and serialized through the client's rate limiter. Because that
|
||||
// limiter is global, total wall time is roughly (distinct authors) ×
|
||||
// (rate interval) on a busy account, dozens of seconds. limit caps how
|
||||
// many distinct authors are resolved: pass a small value (e.g. 12) to
|
||||
// bound a cold load. Authors are resolved in notification order with
|
||||
// journals first, so a Home "recent journals" feed still gets real
|
||||
// avatars; any author past the limit keeps AvatarURL == "" (callers
|
||||
// typically render an initials fallback). A limit <= 0 means unlimited.
|
||||
//
|
||||
// Per-author failures are ignored: a user whose page can't be fetched
|
||||
// simply keeps an empty AvatarURL.
|
||||
func WithResolvedAvatars(limit int) NotificationsOption {
|
||||
return func(c *notificationsConfig) {
|
||||
c.resolveAvatars = true
|
||||
c.avatarLimit = limit
|
||||
}
|
||||
// AvatarLimit caps how many distinct authors are resolved when
|
||||
// ResolveAvatars is true. Authors are visited journals-first; any
|
||||
// author past the limit keeps AvatarURL == "". Zero or negative
|
||||
// means unlimited. Has no effect when ResolveAvatars is false.
|
||||
AvatarLimit int
|
||||
}
|
||||
|
||||
// Notifications fetches /msg/others/ and returns the parsed notification
|
||||
// page. Requires a logged-in client; anonymous calls surface as
|
||||
// [ErrUnauthorized].
|
||||
//
|
||||
// All categories are returned in a single fetch there is no pagination
|
||||
// on this page. Pass [WithResolvedAvatars] to additionally backfill author
|
||||
// avatars that FA omits from the page (see that option's docs).
|
||||
func (c *Client) Notifications(ctx context.Context, opts ...NotificationsOption) (*Notifications, error) {
|
||||
var cfg notificationsConfig
|
||||
for _, o := range opts {
|
||||
o(&cfg)
|
||||
}
|
||||
|
||||
// All categories are returned in a single fetch — there is no pagination
|
||||
// on this page. Set [NotificationsOptions.ResolveAvatars] to additionally
|
||||
// backfill author avatars that FA omits from the page.
|
||||
//
|
||||
// reqOpts are per-request overrides (typically [WithCookies] etc. for the
|
||||
// multi-tenant case where many users share one client and one rate limiter).
|
||||
func (c *Client) Notifications(ctx context.Context, opts NotificationsOptions, reqOpts ...Option) (*Notifications, error) {
|
||||
var out *Notifications
|
||||
err := c.fetch(ctx, urls.MsgOthers(), func(doc *goquery.Document) error {
|
||||
n, err := parseNotifications(doc)
|
||||
@@ -126,13 +112,13 @@ func (c *Client) Notifications(ctx context.Context, opts ...NotificationsOption)
|
||||
}
|
||||
out = n
|
||||
return nil
|
||||
})
|
||||
}, reqOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cfg.resolveAvatars {
|
||||
c.resolveNotificationAvatars(ctx, out, cfg.avatarLimit)
|
||||
if opts.ResolveAvatars {
|
||||
c.resolveNotificationAvatars(ctx, out, opts.AvatarLimit, reqOpts)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -152,7 +138,7 @@ func (c *Client) Notifications(ctx context.Context, opts ...NotificationsOption)
|
||||
// Failures are deliberately swallowed: a single unreachable profile must
|
||||
// not fail the whole notifications call, and a stale ctx simply leaves the
|
||||
// remaining avatars empty.
|
||||
func (c *Client) resolveNotificationAvatars(ctx context.Context, n *Notifications, limit int) {
|
||||
func (c *Client) resolveNotificationAvatars(ctx context.Context, n *Notifications, limit int, reqOpts []Option) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
@@ -166,7 +152,7 @@ func (c *Client) resolveNotificationAvatars(ctx context.Context, n *Notification
|
||||
if limit > 0 && len(cache) >= limit {
|
||||
return // fetch budget exhausted leave AvatarURL empty
|
||||
}
|
||||
avatar = c.fetchUserAvatar(ctx, ref.Name)
|
||||
avatar = c.fetchUserAvatar(ctx, ref.Name, reqOpts)
|
||||
cache[ref.Name] = avatar
|
||||
}
|
||||
ref.AvatarURL = avatar
|
||||
@@ -194,7 +180,7 @@ func (c *Client) resolveNotificationAvatars(ctx context.Context, n *Notification
|
||||
// fetchUserAvatar fetches /user/{name}/ and returns just the profile
|
||||
// owner's avatar URL. It returns "" on any failure callers treat a
|
||||
// missing avatar as non-fatal.
|
||||
func (c *Client) fetchUserAvatar(ctx context.Context, name string) string {
|
||||
func (c *Client) fetchUserAvatar(ctx context.Context, name string, reqOpts []Option) string {
|
||||
var avatar string
|
||||
_ = c.fetch(ctx, urls.User(name), func(doc *goquery.Document) error {
|
||||
avatar = urls.AbsoluteCDN(firstNonEmpty(
|
||||
@@ -202,6 +188,6 @@ func (c *Client) fetchUserAvatar(ctx context.Context, name string) string {
|
||||
trimAttr(doc.Find("div.userpage-nav-avatar img").First(), "src"),
|
||||
))
|
||||
return nil
|
||||
})
|
||||
}, reqOpts...)
|
||||
return avatar
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ func TestNotifications_ResolvesAvatars(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
client := newE2EClient(t, srv)
|
||||
n, err := client.Notifications(context.Background(), WithResolvedAvatars(0))
|
||||
n, err := client.Notifications(context.Background(), NotificationsOptions{ResolveAvatars: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Notifications: %v", err)
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func TestNotifications_ResolvedAvatarsRespectsLimit(t *testing.T) {
|
||||
client := newE2EClient(t, srv)
|
||||
// Budget of 2 distinct authors. Journals resolve in document order, so
|
||||
// authora + authorb get fetched; authorc is past the budget.
|
||||
n, err := client.Notifications(context.Background(), WithResolvedAvatars(2))
|
||||
n, err := client.Notifications(context.Background(), NotificationsOptions{ResolveAvatars: true, AvatarLimit: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("Notifications: %v", err)
|
||||
}
|
||||
@@ -178,7 +178,7 @@ func TestNotifications_NoAvatarResolutionByDefault(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
client := newE2EClient(t, srv)
|
||||
n, err := client.Notifications(context.Background())
|
||||
n, err := client.Notifications(context.Background(), NotificationsOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Notifications: %v", err)
|
||||
}
|
||||
|
||||
98
request_options.go
Normal file
98
request_options.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package fa
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// reqOverrideKey scopes the per-request override on a context. The
|
||||
// transport reads it; nothing outside this package can synthesize one.
|
||||
type reqOverrideKey struct{}
|
||||
|
||||
// requestOverride is the resolved diff between the client's default
|
||||
// config and the options passed to a single call. The transport applies
|
||||
// it on every outbound request whose context carries one.
|
||||
//
|
||||
// Only request-level fields appear here. Client-only fields (rate limiter,
|
||||
// http.Client, retries, parser flags) are deliberately absent: passing the
|
||||
// corresponding Option to a call is a silent no-op, which is documented on
|
||||
// each Option.
|
||||
type requestOverride struct {
|
||||
cookies Cookies
|
||||
cf CFCookies
|
||||
sfw SFWMode
|
||||
userAgent string
|
||||
}
|
||||
|
||||
// applyRequestOptions resolves per-call options on top of the client's
|
||||
// config and, if any request-level field actually changed, returns a
|
||||
// context carrying a *requestOverride for the transport to read. When no
|
||||
// options are passed or none of them touch request-level fields, the
|
||||
// original context is returned unchanged so the hot path stays
|
||||
// allocation-free.
|
||||
func (c *Client) applyRequestOptions(ctx context.Context, opts []Option) context.Context {
|
||||
if len(opts) == 0 {
|
||||
return ctx
|
||||
}
|
||||
cfg := c.cfg
|
||||
for _, o := range opts {
|
||||
if o != nil {
|
||||
o(&cfg)
|
||||
}
|
||||
}
|
||||
if cfg.cookies == c.cfg.cookies &&
|
||||
cfg.cf == c.cfg.cf &&
|
||||
cfg.sfw == c.cfg.sfw &&
|
||||
cfg.userAgent == c.cfg.userAgent {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, reqOverrideKey{}, &requestOverride{
|
||||
cookies: cfg.cookies,
|
||||
cf: cfg.cf,
|
||||
sfw: cfg.sfw,
|
||||
userAgent: cfg.userAgent,
|
||||
})
|
||||
}
|
||||
|
||||
// requestOverrideFrom extracts the override the transport should apply
|
||||
// to this request, or nil if none was attached.
|
||||
func requestOverrideFrom(ctx context.Context) *requestOverride {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
ov, _ := ctx.Value(reqOverrideKey{}).(*requestOverride)
|
||||
return ov
|
||||
}
|
||||
|
||||
// touchesCookies reports whether this override should replace the Cookie
|
||||
// header. A UA-only override leaves the header (and thus the jar's
|
||||
// cookies) alone.
|
||||
func (o *requestOverride) touchesCookies() bool {
|
||||
return o.cookies.A != "" ||
|
||||
o.cookies.B != "" ||
|
||||
o.cf.Clearance != "" ||
|
||||
o.sfw != SFWAuto
|
||||
}
|
||||
|
||||
// cookieHeader renders the override's cookies into a single Cookie
|
||||
// header value. The transport writes this verbatim, replacing whatever
|
||||
// the shared cookie jar produced from c.http's jar.
|
||||
func (o *requestOverride) cookieHeader() string {
|
||||
var parts []string
|
||||
if o.cookies.A != "" {
|
||||
parts = append(parts, "a="+o.cookies.A)
|
||||
}
|
||||
if o.cookies.B != "" {
|
||||
parts = append(parts, "b="+o.cookies.B)
|
||||
}
|
||||
if o.cf.Clearance != "" {
|
||||
parts = append(parts, "cf_clearance="+o.cf.Clearance)
|
||||
}
|
||||
switch o.sfw {
|
||||
case SFWOn:
|
||||
parts = append(parts, "sfw=1")
|
||||
case SFWOff:
|
||||
parts = append(parts, "sfw=0")
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
138
request_options_test.go
Normal file
138
request_options_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package fa
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRequestOverride_PerCallCookiesAndUA exercises the multi-tenant flow:
|
||||
// one Client built with default creds, two calls in a row each carrying a
|
||||
// different user's cookies via per-call Options. The server records the
|
||||
// Cookie + User-Agent headers it saw on each request; the per-call values
|
||||
// must override the client's, and the client's defaults must come back on
|
||||
// a call that passes no overrides.
|
||||
func TestRequestOverride_PerCallCookiesAndUA(t *testing.T) {
|
||||
type seen struct {
|
||||
cookie string
|
||||
userAgent string
|
||||
}
|
||||
var (
|
||||
mu sync.Mutex
|
||||
hits []seen
|
||||
)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/view/1/", func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
hits = append(hits, seen{cookie: r.Header.Get("Cookie"), userAgent: r.Header.Get("User-Agent")})
|
||||
mu.Unlock()
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte(syntheticSubmissionHTML))
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
// Client built with default ("system") creds plus a default UA.
|
||||
client := newE2EClient(t, srv)
|
||||
// Layer the client default cookies in via WithCookies so we can verify
|
||||
// they appear when no override is passed.
|
||||
clientWithDefaults := New(
|
||||
WithHTTPClient(client.http),
|
||||
WithRateLimit(0, 16),
|
||||
WithMaxRetries(0),
|
||||
WithUserAgent("default-ua/1.0"),
|
||||
WithCookies(Cookies{A: "defaultA", B: "defaultB"}),
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Call 1: no override → client defaults must apply.
|
||||
if _, err := clientWithDefaults.GetSubmission(ctx, 1); err != nil {
|
||||
t.Fatalf("call 1: %v", err)
|
||||
}
|
||||
// Call 2: per-user override (user-A creds + user-A UA).
|
||||
if _, err := clientWithDefaults.GetSubmission(ctx, 1,
|
||||
WithCookies(Cookies{A: "userA_a", B: "userA_b"}),
|
||||
WithCloudflare(CFCookies{Clearance: "userA_cf"}),
|
||||
WithUserAgent("userA-browser/1.0"),
|
||||
); err != nil {
|
||||
t.Fatalf("call 2: %v", err)
|
||||
}
|
||||
// Call 3: a different user.
|
||||
if _, err := clientWithDefaults.GetSubmission(ctx, 1,
|
||||
WithCookies(Cookies{A: "userB_a", B: "userB_b"}),
|
||||
); err != nil {
|
||||
t.Fatalf("call 3: %v", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(hits) != 3 {
|
||||
t.Fatalf("hits = %d; want 3", len(hits))
|
||||
}
|
||||
|
||||
// Call 1: default cookies + default UA.
|
||||
if !strings.Contains(hits[0].cookie, "a=defaultA") || !strings.Contains(hits[0].cookie, "b=defaultB") {
|
||||
t.Errorf("call 1 cookie = %q; want default a/b", hits[0].cookie)
|
||||
}
|
||||
if hits[0].userAgent != "default-ua/1.0" {
|
||||
t.Errorf("call 1 UA = %q; want default-ua/1.0", hits[0].userAgent)
|
||||
}
|
||||
|
||||
// Call 2: user-A creds replace the jar's defaults wholesale.
|
||||
if !strings.Contains(hits[1].cookie, "a=userA_a") || !strings.Contains(hits[1].cookie, "b=userA_b") {
|
||||
t.Errorf("call 2 cookie = %q; want user-A a/b", hits[1].cookie)
|
||||
}
|
||||
if !strings.Contains(hits[1].cookie, "cf_clearance=userA_cf") {
|
||||
t.Errorf("call 2 cookie missing cf_clearance: %q", hits[1].cookie)
|
||||
}
|
||||
if strings.Contains(hits[1].cookie, "defaultA") || strings.Contains(hits[1].cookie, "defaultB") {
|
||||
t.Errorf("call 2 cookie leaked client defaults: %q", hits[1].cookie)
|
||||
}
|
||||
if hits[1].userAgent != "userA-browser/1.0" {
|
||||
t.Errorf("call 2 UA = %q; want userA-browser/1.0", hits[1].userAgent)
|
||||
}
|
||||
|
||||
// Call 3: user-B cookies override, but UA falls back to client default
|
||||
// because the override did not touch it.
|
||||
if !strings.Contains(hits[2].cookie, "a=userB_a") || !strings.Contains(hits[2].cookie, "b=userB_b") {
|
||||
t.Errorf("call 3 cookie = %q; want user-B a/b", hits[2].cookie)
|
||||
}
|
||||
if hits[2].userAgent != "default-ua/1.0" {
|
||||
t.Errorf("call 3 UA = %q; want default-ua/1.0 (no override)", hits[2].userAgent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestOverride_NoOverrideMeansNoCtxValue is a cheap sanity check
|
||||
// that applyRequestOptions short-circuits when nothing request-level
|
||||
// actually changed (e.g. caller passed only client-only options).
|
||||
func TestRequestOverride_NoOverrideMeansNoCtxValue(t *testing.T) {
|
||||
c := New(WithCookies(Cookies{A: "x", B: "y"}))
|
||||
ctx := context.Background()
|
||||
|
||||
// No options at all → same ctx.
|
||||
if got := c.applyRequestOptions(ctx, nil); got != ctx {
|
||||
t.Error("nil opts should pass through ctx unchanged")
|
||||
}
|
||||
// A client-only option that does not touch request-level fields.
|
||||
got := c.applyRequestOptions(ctx, []Option{WithMaxRetries(7)})
|
||||
if got != ctx {
|
||||
t.Error("client-only option should not attach a request override")
|
||||
}
|
||||
// A request-level option that happens to equal the current value.
|
||||
got = c.applyRequestOptions(ctx, []Option{WithCookies(Cookies{A: "x", B: "y"})})
|
||||
if got != ctx {
|
||||
t.Error("override matching client config should not attach")
|
||||
}
|
||||
// A real override.
|
||||
got = c.applyRequestOptions(ctx, []Option{WithCookies(Cookies{A: "z", B: "w"})})
|
||||
if got == ctx {
|
||||
t.Error("real override should produce a new ctx")
|
||||
}
|
||||
if ov := requestOverrideFrom(got); ov == nil || ov.cookies.A != "z" {
|
||||
t.Errorf("override ctx missing or wrong: %+v", ov)
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,7 @@ type SearchOptions struct {
|
||||
// Search works anonymously for most queries; some adult-content searches
|
||||
// require login and will surface as [ErrUnauthorized] via the system-
|
||||
// message classifier.
|
||||
func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) iter.Seq2[*Submission, error] {
|
||||
func (c *Client) Search(ctx context.Context, query string, opts SearchOptions, reqOpts ...Option) iter.Seq2[*Submission, error] {
|
||||
return func(yield func(*Submission, error) bool) {
|
||||
page := opts.StartPage
|
||||
if page < 1 {
|
||||
@@ -134,7 +134,7 @@ func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) i
|
||||
err := c.fetch(ctx, pageURL, func(doc *goquery.Document) error {
|
||||
items, hasNext = parseSearchResults(doc, c.cfg.jsonListings)
|
||||
return nil
|
||||
})
|
||||
}, reqOpts...)
|
||||
if err != nil {
|
||||
yield(nil, err)
|
||||
return
|
||||
|
||||
@@ -47,7 +47,7 @@ type Submission struct {
|
||||
// Returns [ErrNotFound] if FA renders a "submission not found" system message,
|
||||
// [ErrUnauthorized] for restricted-visibility submissions when called
|
||||
// without valid cookies, or a wrapped parse error if the markup has shifted.
|
||||
func (c *Client) GetSubmission(ctx context.Context, id SubmissionID) (*Submission, error) {
|
||||
func (c *Client) GetSubmission(ctx context.Context, id SubmissionID, opts ...Option) (*Submission, error) {
|
||||
if id <= 0 {
|
||||
return nil, fmt.Errorf("fa: GetSubmission: id must be > 0")
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func (c *Client) GetSubmission(ctx context.Context, id SubmissionID) (*Submissio
|
||||
}
|
||||
out = s
|
||||
return nil
|
||||
})
|
||||
}, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -72,13 +72,14 @@ func (c *Client) GetSubmission(ctx context.Context, id SubmissionID) (*Submissio
|
||||
//
|
||||
// Returns the number of bytes written. Errors from the writer are wrapped
|
||||
// as-is; HTTP errors come back as [*HTTPError].
|
||||
func (c *Client) Download(ctx context.Context, sub *Submission, w io.Writer) (int64, error) {
|
||||
func (c *Client) Download(ctx context.Context, sub *Submission, w io.Writer, opts ...Option) (int64, error) {
|
||||
if sub == nil {
|
||||
return 0, errors.New("fa: Download: nil submission")
|
||||
}
|
||||
if sub.FileURL == "" {
|
||||
return 0, errors.New("fa: Download: submission has no FileURL")
|
||||
}
|
||||
ctx = c.applyRequestOptions(ctx, opts)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sub.FileURL, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
|
||||
11
transport.go
11
transport.go
@@ -32,6 +32,17 @@ const defaultMaxRetries = 3
|
||||
// injects the User-Agent header, retries on transient failures, and
|
||||
// classifies Cloudflare challenges as non-retryable user-actionable errors.
|
||||
func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if ov := requestOverrideFrom(req.Context()); ov != nil {
|
||||
if ov.userAgent != "" {
|
||||
req.Header.Set("User-Agent", ov.userAgent)
|
||||
}
|
||||
if ov.touchesCookies() {
|
||||
// The cookie jar already wrote a Cookie header by the time we
|
||||
// reach RoundTrip; replace it wholesale so per-user creds win
|
||||
// over whatever the shared jar would inject.
|
||||
req.Header.Set("Cookie", ov.cookieHeader())
|
||||
}
|
||||
}
|
||||
if t.userAgent != "" && req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", t.userAgent)
|
||||
}
|
||||
|
||||
4
user.go
4
user.go
@@ -33,7 +33,7 @@ type User struct {
|
||||
}
|
||||
|
||||
// GetUser fetches a user profile by URL-safe name (FA's lowercase login form).
|
||||
func (c *Client) GetUser(ctx context.Context, name string) (*User, error) {
|
||||
func (c *Client) GetUser(ctx context.Context, name string, opts ...Option) (*User, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("fa: GetUser: empty name")
|
||||
@@ -46,7 +46,7 @@ func (c *Client) GetUser(ctx context.Context, name string) (*User, error) {
|
||||
}
|
||||
out = u
|
||||
return nil
|
||||
})
|
||||
}, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user