inital commit

This commit is contained in:
2026-05-25 22:27:18 +02:00
commit 965f9d6ad4
91 changed files with 28963 additions and 0 deletions

55
examples/basic/main.go Normal file
View File

@@ -0,0 +1,55 @@
// basic demonstrates use of the SDK: fetching a single submission and
// printing a few fields.
//
// The example runs anonymously by default. If the FA_A / FA_B (and ideally
// CF_CLEARANCE + FA_UA) environment variables are set, it authenticates
// with them required for any submission FA gates behind login, mature
// content guard, or Cloudflare challenges.
//
// go run ./examples/basic 12345678
package main
import (
"context"
"fmt"
"log"
"os"
"strconv"
fa "git.anthrove.art/public/go-fa-api"
)
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)
}
opts := []fa.Option{fa.WithUserAgent("go-fa-api-example/0.1")}
if a, b := os.Getenv("FA_A"), os.Getenv("FA_B"); a != "" && b != "" {
log.Printf("using FA_A/FA_B cookies for authenticated request")
opts = []fa.Option{
fa.WithCookies(fa.Cookies{A: a, B: b}),
fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
fa.WithUserAgent(envOr("FA_UA", "go-fa-api-example/0.1")),
}
}
client := fa.New(opts...)
sub, err := client.GetSubmission(context.Background(), fa.SubmissionID(id))
if err != nil {
log.Fatalf("GetSubmission: %v", err)
}
fmt.Printf("%s\nby %s\nrating: %s\ntags: %v\nfile: %s\n",
sub.Title, sub.Author.DisplayName, sub.Rating, sub.Tags, sub.FileURL)
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

54
examples/browse/main.go Normal file
View File

