inital commit

This commit is contained in:
2026-05-26 20:21:55 +02:00
parent 965f9d6ad4
commit 0ae20aa68d
21 changed files with 651 additions and 111 deletions

189
.gitingore Normal file
View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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
View 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
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
View 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
View 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)
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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
}