From 0ae20aa68d5ea259c98335d1e0cec9bc61910fb4 Mon Sep 17 00:00:00 2001 From: SoXX Date: Tue, 26 May 2026 20:21:55 +0200 Subject: [PATCH] inital commit --- .gitingore | 189 +++++++++++++++++++++++++++++++++ actions.go | 34 +++--- browse.go | 4 +- client.go | 3 +- comment.go | 12 +-- comments_post.go | 12 +-- examples/multiuser/main.go | 113 ++++++++++++++++++++ examples/notifications/main.go | 2 +- gallery.go | 15 +-- inbox.go | 4 +- journal.go | 8 +- notes.go | 8 +- notes_send.go | 6 +- notifications.go | 84 ++++++--------- notifications_test.go | 6 +- request_options.go | 98 +++++++++++++++++ request_options_test.go | 138 ++++++++++++++++++++++++ search.go | 4 +- submission.go | 7 +- transport.go | 11 ++ user.go | 4 +- 21 files changed, 651 insertions(+), 111 deletions(-) create mode 100644 .gitingore create mode 100644 examples/multiuser/main.go create mode 100644 request_options.go create mode 100644 request_options_test.go diff --git a/.gitingore b/.gitingore new file mode 100644 index 0000000..029209e --- /dev/null +++ b/.gitingore @@ -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 diff --git a/actions.go b/actions.go index 7639d17..ae640be 100644 --- a/actions.go +++ b/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 diff --git a/browse.go b/browse.go index e639071..fca1f81 100644 --- a/browse.go +++ b/browse.go @@ -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 diff --git a/client.go b/client.go index a76fdd6..a195547 100644 --- a/client.go +++ b/client.go @@ -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) diff --git a/comment.go b/comment.go index 8e03908..e3e970e 100644 --- a/comment.go +++ b/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 diff --git a/comments_post.go b/comments_post.go index cbdfd46..08da40f 100644 --- a/comments_post.go +++ b/comments_post.go @@ -24,26 +24,26 @@ type PostCommentOptions struct { // URL with fields `action=reply`, `replyto=`, `reply=`. // 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 } diff --git a/examples/multiuser/main.go b/examples/multiuser/main.go new file mode 100644 index 0000000..270ac2d --- /dev/null +++ b/examples/multiuser/main.go @@ -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 ", 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 +} diff --git a/examples/notifications/main.go b/examples/notifications/main.go index a1dbcc5..a71e61d 100644 --- a/examples/notifications/main.go +++ b/examples/notifications/main.go @@ -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) } diff --git a/gallery.go b/gallery.go index 116b261..d9dec36 100644 --- a/gallery.go +++ b/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 diff --git a/inbox.go b/inbox.go index a8729dd..3f716ab 100644 --- a/inbox.go +++ b/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 diff --git a/journal.go b/journal.go index ec3c275..970320d 100644 --- a/journal.go +++ b/journal.go @@ -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 diff --git a/notes.go b/notes.go index 8a526fd..3722ea1 100644 --- a/notes.go +++ b/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 } diff --git a/notes_send.go b/notes_send.go index ae52d0f..90eaa8f 100644 --- a/notes_send.go +++ b/notes_send.go @@ -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 } diff --git a/notifications.go b/notifications.go index 5001898..54c2731 100644 --- a/notifications.go +++ b/notifications.go @@ -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 } diff --git a/notifications_test.go b/notifications_test.go index f2e7620..ba74af0 100644 --- a/notifications_test.go +++ b/notifications_test.go @@ -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) } diff --git a/request_options.go b/request_options.go new file mode 100644 index 0000000..7425b40 --- /dev/null +++ b/request_options.go @@ -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, "; ") +} diff --git a/request_options_test.go b/request_options_test.go new file mode 100644 index 0000000..66933f4 --- /dev/null +++ b/request_options_test.go @@ -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) + } +} diff --git a/search.go b/search.go index 292e52e..dc62d2a 100644 --- a/search.go +++ b/search.go @@ -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 diff --git a/submission.go b/submission.go index 07ad9ac..1276d0a 100644 --- a/submission.go +++ b/submission.go @@ -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 diff --git a/transport.go b/transport.go index f0ee4b2..e0cfd13 100644 --- a/transport.go +++ b/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) } diff --git a/user.go b/user.go index 399c51b..76712d4 100644 --- a/user.go +++ b/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 }