@@ -0,0 +1,54 @@
// browse prints the global front-page feed at /browse/ FA's "what's new
// across the site" stream. Uses anon access by default; honours FA_A /
// FA_B / CF_CLEARANCE / FA_UA from env when set (recommended, since FA
// gates parts of the feed behind login + Cloudflare).
//
// go run ./examples/browse # 1 page, ~72 items
// go run ./examples/browse 3 # 3 pages
package main
import (
"context"
"fmt"
"log"
"os"
"strconv"
fa "git.anthrove.art/public/go-fa-api"
)
func main() {
maxPages := 1
if len(os.Args) >= 2 {
if n, err := strconv.Atoi(os.Args[1]); err == nil && n > 0 {
maxPages = n
}
}
opts := []fa.Option{fa.WithUserAgent("go-fa-api-example/0.1")}
if a, b := os.Getenv("FA_A"), os.Getenv("FA_B"); a != "" && b != "" {
opts = []fa.Option{
fa.WithCookies(fa.Cookies{A: a, B: b}),
fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
fa.WithUserAgent(envOr("FA_UA", "go-fa-api-example/0.1")),
}
}
client := fa.New(opts...)
count := 0
for sub, err := range client.Browse(context.Background(), fa.BrowseOptions{MaxPages: maxPages}) {
if err != nil {
log.Fatalf("Browse: %v", err)
}
count++
fmt.Printf("[%d] %s %s by %s\n", sub.ID, sub.Title, sub.Rating, sub.Author.DisplayName)
}
fmt.Printf("\n%d submissions across %d page(s)\n", count, maxPages)
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

49
examples/download/main.go Normal file
View File

@@ -0,0 +1,49 @@
// download fetches a single submission and streams its main file to disk.
// Demonstrates that downloads share the SDK's rate limiter and cookie jar
// with metadata fetches.
//
// go run ./examples/download 12345678 out.jpg
package main
import (
"context"
"log"
"os"
"strconv"
fa "git.anthrove.art/public/go-fa-api"
)
func main() {
if len(os.Args) < 3 {
log.Fatalf("usage: %s <submission-id> <out-path>", os.Args[0])
}
id, err := strconv.ParseInt(os.Args[1], 10, 64)
if err != nil {
log.Fatalf("invalid id: %v", err)
}
client := fa.New(
fa.WithCookies(fa.Cookies{A: os.Getenv("FA_A"), B: os.Getenv("FA_B")}),
fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
fa.WithUserAgent(os.Getenv("FA_UA")),
)
ctx := context.Background()
sub, err := client.GetSubmission(ctx, fa.SubmissionID(id))
if err != nil {
log.Fatalf("GetSubmission: %v", err)
}
out, err := os.Create(os.Args[2])
if err != nil {
log.Fatalf("create: %v", err)
}
defer out.Close()
n, err := client.Download(ctx, sub, out)
if err != nil {
log.Fatalf("download: %v", err)
}
log.Printf("wrote %d bytes to %s", n, os.Args[2])
}

View File

@@ -0,0 +1,54 @@
// gallery_dump iterates a user's gallery (authenticated) and prints the
// title and ID of every submission encountered, honouring the SDK's default
// 1 req/sec rate limit.
//
// Required environment variables:
//
// FA_A — the `a` session cookie
// FA_B — the `b` session cookie
// CF_CLEARANCE — the cf_clearance cookie from the same browser session
// FA_UA — the User-Agent string that produced CF_CLEARANCE
//
// Usage:
//
// go run ./examples/gallery_dump <username> [maxPages]
package main
import (
"context"
"fmt"
"log"
"os"
"strconv"
fa "git.anthrove.art/public/go-fa-api"
)
func main() {
if len(os.Args) < 2 {
log.Fatalf("usage: %s <username> [maxPages]", os.Args[0])
}
user := os.Args[1]
maxPages := 0
if len(os.Args) >= 3 {
if n, err := strconv.Atoi(os.Args[2]); err == nil {
maxPages = n
}
}
client := fa.New(
fa.WithCookies(fa.Cookies{A: os.Getenv("FA_A"), B: os.Getenv("FA_B")}),
fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
fa.WithUserAgent(os.Getenv("FA_UA")),
)
count := 0
for sub, err := range client.Gallery(context.Background(), user, fa.ListOptions{MaxPages: maxPages}) {
if err != nil {
log.Fatalf("iter: %v", err)
}
count++
fmt.Printf("[%d] %s %s\n", sub.ID, sub.Title, sub.Rating)
}
fmt.Printf("\n%d submissions\n", count)
}

76
examples/inbox/main.go Normal file
View File

@@ -0,0 +1,76 @@
// inbox prints the logged-in user's new submissions the "what's new
// from people you watch" feed at https://www.furaffinity.net/msg/submissions/.
//
// Requires FA_A and FA_B (session cookies). CF_CLEARANCE + FA_UA are
// strongly recommended without them FurAffinity's Cloudflare layer will
// usually serve a challenge page instead of the inbox.
//
// go run ./examples/inbox # first page (~72 items)
// go run ./examples/inbox 3 # up to 3 cursor pages (~216 items)
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
"strconv"
fa "git.anthrove.art/public/go-fa-api"
)
func main() {
maxPages := 1
if len(os.Args) >= 2 {
if n, err := strconv.Atoi(os.Args[1]); err == nil && n > 0 {
maxPages = n
}
}
a, b := os.Getenv("FA_A"), os.Getenv("FA_B")
if a == "" || b == "" {
log.Fatal("FA_A and FA_B must be set the submission inbox requires login")
}
//if os.Getenv("CF_CLEARANCE") == "" || os.Getenv("FA_UA") == "" {
// log.Println("warning: CF_CLEARANCE / FA_UA not set expect a Cloudflare challenge")
//}
client := fa.New(
fa.WithCookies(fa.Cookies{A: a, B: b}),
//fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
fa.WithUserAgent(envOr("FA_UA", "go-fa-api-example/0.1")),
// JSON-first listing parse: more resilient to FA markup tweaks,
// and populates each item's author avatar URL.
fa.WithExperimentalJSONListings(false),
)
count := 0
for sub, err := range client.SubmissionInbox(context.Background(), fa.ListOptions{MaxPages: maxPages}) {
if err != nil {
switch {
case errors.Is(err, fa.ErrCloudflareChallenge):
log.Fatal("Cloudflare challenge refresh CF_CLEARANCE + FA_UA from your browser and retry")
case errors.Is(err, fa.ErrUnauthorized):
log.Fatal("unauthorized FA_A / FA_B are missing or expired")
default:
log.Fatalf("SubmissionInbox: %v", err)
}
}
count++
when := "—"
if !sub.PostedAt.IsZero() {
when = sub.PostedAt.Format("2006-01-02 15:04")
}
fmt.Printf("[%d] %-50.50s %-8s by %s (%s)\n",
sub.ID, sub.Title, sub.Rating, sub.Author.DisplayName, when)
}
fmt.Printf("\n%d new submission(s) across up to %d page(s)\n", count, maxPages)
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

73
examples/notes/main.go Normal file
View File

@@ -0,0 +1,73 @@
// notes dumps the /msg/pms/ inbox listing and, if an argument is given,
// prints the full body of that note id. Requires FA_A / FA_B in env.
//
// go run ./examples/notes # list inbox
// go run ./examples/notes 131012623 # read one note
package main
import (
"context"
"fmt"
"log"
"os"
"strconv"
fa "git.anthrove.art/public/go-fa-api"
)
func main() {
a, b := os.Getenv("FA_A"), os.Getenv("FA_B")
if a == "" || b == "" {
log.Fatal("FA_A and FA_B must be set notes require login")
}
client := fa.New(
fa.WithCookies(fa.Cookies{A: a, B: b}),
fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
fa.WithUserAgent(envOr("FA_UA", "go-fa-api-example/0.1")),
)
ctx := context.Background()
if len(os.Args) >= 2 {
id, err := strconv.ParseInt(os.Args[1], 10, 64)
if err != nil {
log.Fatalf("invalid note id: %v", err)
}
n, err := client.GetNote(ctx, fa.NoteID(id))
if err != nil {
log.Fatalf("GetNote: %v", err)
}
fmt.Printf("Subject: %s\nFrom: %s (@%s)\nTo: %s (@%s)\nSent: %s\n\n%s\n",
n.Subject,
n.From.DisplayName, n.From.Name,
n.To.DisplayName, n.To.Name,
n.SentAt.Format("2006-01-02 15:04"),
n.BodyText)
return
}
count := 0
for np, err := range client.Notes(ctx, fa.ListOptions{MaxPages: 1}) {
if err != nil {
log.Fatalf("Notes: %v", err)
}
count++
unread := " "
if np.Unread {
unread = "*"
}
from := np.Sender.DisplayName
if from == "" {
from = np.Sender.Name
}
fmt.Printf("[%s] [%d] %s from %s (%s)\n",
unread, np.ID, np.Subject, from, np.SentAt.Format("2006-01-02 15:04"))
}
fmt.Printf("\n%d notes on first page\n", count)
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View File

@@ -0,0 +1,70 @@
// notifications dumps the full /msg/others/ page watch / journal /
// comment / fav / shout notifications, single fetch, all categories.
// Requires FA_A / FA_B in env (and ideally CF_CLEARANCE + FA_UA).
//
// go run ./examples/notifications
package main
import (
"context"
"fmt"
"log"
"os"
fa "git.anthrove.art/public/go-fa-api"
)
func main() {
a, b := os.Getenv("FA_A"), os.Getenv("FA_B")
if a == "" || b == "" {
log.Fatal("FA_A and FA_B must be set notifications require login")
}
client := fa.New(
fa.WithCookies(fa.Cookies{A: a, B: b}),
fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
fa.WithUserAgent(envOr("FA_UA", "go-fa-api-example/0.1")),
)
n, err := client.Notifications(context.Background())
if err != nil {
log.Fatalf("Notifications: %v", err)
}
section("Journals", len(n.Journals))
for _, j := range n.Journals {
fmt.Printf(" [%d] %s by %s (%s, %s)\n",
j.JournalID, j.Title, j.Author.DisplayName, j.Rating,
j.PostedAt.Format("2006-01-02 15:04"))
}
section("New watchers", len(n.Watches))
for _, w := range n.Watches {
fmt.Printf(" %s (%s)\n", w.User.DisplayName, w.WatchedAt.Format("2006-01-02 15:04"))
}
section("Submission comments", len(n.SubmissionComments))
for _, c := range n.SubmissionComments {
fmt.Printf(" on submission %d (%q) by %s\n", c.OnSubmission, c.OnTitle, c.Author.DisplayName)
}
section("Journal comments", len(n.JournalComments))
for _, c := range n.JournalComments {
fmt.Printf(" on journal %d (%q) by %s\n", c.OnJournal, c.OnTitle, c.Author.DisplayName)
}
section("Favorites", len(n.Favorites))
for _, f := range n.Favorites {
fmt.Printf(" %s favorited %d (%q)\n", f.Favoriter.DisplayName, f.SubmissionID, f.SubmissionTitle)
}
section("Shouts", len(n.Shouts))
for _, s := range n.Shouts {
fmt.Printf(" shout from %s (%s)\n", s.Author.DisplayName, s.PostedAt.Format("2006-01-02 15:04"))
}
}
func section(name string, count int) {
fmt.Printf("\n=== %s (%d) ===\n", name, count)
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

117
examples/priority/main.go Normal file
View File

@@ -0,0 +1,117 @@
// priority demonstrates multi-level rate-limiter priority.
//
// A background gallery crawl runs continuously at fa.PriorityBackground
// while interactive submission fetches at fa.PriorityInteractive jump ahead
// of it even though the crawl started first and both share one global
// token bucket. (fa.PriorityNormal and fa.PriorityLow sit between the two;
// the same rule applies higher priority is always served first.)
//
// The example runs anonymously by default. If the FA_A / FA_B (and ideally
// CF_CLEARANCE + FA_UA) environment variables are set, it authenticates
// with them.
//
// Watch the timestamps in the output: once the interactive fetches are
// issued, they take the next tokens and the background crawl is pushed back.
//
// go run ./examples/priority <username> <submission-id> [<submission-id>...]
package main
import (
"context"
"fmt"
"log"
"os"
"strconv"
"sync"
"time"
fa "git.anthrove.art/public/go-fa-api"
)
func main() {
if len(os.Args) < 3 {
log.Fatalf("usage: %s <username> <submission-id> [<submission-id>...]", os.Args[0])
}
user := os.Args[1]
ids := make([]fa.SubmissionID, 0, len(os.Args)-2)
for _, arg := range os.Args[2:] {
n, err := strconv.ParseInt(arg, 10, 64)
if err != nil {
log.Fatalf("invalid submission id %q: %v", arg, err)
}
ids = append(ids, fa.SubmissionID(n))
}
// Priority scheduling must be enabled at construction time; without
// WithPrioritizedRateLimiting the WithPriority markers below are inert.
opts := []fa.Option{
fa.WithUserAgent(envOr("FA_UA", "go-fa-api-example/0.1")),
fa.WithPrioritizedRateLimiting(true),
}
if a, b := os.Getenv("FA_A"), os.Getenv("FA_B"); a != "" && b != "" {
log.Printf("using FA_A/FA_B cookies for authenticated requests")
opts = append(opts,
fa.WithCookies(fa.Cookies{A: a, B: b}),
fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
)
}
client := fa.New(opts...)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
start := time.Now()
since := func() string { return fmt.Sprintf("%5.1fs", time.Since(start).Seconds()) }
// Background crawler: walks the user's gallery at the lowest priority,
// keeping the shared token bucket busy so the priority effect is visible.
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
bgCtx := fa.WithBackgroundPriority(ctx)
n := 0
for sub, err := range client.Gallery(bgCtx, user, fa.ListOptions{}) {
if err != nil {
if ctx.Err() == nil { // not just our own cancel
log.Printf("[%s] background crawl stopped: %v", since(), err)
}
return
}
n++
fmt.Printf("[%s] background · gallery item %3d (%d) %s\n",
since(), n, sub.ID, sub.Title)
}
fmt.Printf("[%s] background · gallery exhausted after %d items\n", since(), n)
}()
// Give the crawler a head start so it is mid-crawl, holding the queue,
// when the interactive fetches arrive.
time.Sleep(1500 * time.Millisecond)
// Interactive fetches: the user is waiting on these. Each takes the very
// next token, ahead of the background crawl's pending page fetch.
for _, id := range ids {
ictx := fa.WithPriority(ctx, fa.PriorityInteractive)
t0 := time.Now()
sub, err := client.GetSubmission(ictx, id)
if err != nil {
log.Printf("[%s] interactive · GetSubmission(%d): %v", since(), id, err)
continue
}
fmt.Printf("[%s] INTERACTIVE · got (%d) %q in %.1fs jumped the queue\n",
since(), id, sub.Title, time.Since(t0).Seconds())
}
// Foreground work is done stop the background crawler and wait for it.
cancel()
wg.Wait()
fmt.Printf("[%s] done\n", since())
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

68
examples/search/main.go Normal file
View File

@@ -0,0 +1,68 @@
// search runs a /search/?q=... query and prints the first N pages of
// matches. Works anonymously for general-rated results; mature/adult
// searches require login.
//
// go run ./examples/search dragon
// go run ./examples/search "fox knight" --max=2 --rating=general --order=date
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"strings"
fa "git.anthrove.art/public/go-fa-api"
)
func main() {
maxPages := flag.Int("max", 1, "max pages to fetch")
ratingFlag := flag.String("rating", "", "comma-separated: general, mature, adult (empty = all)")
orderFlag := flag.String("order", "", "relevancy | date | popularity (empty = default)")
flag.Parse()
if flag.NArg() == 0 {
log.Fatalf("usage: %s [flags] <query>", os.Args[0])
}
query := strings.Join(flag.Args(), " ")
opts := []fa.Option{fa.WithUserAgent("go-fa-api-example/0.1")}
if a, b := os.Getenv("FA_A"), os.Getenv("FA_B"); a != "" && b != "" {
opts = []fa.Option{
fa.WithCookies(fa.Cookies{A: a, B: b}),
fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
fa.WithUserAgent(envOr("FA_UA", "go-fa-api-example/0.1")),
}
}
client := fa.New(opts...)
so := fa.SearchOptions{MaxPages: *maxPages}
if *ratingFlag != "" {
for _, r := range strings.Split(*ratingFlag, ",") {
so.Ratings = append(so.Ratings, fa.ParseRating(strings.TrimSpace(r)))
}
}
if *orderFlag != "" {
so.OrderBy = fa.SearchOrder(*orderFlag)
}
count := 0
for sub, err := range client.Search(context.Background(), query, so) {
if err != nil {
log.Fatalf("Search: %v", err)
}
count++
fmt.Printf("[%d] %s %s by %s\n",
sub.ID, sub.Title, sub.Rating, sub.Author.DisplayName)
}
fmt.Printf("\n%d results for %q across up to %d page(s)\n", count, query, *maxPages)
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}