12 Commits

Author SHA1 Message Date
95193fb66d fix(notes): take last numeric segment of href, not the first
The notes listing renders each thread link as
/msg/pms/{folder}/{noteID}/#message. extractIntFromHref returned the
first numeric segment it found, which was always the folder index (1
for the inbox), so every NotePreview.ID came out as 1 and any
follow-up GetNote(np.ID) call failed with "this message has either
been deleted or is not yours".

Surfaced by an end-to-end smoke run against the live site. Limited
to the notes parser; the other extractIntFromHref callers
(/view/{id}/, /journal/{id}/) only ever have a single numeric segment
so they are unaffected.
2026-06-02 22:52:50 +02:00
83487e531a fix(favorites): use cursor-based pagination instead of page numbers
FA's /favorites/{user}/ pagination is cursor-addressed by the fave-ID
of the last item on the previous page (e.g.
/favorites/{user}/1951234825/next), not by sequential integers. The
previous URL builder generated /favorites/{user}/{N}/ for N>=2; FA
interpreted that as a malformed cursor and silently returned page 1,
which caused the Favorites iterator to loop forever and the new
FavoritesPage to report HasNext=true on every call.

Changes:
- urls.Favorites(name) returns the first-page URL; new
  urls.FavoritesCursor(name, cursor) builds /favorites/.../next URLs.
- FavoritesPage now takes a cursor string; empty = first page.
  Returns ListingPage.NextPage as the opaque fave-ID for the next call.
- ListingPage gains NextPage string (decimal page number for
  Gallery/Scraps, fave-ID cursor for Favorites) and drops the Page int
  field that conflated those two notions.
- Client.Favorites iterator now walks cursors internally; StartPage
  is ignored for favorites (documented).
- detectNextPage / nextPageURL now parse the form action so the same
  helper works for both page-number and cursor pagination.
- Added regression test that fails on the infinite-loop bug.
- Example: examples/favorites_page demonstrates cursor walking.
2026-06-02 22:44:14 +02:00
8f4767966a feat(listing): add per-page methods with HasNext flag
GalleryPage / ScrapsPage / FavoritesPage return a ListingPage struct
carrying the page items, the 1-based page number, and a HasNext flag
that mirrors FA's "next page" link. This lets external scrapers drive
their own pagination loop (checkpoint resume, parallel workers,
custom throttling) without re-implementing the page-walking code.

The existing iter.Seq2-shaped methods now share the same per-page
primitive internally so behaviour stays in lock-step.
2026-06-02 22:28:49 +02:00
a2fc1b7e32 feat(listing): populate Tags and CategorizedTags from figure data-tags
FA's beta listing pages emit each submission's tag list on the
figure's <img data-tags="..."> attribute, mixing prefixed system tags
(s_/c_/a_/u_/t_) with the unprefixed keyword list. Reading it during
gallery-page parse lets callers classify favorites/gallery/scraps/
browse/search/inbox items at scrape time, avoiding a /view/{id}
round-trip per submission.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 21:53:56 +02:00
bc2d27f702 chore: add fixture-refresh helper script
scripts/refresh-user-fixture.sh wraps the TestRefreshFixtures
invocation: validates required env vars, warns on missing CF
credentials, runs the refresh, and re-runs the user parser tests
against the new fixture.
2026-06-02 21:30:28 +02:00
2d6e73a800 test(actions): make TestFindFavLinks resilient to capture state
Asserts exactly one of fav/unfav is set with a well-formed URL,
instead of hardcoding the +Fav direction. The previous test broke
whenever the capturing account favourited the target submission.
Also points the test at submission 12345678 (the documented default
FA_TEST_SUB_ID) so it matches what TestRefreshFixtures captures by
default.
2026-06-02 21:29:38 +02:00
79e8a35732 test: refresh fixtures with full target coverage
Picks up testdata/html files that were previously skipped by
TestRefreshFixtures because their optional env vars were unset:
gallery_page_last, search_results, note_view, plus a fresh
submission.html / gallery_page1 captured against the right targets.
2026-06-02 21:29:31 +02:00
5cb196940d chore: remove SDK_ISSUES.md
The umbrella audit (#4) had no open SDK issues remaining; the file is
no longer load-bearing.
2026-06-02 21:23:59 +02:00
25800bc753 test: refresh authenticated HTML fixtures
Re-captures testdata/html/*.html against the live site with valid
session cookies; the previous user.html was the logged-out interstitial,
which broke TestParseUser_RealFixture entirely. Bumps the expected
Stats.Views in that test to match the new fixture.
2026-06-02 21:23:52 +02:00
20fcad7fbb feat(submission): parse FA's prefixed system tags into CategorizedTags
FA renders its species/character/artist/type system tags as tag-block
anchors with a data-tag-name carrying a single-letter prefix
(s_/c_/a_-u_/t_) and a sibling tag-invalid span instead of a /search/
link. The existing keyword pass skips them, so they were lost.

Adds a Submission.CategorizedTags field exposing the four buckets with
the prefix stripped, plus an examples/categorized_tags runnable demo.
2026-06-02 21:15:30 +02:00
02479212bc chore: fix .gitignore filename typo
Renames .gitingore to .gitignore so the rules actually apply; .idea/
was leaking into the working tree as a result.
2026-06-02 21:15:04 +02:00
0ae20aa68d inital commit 2026-05-26 20:21:55 +02:00
50 changed files with 20426 additions and 2589 deletions

189
.gitignore vendored 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

@@ -1,268 +0,0 @@
# SDK issues FA Droid
Bugs whose root cause is in the **FA SDK** (`go-fa-api`, at
`/var/home/soxx/git/go-fa-api`, `replace`d in `go.mod`) not in this app.
The SDK is a **separate module, maintained and fixed by a separate AI**
that does not see this app's code or conversation context. This file is the
handoff: every entry is a self-contained bug report meant to be copy-pasted
to that AI as-is.
App-side bugs (frontend / go-service) live in `ISSUES.md`; fixed issues in
`SOLVED.md`. Features with UI but no backend are in `STUBS.md`; Wails/Android
stack traps in `PITFALLS.md`.
**Status legend:** `[ ]` open · `[~]` diagnosed handoff brief drafted ·
`[x]` fixed in the SDK (then moved to `SOLVED.md`)
**Numbering:** issue numbers are shared across `ISSUES.md`, `SOLVED.md` and
this file draw the next free number from the same sequence.
---
## SDK handoff brief required fields
Every entry MUST carry a handoff brief detailed enough to be copy-pasted to
the SDK AI as a standalone bug report. Do not write "sdk issue?" and stop —
that is not actionable for someone without this repo open.
A handoff brief must answer all seven of:
1. **SDK entry point** the exact exported function involved
(e.g. `Client.Fav(ctx, SubmissionID)` in `actions.go`). Name the file.
2. **How the app calls it** which `internal/services/*.go` wrapper
invokes it, and with what arguments.
3. **Observed behaviour** what the SDK call returns: an error (quote it
verbatim), a wrong value, or a silent no-op.
4. **Expected behaviour** what FurAffinity should do server-side and
what the SDK should return.
5. **Reproduction** concrete inputs (submission ID, username) and the
steps that trigger it.
6. **Suspected layer** HTML parsing (FA changed its markup?), request
shape (wrong form fields / missing CSRF / wrong URL), auth/cookies, or
rate-limiting. Point at the likely file: parsers are `*_parser.go`,
form posts are in `actions.go` / `*_post.go` / `*_send.go`.
7. **What is NOT the SDK** explicitly rule out the app layer so the SDK
AI does not chase a frontend bug. State what you verified.
If you cannot yet fill all seven fields, the issue stays `[~]` and the brief
lists exactly which diagnostics are still needed never hand over a
half-brief as if it were complete.
---
## Issues
### #4 [~] Audit the FA SDK for bugs
- **Where:** `go-fa-api` (sibling module, `replace`d in `go.mod`).
- **Task:** Standing umbrella review the SDK surface used by
`internal/services/` for incorrect parsing, missing endpoints, or wrong
request shapes.
- **Cause tag:** `sdk`
- **Progress:** The first audit pass produced #1, #5, #9, #10 and #14 **all
now fixed and verified** (#1/#5/#9/#10 in app v0.0.7, #14 in v0.0.10; see
`SOLVED.md`). #15 was then fixed too (SDK gave `WithResolvedAvatars` a
count limit; app v0.0.14). #17 (priority rate limiting) was implemented too
— verified in app v0.0.16. **No open SDK issues currently.** Keep this
entry as the umbrella for future audit passes; file each concrete finding
as its own numbered issue.
_No open SDK issues. Fixed ones are recorded in `SOLVED.md`._
---
## Add new SDK issues below
<!-- #N title (N = next free number, shared with ISSUES.md)
- Where:
- Symptom:
- Expected:
- Cause tag: sdk
- SDK handoff brief:
1. Entry point:
2. How the app calls it:
3. Observed behaviour:
4. Expected behaviour:
5. Reproduction:
6. Suspected layer:
7. What is NOT the SDK:
-->
### #18 [x] Rate limiter: upgrade from 2-level to N-level priority
**Implemented in the SDK update** `options.go` now exposes `WithPriority`
and a multi-level `Priority` type; `WithPrioritizedRateLimiting` honours it.
FA Droid follow-up (assign tiers to reads/writes/preload/crawl) is tracked as
app-side work, not an SDK issue.
- **Component:** `go-fa-api` `ratelimit.go`, `options.go`.
- **Today:** the limiter has exactly two priorities. `WithBackgroundPriority(ctx)`
marks a request background; everything else is foreground. Background
requests wait until no foreground request is queued. Enabled via
`WithPrioritizedRateLimiting(true)`.
- **Need:** a consumer (FA Droid) wants *three+* tiers, not two:
1. user-interactive (the page on screen, user write actions),
2. speculative neighbor preload (likely-next submissions),
3. bulk background crawl (inbox/watchlist warming).
With only two levels, neighbor preload must either compete with the live
page (tier 1) or interleave with the bulk crawl (tier 2) neither is right.
- **Proposed API:** keep `WithBackgroundPriority` working (maps to the lowest
level) and add `WithPriority(ctx, Priority)` where `Priority` is an ordered
enum, e.g. `PriorityInteractive`, `PriorityNormal` (default),
`PriorityLow`, `PriorityBackground`. The limiter serves waiting goroutines
highest-priority-first; the token emission rate is unchanged.
- **Compatibility:** default (no marker) must remain `PriorityNormal`;
`WithBackgroundPriority` must remain equivalent to `PriorityBackground`.
- **Why it matters to the app:** FA Droid will then assign tier 1 to
current-page reads + the write-action queue worker, tier `PriorityLow` to
neighbor preload, and `PriorityBackground` to the inbox crawler.
### #21 [x] GetSubmission doesn't expose the viewer's favorite state
- **Where:** `go-fa-api` `submission.go` (`Submission` struct + `parseSubmission`).
- **Symptom:** A submission the logged-in user has favorited shows the
favorite heart as *empty* on the app's submission detail page. There is no
way to know a submission's favorite state from a `GetSubmission` result.
- **Expected:** `GetSubmission` should report whether the authenticated viewer
has favorited the submission, so the UI can render the correct heart state.
- **Cause tag:** `sdk`
- **SDK handoff brief:**
1. **Entry point:** `Client.GetSubmission(ctx, SubmissionID)` in
`submission.go`, which builds the result via `parseSubmission(id, doc)`.
The returned `Submission` struct (`submission.go:17-38`) has fields
ID, Title, Author, PostedAt, Rating, Category, Type, Species, Gender,
Description, DescriptionText, Tags, FileURL, ThumbURL, Width, Height,
Stats, Folders, Prev, Next and **nothing indicating favorite state**.
2. **How the app calls it:** `SubmissionService.getCached` in
`internal/services/submission.go` calls `GetSubmission`, then
`dto.FromSubmission` (`internal/dto/types.go`) copies the struct to the
wire DTO. The frontend (`SubmissionView.svelte`) does
`favorited = !!sub.favorited`.
3. **Observed behaviour:** `GetSubmission` returns a `*Submission` with no
favorite-state field. It is not a wrong value or an error the datum is
simply absent from the SDK's public type.
4. **Expected behaviour:** When `/view/{id}/` is fetched with valid `a`/`b`
cookies, FA renders either a `+Fav` (`/fav/{id}/...`) or a `Fav`
(`/unfav/{id}/...`) anchor exactly one, matching the viewer's current
state. The SDK should surface this on `Submission`, e.g. a new
`Favorited bool` field (final name your call), set true when the page
shows the `/unfav/` link. On an anonymous (no-cookie) fetch neither link
is present → `Favorited` false, which is correct.
5. **Reproduction:** With cookies set, favorite submission X on FA, then
call `GetSubmission(ctx, X)` the result cannot express that X is
favorited.
6. **Suspected layer:** HTML parsing `parseSubmission`. The SDK *already*
has the exact parser: `findFavLinks(doc, subID) (favURL, unfavURL string)`
in `actions.go` (used by `toggleFavorite` for its idempotency check).
`unfavURL != ""` means "currently favorited." `parseSubmission` just needs
to run that check and set the new field. No new scraping logic required.
7. **What is NOT the SDK:** The app side is verified ready and correct.
`SubmissionView.svelte` already reads `sub.favorited` and types it
(`favorited?: boolean`); `getCached` correctly busts and re-fetches the
submission cache after a fav write (via `WriteService` / the action
queue). The value never reaches the app only because the SDK `Submission`
struct has no field to carry it `dto.FromSubmission` has nothing to map.
- **Related (please also check):** the same gap likely exists for *watch*
state `findWatchLinks` exists in `actions.go`, but the `User` struct may
not expose whether the viewer watches that user. Worth fixing in the same
pass.
- **When it lands (FA Droid follow-up):** add `Favorited bool` to
`dto.Submission` (`json:"favorited"`), map it in `dto.FromSubmission`, and
regenerate bindings. The frontend already consumes `sub.favorited`, so no UI
change is needed.
- **Status:** done SDK update added `Submission.Favorited`; app wired it in
v0.0.22.
### #23 [ ] SubmissionInbox yields only the first page (~72 items)
- **Where:** `go-fa-api` `inbox.go` (`SubmissionInbox` + `parseSubmissionInboxPage`).
- **Symptom:** The new-submission inbox shows only ~72 items even when the
account has thousands pending. A user with ~4718 inbox submissions sees one
page.
- **Expected:** `SubmissionInbox` should walk every cursor page until FA stops
rendering a "Next 72" link.
- **Cause tag:** `sdk`
- **SDK handoff brief:**
1. **Entry point:** `Client.SubmissionInbox(ctx, ListOptions)` in `inbox.go`,
whose iterator follows `parseSubmissionInboxPage`'s returned `nextURL`.
2. **How the app calls it:** `InboxService.StreamSubmissions`
(`internal/services/inbox.go`) runs one goroutine that does
`for sub, err := range client.SubmissionInbox(ctx, fa.ListOptions{})`
`ListOptions{}` means `MaxPages: 0` (unbounded), so the app does not
cap the crawl.
3. **Observed behaviour:** the `range` completes after ~72 items the
iterator yields one page then ends. Verified on-device: the app's crawl
channel (fed one item per `yield`) closes after exactly one ~72-item
chunk, so `StreamSubmissions` reports `HasMore: false` immediately after
page 1.
4. **Expected behaviour:** FA's `/msg/submissions/` paginates via a
"Next 72" link encoding a from-id cursor; the iterator should follow it
across all ~66 pages for a 4718-item inbox.
5. **Reproduction:** logged-in client with a large submission inbox;
`count := 0; for range client.SubmissionInbox(ctx, ListOptions{}) { count++ }`
yields ~72, not the true total.
6. **Suspected layer:** HTML parsing `parseSubmissionInboxPage`. Its next-
cursor selector is `div.messagecenter-navigation a.button.more`; if FA
changed that markup the parser returns `nextURL == ""` and the iterator
stops after page 1. Check the selector against current `/msg/submissions/`
HTML (and the cursor-URL construction).
7. **What is NOT the SDK:** app side verified. `StreamSubmissions` ranges the
iterator fully on a single goroutine and streams everything it yields; it
passes `ListOptions{}` (no `MaxPages` cap). On-device logging shows the
crawl ends after one chunk the iterator simply stops yielding.
### #24 [x] Request logger drops `context.Context` (breaks trace propagation)
**Fixed in the SDK** `transport.go`'s `logRequest` now emits its record via
`logger.InfoContext(req.Context(), "fa.request", …)` instead of `logger.Info`,
so a context-aware `slog.Handler` recovers the caller's active span and the
HTTP span nests under the RPC span. A regression test,
`TestTransport_LogRequest_PropagatesRequestContext` in `transport_test.go`,
installs a context-capturing handler and asserts a sentinel value threaded
through `req.Context()` reaches the slog record guarding against a silent
revert to `Info`.
- **Where:** `go-fa-api` the HTTP transport's request logging (`transport.go`,
the `logRequest` helper / wherever `slog` records an outgoing request).
- **Type:** Enhancement, not a bug the SDK works correctly; this is a
one-line change the app needs for distributed tracing.
- **Symptom:** The app (FA Droid, WI-10) added OpenTelemetry spans. An app RPC
opens an `rpc` span and threads its `context.Context` into the SDK call. The
SDK's per-request `slog` line is currently emitted with `logger.Info(...)`,
which carries **no context** so the app's `slog` handler cannot recover the
active span, and each HTTP span becomes an unparented root instead of a child
of the RPC span.
- **Expected:** the request log record should carry the request's context so a
context-aware `slog.Handler` can read the active span from it.
- **The fix (one line):** change the request-logging call from
`logger.Info("fa.request", …)` to
`logger.InfoContext(req.Context(), "fa.request", …)` (use the `*http.Request`'s
own `Context()`). `slog` already supports `InfoContext`; no API change, no new
dependency.
- **Cause tag:** `sdk`
- **SDK handoff brief:**
1. **SDK entry point:** the HTTP transport `RoundTrip` / request path in
`transport.go` specifically the `slog` call that logs each outgoing FA
request (`logRequest`, or inline). All SDK client methods route through it.
2. **How the app calls it:** every `internal/services/*.go` wrapper calls a
`Client.*` method with a `context.Context` that now carries an OTel span;
that ctx reaches the `*http.Request` (`req.Context()`).
3. **Observed behaviour:** the request `slog` record is created without a
context, so `Handler.Handle` receives `context.Background()`.
4. **Expected behaviour:** the record carries `req.Context()`, so a
context-aware handler can extract the active span.
5. **Reproduction:** with WI-10's `diag` slog handler installed, every HTTP
span has `parentSpanId == ""` even when the call was made inside an RPC
span. After the `InfoContext` change, the HTTP span nests under the RPC
span on the same trace id.
6. **Suspected layer:** request shape / logging purely the logging call,
no parsing or auth involved.
7. **What is NOT the SDK behaviour-wise:** no functional SDK behaviour
changes request execution, parsing, retries, rate-limiting are all
untouched. This only changes which `slog` method is used so context flows.
- **Note:** WI-10 applied this change to the *local* `replace`d working copy of
`go-fa-api` so FA Droid v0.0.33 has working rpc→http span linkage. It is
**not committed in the SDK repo**. Until it is upstreamed, a clean SDK
checkout will degrade gracefully HTTP spans become valid but unparented
roots (no crash, no data loss, only the rpc↔http link is lost).

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

@@ -364,16 +364,25 @@ func TestFindFavLinks_RealFixture(t *testing.T) {
if err != nil {
t.Fatalf("read doc: %v", err)
}
// submission.html was captured for submission 65052636 (a "+Fav" page).
favURL, unfavURL := findFavLinks(doc, 65052636)
if favURL == "" {
t.Error("findFavLinks: favURL empty +Fav anchor not found in real markup")
}
if !strings.Contains(favURL, "/fav/65052636/") || !strings.Contains(favURL, "key=") {
t.Errorf("findFavLinks: favURL = %q; want a /fav/65052636/?key=... URL", favURL)
}
if unfavURL != "" {
t.Errorf("findFavLinks: unfavURL = %q; want empty on a not-yet-faved page", unfavURL)
// submission.html was captured for submission 12345678. FA renders either
// a +Fav or a -Fav anchor depending on the capturing account's current
// state never both, never neither (when authenticated). The test stays
// direction-agnostic so it doesn't break when the capturing account
// favourites/unfavourites this submission.
favURL, unfavURL := findFavLinks(doc, 12345678)
switch {
case favURL == "" && unfavURL == "":
t.Error("findFavLinks: both URLs empty fav/unfav anchor not found in real markup")
case favURL != "" && unfavURL != "":
t.Errorf("findFavLinks: both URLs set (fav=%q unfav=%q); expected exactly one", favURL, unfavURL)
case favURL != "":
if !strings.Contains(favURL, "/fav/12345678/") || !strings.Contains(favURL, "key=") {
t.Errorf("findFavLinks: favURL = %q; want a /fav/12345678/?key=... URL", favURL)
}
case unfavURL != "":
if !strings.Contains(unfavURL, "/unfav/12345678/") || !strings.Contains(unfavURL, "key=") {
t.Errorf("findFavLinks: unfavURL = %q; want a /unfav/12345678/?key=... URL", unfavURL)
}
}
}

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
}

View File

@@ -0,0 +1,90 @@
// categorized_tags demonstrates how the SDK groups FA's prefixed system
// tags into the four CategorizedTags buckets: Species (s_), Characters (c_),
// Artists (a_/u_), and Types (t_). Each bucket is printed on its own so the
// example covers every aspect of the feature.
//
// Runs anonymously by default. Set FA_A / FA_B (and ideally CF_CLEARANCE +
// FA_UA) to authenticate when the target submission requires it.
//
// go run ./examples/categorized_tags 12345678
package main
import (
"context"
"fmt"
"log"
"os"
"strconv"
"strings"
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\n\n", sub.Title, sub.Author.DisplayName)
fmt.Println("=== Plain keyword tags (no prefix) ===")
if len(sub.Tags) == 0 {
fmt.Println(" (none)")
} else {
fmt.Println(" " + strings.Join(sub.Tags, ", "))
}
ct := sub.CategorizedTags
fmt.Println()
fmt.Println("=== Species (s_) ===")
printBucket(ct.Species, "s_")
fmt.Println()
fmt.Println("=== Characters (c_) ===")
printBucket(ct.Characters, "c_")
fmt.Println()
fmt.Println("=== Artists (a_ / u_) ===")
printBucket(ct.Artists, "a_")
fmt.Println()
fmt.Println("=== Types (t_) ===")
printBucket(ct.Types, "t_")
}
func printBucket(items []string, prefix string) {
if len(items) == 0 {
fmt.Println(" (none)")
return
}
for _, v := range items {
fmt.Printf(" %s%s\n", prefix, v)
}
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View File

@@ -0,0 +1,79 @@
// favorites_page exercises the per-page favorites listing API
// ([Client.FavoritesPage]) against the live FA site so a caller can see
// exactly what fields come back: HasNext, NextPage, len(Items), and a
// sample of the tag data lifted from each figure's data-tags attribute.
//
// Favorites pagination is cursor-based: each page returns an opaque
// NextPage token that addresses the next page. Pass it back in on the
// next call; treat empty as end-of-pagination.
//
// Required environment variables:
//
// FA_A — the `a` session cookie
// FA_B — the `b` session cookie
// CF_CLEARANCE — (optional) cf_clearance cookie if Cloudflare challenges
// FA_UA — (optional) User-Agent matching CF_CLEARANCE
//
// Usage:
//
// go run ./examples/favorites_page <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 && n > 0 {
maxPages = n
}
}
opts := []fa.Option{
fa.WithCookies(fa.Cookies{A: os.Getenv("FA_A"), B: os.Getenv("FA_B")}),
}
if cf := os.Getenv("CF_CLEARANCE"); cf != "" {
opts = append(opts, fa.WithCloudflare(fa.CFCookies{Clearance: cf}))
}
if ua := os.Getenv("FA_UA"); ua != "" {
opts = append(opts, fa.WithUserAgent(ua))
}
client := fa.New(opts...)
cursor := ""
pageNum := 0
for {
pageNum++
lp, err := client.FavoritesPage(context.Background(), user, cursor)
if err != nil {
log.Fatalf("FavoritesPage(cursor=%q): %v", cursor, err)
}
fmt.Printf("=== page %d cursor=%q items=%d HasNext=%v NextPage=%q ===\n",
pageNum, cursor, len(lp.Items), lp.HasNext, lp.NextPage)
for i, sub := range lp.Items {
fmt.Printf(" [%d] id=%d rating=%s author=%s title=%q\n",
i, sub.ID, sub.Rating, sub.Author.Name, sub.Title)
}
if !lp.HasNext {
fmt.Printf("\nreached end of pagination after %d page(s)\n", pageNum)
return
}
if maxPages > 0 && pageNum >= maxPages {
fmt.Printf("\nstopped at maxPages=%d (HasNext was still true; next cursor=%q)\n", maxPages, lp.NextPage)
return
}
cursor = lp.NextPage
}
}

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

@@ -161,7 +161,7 @@ func TestRefreshFixtures(t *testing.T) {
},
{
name: "favorites_page1.html",
url: urls.Favorites(favoritesUser, 1),
url: urls.Favorites(favoritesUser),
requires: []string{favoritesUser},
notes: "favorites per-item Author should be the original artist",
},

View File

@@ -3,6 +3,7 @@ package fa
import (
"context"
"iter"
"strconv"
"github.com/PuerkitoBio/goquery"
@@ -12,32 +13,142 @@ import (
// Gallery iterates the submissions in a user's main gallery, newest first.
//
// Each yielded *Submission carries only the fields visible on the listing
// page: ID, Title, Author (for favorites), ThumbURL, and Rating. Call
// page: ID, Title, Author (for favorites), ThumbURL, Rating, and the Tags
// / CategorizedTags parsed from the figure's data-tags attribute. 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.listPagedSection(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.listPagedSection(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)
//
// Favorites use a fave-ID cursor for pagination, not sequential page
// numbers, so [ListOptions.StartPage] is ignored — the walk always
// begins at the newest favorite. [ListOptions.MaxPages] still bounds
// the crawl.
func (c *Client) Favorites(ctx context.Context, name string, opts ListOptions, reqOpts ...Option) iter.Seq2[*Submission, error] {
return func(yield func(*Submission, error) bool) {
cursor := ""
pagesFetched := 0
for {
if opts.reachedLimit(pagesFetched) {
return
}
lp, err := c.FavoritesPage(ctx, name, cursor, reqOpts...)
if err != nil {
yield(nil, err)
return
}
pagesFetched++
if len(lp.Items) == 0 {
return
}
for _, s := range lp.Items {
if !yield(s, nil) {
return
}
}
if !lp.HasNext {
return
}
cursor = lp.NextPage
}
}
}
// listGallerySection is the shared engine for Gallery / Scraps / Favorites.
// urlFn picks the section-specific URL builder; the rest of the pagination
// machinery is identical across all three sections.
func (c *Client) listGallerySection(
// GalleryPage fetches a single page of /gallery/{name}/ and returns the
// items along with whether more pages exist. Pages are 1-based; pass 0 or
// 1 for the first page. Use this when driving pagination manually
// (resuming from a checkpoint, distributing pages across workers); use
// [Client.Gallery] when you just want every item in order.
//
// On a non-final page the returned [ListingPage].NextPage is the next
// page number as a decimal string ("2", "3", …) — pass it back to the
// next call after [strconv.Atoi], or treat it as opaque.
func (c *Client) GalleryPage(ctx context.Context, name string, page int, reqOpts ...Option) (*ListingPage, error) {
return c.fetchNumberedPage(ctx, name, page, urls.Gallery, reqOpts)
}
// ScrapsPage is the single-page counterpart to [Client.Scraps]. See
// [Client.GalleryPage] for usage notes.
func (c *Client) ScrapsPage(ctx context.Context, name string, page int, reqOpts ...Option) (*ListingPage, error) {
return c.fetchNumberedPage(ctx, name, page, urls.Scraps, reqOpts)
}
// FavoritesPage fetches a single page of /favorites/{name}/, addressed
// by the cursor FA emitted on the previous page (empty string for the
// first page). FA paginates favorites with a fave-ID cursor — not a
// sequential page number — so the caller must walk forward by passing
// the returned [ListingPage].NextPage value into the next call. Passing
// a guessed cursor (e.g. "2") makes FA silently return the first page
// and the loop will not terminate.
func (c *Client) FavoritesPage(ctx context.Context, name string, cursor string, reqOpts ...Option) (*ListingPage, error) {
out := &ListingPage{}
err := c.fetch(ctx, urls.FavoritesCursor(name, cursor), func(doc *goquery.Document) error {
items, nextURL, hasNext := parseListingPage(doc, c.cfg.jsonListings)
out.Items = items
out.HasNext = hasNext
if hasNext {
out.NextPage = favoritesCursorFromURL(nextURL)
// If the markup was unrecognisable, refuse to claim a next
// page rather than re-fetching the first one in a loop.
if out.NextPage == "" {
out.HasNext = false
}
}
return nil
}, reqOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// fetchNumberedPage is the shared primitive for page-number-based
// listings (Gallery / Scraps). urlFn picks the section-specific URL
// builder; the rest of the pagination machinery is identical.
func (c *Client) fetchNumberedPage(
ctx context.Context,
name string,
page int,
urlFn func(string, int) string,
reqOpts []Option,
) (*ListingPage, error) {
if page < 1 {
page = 1
}
out := &ListingPage{}
err := c.fetch(ctx, urlFn(name, page), func(doc *goquery.Document) error {
items, _, hasNext := parseListingPage(doc, c.cfg.jsonListings)
out.Items = items
out.HasNext = hasNext
if hasNext {
out.NextPage = strconv.Itoa(page + 1)
}
return nil
}, reqOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// listPagedSection is the shared engine for the page-number-based
// listing iterators (Gallery / Scraps). Favorites has its own loop in
// [Client.Favorites] because its pagination is cursor-based.
func (c *Client) listPagedSection(
ctx context.Context,
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()
@@ -46,28 +157,21 @@ func (c *Client) listGallerySection(
if opts.reachedLimit(pagesFetched) {
return
}
var (
items []*Submission
hasNext bool
)
err := c.fetch(ctx, urlFn(name, page), func(doc *goquery.Document) error {
items, hasNext = parseGalleryPage(doc, c.cfg.jsonListings)
return nil
})
lp, err := c.fetchNumberedPage(ctx, name, page, urlFn, reqOpts)
if err != nil {
yield(nil, err)
return
}
pagesFetched++
if len(items) == 0 {
if len(lp.Items) == 0 {
return
}
for _, s := range items {
for _, s := range lp.Items {
if !yield(s, nil) {
return
}
}
if !hasNext {
if !lp.HasNext {
return
}
page++

198
gallery_page_test.go Normal file
View File

@@ -0,0 +1,198 @@
package fa
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
)
// fakeGalleryPage builds a minimal gallery-page response with two figures.
// nextHref is the next-page URL emitted in the Next form; empty means no
// Next button (last page).
func fakeGalleryPage(startID int, nextHref string) string {
var b strings.Builder
b.WriteString(`<html><body>`)
for i := 0; i < 2; i++ {
id := startID + i
fmt.Fprintf(&b, `
<figure id="sid-%d" class="t-image r-general">
<a href="/view/%d/" title="Sub %d">
<img data-tags="u_someartist c_artwork_digital t_all s_wolf wolf" src="//d.example/t/%d.png"/>
</a>
<figcaption>
<p>Sub %d</p>
<a href="/user/someartist/">someartist</a>
</figcaption>
</figure>`, id, id, id, id, id)
}
if nextHref != "" {
fmt.Fprintf(&b, `<form action=%q method="get"><button class="button standard" type="submit">Next</button></form>`, nextHref)
}
b.WriteString(`</body></html>`)
return b.String()
}
func TestGalleryPage_HasNextPropagates(t *testing.T) {
var requests atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/gallery/u/", func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
_, _ = w.Write([]byte(fakeGalleryPage(1000, "/gallery/u/2/")))
})
mux.HandleFunc("/gallery/u/2/", func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
_, _ = w.Write([]byte(fakeGalleryPage(2000, "")))
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
first, err := client.GalleryPage(context.Background(), "u", 1)
if err != nil {
t.Fatalf("GalleryPage(1): %v", err)
}
if !first.HasNext {
t.Error("first.HasNext = false; want true")
}
if first.NextPage != "2" {
t.Errorf("first.NextPage = %q; want \"2\"", first.NextPage)
}
if len(first.Items) != 2 {
t.Fatalf("first.Items len = %d; want 2", len(first.Items))
}
if first.Items[0].ID != 1000 {
t.Errorf("first.Items[0].ID = %d; want 1000", first.Items[0].ID)
}
if len(first.Items[0].Tags) == 0 || len(first.Items[0].CategorizedTags.Species) == 0 {
t.Errorf("first.Items[0]: tags not populated from data-tags: %+v", first.Items[0])
}
last, err := client.GalleryPage(context.Background(), "u", 2)
if err != nil {
t.Fatalf("GalleryPage(2): %v", err)
}
if last.HasNext {
t.Error("last.HasNext = true; want false (last page)")
}
if last.NextPage != "" {
t.Errorf("last.NextPage = %q; want empty", last.NextPage)
}
if requests.Load() != 2 {
t.Errorf("requests = %d; want 2", requests.Load())
}
}
func TestScrapsPage_HitsScrapsRoute(t *testing.T) {
var gotPath string
mux := http.NewServeMux()
mux.HandleFunc("/scraps/u/", func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
_, _ = w.Write([]byte(fakeGalleryPage(1, "")))
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
if _, err := client.ScrapsPage(context.Background(), "u", 1); err != nil {
t.Fatalf("ScrapsPage: %v", err)
}
if gotPath != "/scraps/u/" {
t.Errorf("gotPath = %q; want /scraps/u/", gotPath)
}
}
func TestFavoritesPage_CursorChain(t *testing.T) {
var requests []string
var mu sync.Mutex
record := func(p string) {
mu.Lock()
requests = append(requests, p)
mu.Unlock()
}
mux := http.NewServeMux()
mux.HandleFunc("/favorites/u/", func(w http.ResponseWriter, r *http.Request) {
record(r.URL.Path)
_, _ = w.Write([]byte(fakeGalleryPage(1000, "/favorites/u/9999/next")))
})
mux.HandleFunc("/favorites/u/9999/next", func(w http.ResponseWriter, r *http.Request) {
record(r.URL.Path)
_, _ = w.Write([]byte(fakeGalleryPage(2000, "")))
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
first, err := client.FavoritesPage(context.Background(), "u", "")
if err != nil {
t.Fatalf("FavoritesPage(first): %v", err)
}
if !first.HasNext {
t.Fatal("first.HasNext = false; want true")
}
if first.NextPage != "9999" {
t.Errorf("first.NextPage = %q; want \"9999\" (cursor)", first.NextPage)
}
last, err := client.FavoritesPage(context.Background(), "u", first.NextPage)
if err != nil {
t.Fatalf("FavoritesPage(cursor): %v", err)
}
if last.HasNext {
t.Error("last.HasNext = true; want false")
}
if last.NextPage != "" {
t.Errorf("last.NextPage = %q; want empty", last.NextPage)
}
want := []string{"/favorites/u/", "/favorites/u/9999/next"}
mu.Lock()
defer mu.Unlock()
if len(requests) != len(want) {
t.Fatalf("requests = %v; want %v", requests, want)
}
for i, w := range want {
if requests[i] != w {
t.Errorf("requests[%d] = %q; want %q", i, requests[i], w)
}
}
}
// TestFavorites_IteratorTerminates guards against the cursor-loop
// regression that brought us here: with sequential page numbers, the
// Favorites iterator never terminated because FA fell back to page 1
// for every fake-numbered cursor.
func TestFavorites_IteratorTerminates(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/favorites/u/", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(fakeGalleryPage(1, "/favorites/u/42/next")))
})
mux.HandleFunc("/favorites/u/42/next", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(fakeGalleryPage(3, "")))
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := newE2EClient(t, srv)
count := 0
for sub, err := range client.Favorites(context.Background(), "u", ListOptions{}) {
if err != nil {
t.Fatalf("Favorites: %v", err)
}
if sub == nil {
t.Fatal("nil sub")
}
count++
if count > 10 {
t.Fatalf("iterator did not terminate; count > 10")
}
}
if count != 4 {
t.Errorf("count = %d; want 4 (2 per page * 2 pages)", count)
}
}

View File

@@ -19,6 +19,15 @@ import (
// pure HTML the same behaviour as before [WithExperimentalJSONListings]
// existed.
func parseGalleryPage(doc *goquery.Document, useJSON bool) (items []*Submission, hasNext bool) {
items, _, hasNext = parseListingPage(doc, useJSON)
return items, hasNext
}
// parseListingPage parses one page of a listing endpoint and also returns
// the raw next-page URL FA emits in its "Next" pagination form. Callers
// that need to chain across cursor-based pages (Favorites) consume the
// URL; callers that don't (Gallery / Scraps) can ignore it.
func parseListingPage(doc *goquery.Document, useJSON bool) (items []*Submission, nextURL string, hasNext bool) {
var jsonData listingJSONMap
if useJSON {
jsonData = readListingJSON(doc)
@@ -28,8 +37,8 @@ func parseGalleryPage(doc *goquery.Document, useJSON bool) (items []*Submission,
items = append(items, s)
}
})
hasNext = detectNextPage(doc)
return items, hasNext
nextURL, hasNext = nextPageURL(doc)
return items, nextURL, hasNext
}
// parseGalleryFigure lifts a single submission preview from a
@@ -85,6 +94,15 @@ func parseGalleryFigure(sel *goquery.Selection, jsonData listingJSONMap) *Submis
}
}
// data-tags on the figure's <img> carries both the unprefixed keyword
// list and the prefixed system tags (s_/c_/a_/u_/t_). Splitting it lets
// callers classify listing items without an extra /view/ fetch.
if img := sel.Find("img[data-tags]").First(); img.Length() > 0 {
if raw, ok := img.Attr("data-tags"); ok {
applyListingDataTags(s, raw)
}
}
// JSON enrichment preferred sources for the fields it carries.
if jsonData != nil {
if entry, ok := jsonData[id]; ok {
@@ -105,3 +123,35 @@ func parseGalleryFigure(sel *goquery.Selection, jsonData listingJSONMap) *Submis
return s
}
// applyListingDataTags splits the whitespace-separated data-tags attribute
// FA emits on listing-page <img> elements and routes each token to either
// CategorizedTags (when the token has a known single-letter prefix
// s_/c_/a_/u_/t_) or Tags (everything else).
//
// The prefix mapping mirrors the /view/ parser in submission_parser.go so a
// listing-path Submission carries the same categorisation a /view/-path one
// would, modulo tokens FA can't represent in this flat attribute (multi-word
// tags, the a_ vs u_ distinction).
func applyListingDataTags(s *Submission, raw string) {
for _, tok := range strings.Fields(raw) {
if len(tok) >= 3 && tok[1] == '_' {
name := tok[2:]
switch tok[0] {
case 's':
s.CategorizedTags.Species = append(s.CategorizedTags.Species, name)
continue
case 'c':
s.CategorizedTags.Characters = append(s.CategorizedTags.Characters, name)
continue
case 'a', 'u':
s.CategorizedTags.Artists = append(s.CategorizedTags.Artists, name)
continue
case 't':
s.CategorizedTags.Types = append(s.CategorizedTags.Types, name)
continue
}
}
s.Tags = append(s.Tags, tok)
}
}

View File

@@ -62,6 +62,99 @@ func TestParseGalleryPage_Synthetic(t *testing.T) {
}
}
func TestParseGalleryFigure_DataTags(t *testing.T) {
const html = `<html><body>
<figure id="sid-2001" class="t-image r-general">
<a href="/view/2001/" title="Mixed Tags">
<img data-tags="u_someartist c_artwork_digital t_all s_wolf wolf solo digital landscape" src="//d.example/thumb/2001.png"/>
</a>
</figure>
<figure id="sid-2002" class="t-image r-general">
<a href="/view/2002/" title="No Tags">
<img src="//d.example/thumb/2002.png"/>
</a>
</figure>
<figure id="sid-2003" class="t-image r-general">
<a href="/view/2003/" title="Only Keywords">
<img data-tags="wolf solo" src="//d.example/thumb/2003.png"/>
</a>
</figure>
</body></html>`
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
if err != nil {
t.Fatalf("setup: %v", err)
}
items, _ := parseGalleryPage(doc, false)
if len(items) != 3 {
t.Fatalf("items = %d; want 3", len(items))
}
// Mixed prefixed + unprefixed.
mixed := items[0]
wantTags := []string{"wolf", "solo", "digital", "landscape"}
if !equalStrings(mixed.Tags, wantTags) {
t.Errorf("items[0].Tags = %v; want %v", mixed.Tags, wantTags)
}
if !equalStrings(mixed.CategorizedTags.Species, []string{"wolf"}) {
t.Errorf("items[0].Species = %v", mixed.CategorizedTags.Species)
}
if !equalStrings(mixed.CategorizedTags.Characters, []string{"artwork_digital"}) {
t.Errorf("items[0].Characters = %v", mixed.CategorizedTags.Characters)
}
if !equalStrings(mixed.CategorizedTags.Types, []string{"all"}) {
t.Errorf("items[0].Types = %v", mixed.CategorizedTags.Types)
}
if !equalStrings(mixed.CategorizedTags.Artists, []string{"someartist"}) {
t.Errorf("items[0].Artists = %v", mixed.CategorizedTags.Artists)
}
// Missing data-tags: both slices stay nil.
if items[1].Tags != nil {
t.Errorf("items[1].Tags = %v; want nil", items[1].Tags)
}
if items[1].CategorizedTags.Species != nil ||
items[1].CategorizedTags.Characters != nil ||
items[1].CategorizedTags.Artists != nil ||
items[1].CategorizedTags.Types != nil {
t.Errorf("items[1].CategorizedTags = %+v; want zero", items[1].CategorizedTags)
}
// Unprefixed-only: everything lands in Tags.
if !equalStrings(items[2].Tags, []string{"wolf", "solo"}) {
t.Errorf("items[2].Tags = %v", items[2].Tags)
}
if items[2].CategorizedTags.Species != nil {
t.Errorf("items[2].Species = %v; want nil", items[2].CategorizedTags.Species)
}
}
func TestParseGalleryPage_RealFixtureTags(t *testing.T) {
raw := loadFixture(t, "gallery_page1.html")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("read doc: %v", err)
}
items, _ := parseGalleryPage(doc, false)
if len(items) == 0 {
t.Fatal("real fixture: no items parsed")
}
var withTags, withSpecies int
for _, it := range items {
if len(it.Tags) > 0 {
withTags++
}
if len(it.CategorizedTags.Species) > 0 {
withSpecies++
}
}
if withTags == 0 {
t.Error("no items got Tags populated from data-tags")
}
if withSpecies == 0 {
t.Error("no items got CategorizedTags.Species populated from data-tags")
}
}
func TestParseGalleryPage_RealFixture(t *testing.T) {
raw := loadFixture(t, "gallery_page1.html")
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))

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

@@ -36,10 +36,25 @@ func Scraps(name string, page int) string {
return Host + "/scraps/" + safeName(name) + "/" + pageSegment(page)
}
// Favorites returns the URL for a user's favorites page. FA uses a numeric
// page parameter; the first page is 1.
func Favorites(name string, page int) string {
return Host + "/favorites/" + safeName(name) + "/" + pageSegment(page)
// Favorites returns the URL for the first page of a user's favorites.
// FA paginates favorites with a fave-ID cursor (see [FavoritesCursor]),
// not sequential page numbers — passing /favorites/{user}/{N}/ with a
// small integer N silently falls back to the first page. Use this for
// the first page only; follow the cursor returned in [ListingPage].NextPage
// for subsequent pages.
func Favorites(name string) string {
return Host + "/favorites/" + safeName(name) + "/"
}
// FavoritesCursor returns the URL for a follow-up favorites page,
// addressed by the fave-ID cursor FA emits on the previous page's "Next"
// form (e.g. /favorites/{user}/1951234825/next). The cursor is opaque
// to the SDK — pass through whatever [ListingPage].NextPage gave you.
func FavoritesCursor(name, cursor string) string {
if cursor == "" {
return Favorites(name)
}
return Host + "/favorites/" + safeName(name) + "/" + cursor + "/next"
}
// Journal returns the URL for a single journal entry.

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

@@ -52,11 +52,17 @@ func parseNoteListItem(item *goquery.Selection) *NotePreview {
}
// Note ID lives in the href: /msg/pms/{folder}/{id}/#message. Strip the
// fragment first so extractIntFromHref picks the trailing numeric path.
// fragment first, then take the *last* numeric segment — the folder
// number (e.g. 1) appears before the note ID and would otherwise win
// the "first numeric segment" race in extractIntFromHref.
if i := strings.Index(href, "#"); i != -1 {
href = href[:i]
}
np.ID = NoteID(extractIntFromHref(href))
for _, seg := range strings.Split(href, "/") {
if n, err := parseID[NoteID](seg); err == nil && n != 0 {
np.ID = n
}
}
// Read/unread: classes on the subject link.
if class, _ := subjectLink.Attr("class"); strings.Contains(class, "note-unread") || strings.Contains(class, "unread") && !strings.Contains(class, "note-read") {

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

View File

@@ -6,6 +6,30 @@ import (
"github.com/PuerkitoBio/goquery"
)
// ListingPage is one page of a listing endpoint (Gallery / Scraps /
// Favorites). It carries everything an external caller needs to drive
// pagination by hand: the items, whether FA exposed a "next page" link,
// and an opaque NextPage token to pass back into the next per-page call.
//
// External scrapers that want to manage their own loop (resume from a
// checkpoint, run pages in parallel, throttle differently) should call
// the per-page methods ([Client.GalleryPage], [Client.ScrapsPage],
// [Client.FavoritesPage]) and stop when HasNext is false. Callers that
// just want every item in order should keep using the iter.Seq2-shaped
// methods ([Client.Gallery] et al.), which walk pages internally.
//
// NextPage's contents differ by endpoint — for Gallery / Scraps it is
// the next 1-based page number as a decimal string ("2", "3", …); for
// Favorites it is the fave-ID cursor FA emits on the "Next" form
// (because favorites pagination is cursor-based, not page-number-based).
// Treat the value as opaque: pass whatever you got back to the next
// call without parsing.
type ListingPage struct {
Items []*Submission
HasNext bool
NextPage string // "" when !HasNext; otherwise the opaque token to pass back
}
// ListOptions configures the pagination of a simple iterator method like
// [Client.Gallery] or [Client.Notes]. Filtered iterators ([Client.Search],
// [Client.Browse]) use their own option structs that fold the same fields
@@ -42,17 +66,57 @@ func (o ListOptions) reachedLimit(pagesFetched int) bool {
// FA's beta theme renders pagination as either a Next form button or a
// hyperlink with a recognisable label.
func detectNextPage(doc *goquery.Document) bool {
if doc.Find("form button.button.standard:contains('Next')").Length() > 0 {
return true
url, _ := nextPageURL(doc)
return url != ""
}
// nextPageURL returns the action/href that the "Next" pagination control
// would navigate to, along with a flag indicating whether one was found.
// Returns ("", false) on the last page (FA emits no Next form/anchor, or
// emits it inside an HTML comment that doesn't parse as an element).
func nextPageURL(doc *goquery.Document) (string, bool) {
var action string
doc.Find("form").EachWithBreak(func(_ int, f *goquery.Selection) bool {
if f.Find("button.button.standard:contains('Next')").Length() == 0 {
return true
}
action, _ = f.Attr("action")
return false
})
if action != "" {
return action, true
}
hit := false
var href string
doc.Find("a.button.standard, a.button-link, a.pagination-next").EachWithBreak(func(_ int, sel *goquery.Selection) bool {
text := strings.ToLower(trimText(sel))
if strings.Contains(text, "next") || strings.Contains(text, "older") {
hit = true
href, _ = sel.Attr("href")
return false
}
return true
})
return hit
if href == "" {
return "", false
}
return href, true
}
// favoritesCursorFromURL extracts the fave-ID cursor segment from a
// /favorites/{user}/{cursor}/next URL. Returns "" if the URL does not
// match that shape (in which case the caller treats the listing as
// exhausted rather than chasing a malformed cursor).
func favoritesCursorFromURL(rawURL string) string {
// Strip query / fragment, then split. Favorites paths can be relative
// ("/favorites/u/123/next") or absolute — handle both.
rawURL = strings.TrimPrefix(rawURL, "https://www.furaffinity.net")
rawURL = strings.TrimPrefix(rawURL, "http://www.furaffinity.net")
if i := strings.IndexAny(rawURL, "?#"); i >= 0 {
rawURL = rawURL[:i]
}
parts := strings.Split(strings.Trim(rawURL, "/"), "/")
// Expect ["favorites", "{user}", "{cursor}", "next"].
if len(parts) != 4 || parts[0] != "favorites" || parts[3] != "next" {
return ""
}
return parts[2]
}

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

43
scripts/refresh-user-fixture.sh Executable file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Refresh testdata/html/user.html (and any other fixtures whose env vars are
# set) by re-running TestRefreshFixtures against live FA with your cookies.
#
# Required:
# FA_A, FA_B session cookies
# FA_TEST_USER your FA username (lowercase, e.g. soxx-thefennec)
#
# Strongly recommended (otherwise Cloudflare will likely block the request):
# CF_CLEARANCE cf_clearance cookie from the same browser session
# FA_UA the exact User-Agent that produced CF_CLEARANCE
#
# Usage:
# FA_A=... FA_B=... CF_CLEARANCE=... FA_UA="..." FA_TEST_USER=soxx-thefennec \
# ./scripts/refresh-user-fixture.sh
#
# Or export the vars in your shell first, then just run the script.
set -euo pipefail
cd "$(dirname "$0")/.."
missing=()
for var in FA_A FA_B FA_TEST_USER; do
if [[ -z "${!var:-}" ]]; then
missing+=("$var")
fi
done
if (( ${#missing[@]} )); then
echo "error: missing required env vars: ${missing[*]}" >&2
exit 1
fi
if [[ -z "${CF_CLEARANCE:-}" || -z "${FA_UA:-}" ]]; then
echo "warning: CF_CLEARANCE or FA_UA not set; the request will likely hit a Cloudflare challenge" >&2
fi
echo "refreshing fixtures for user=$FA_TEST_USER"
go test -tags=fixtures -run TestRefreshFixtures -v ./...
echo
echo "re-running user parser tests against the new fixture"
go test -run TestParseUser ./...

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

@@ -13,6 +13,15 @@ import (
"git.anthrove.art/public/go-fa-api/internal/urls"
)
// CategorizedTags groups FA's prefixed system tags by category. Names are
// stored without their prefix (e.g. "s_hybrid_species" → Species "hybrid_species").
type CategorizedTags struct {
Species []string
Characters []string
Artists []string
Types []string
}
// Submission is a fully resolved FA submission as seen on /view/{id}/.
type Submission struct {
ID SubmissionID
@@ -26,7 +35,20 @@ type Submission struct {
Gender Gender
Description string // raw HTML; sanitise before rendering to a browser
DescriptionText string // plaintext convenience
Tags []string
// Tags holds the user-supplied keyword tags. On /view/-path Submissions
// these come from div.submission-tags anchors. On listing-path
// Submissions (Gallery/Scraps/Favorites/Browse/Search/SubmissionInbox)
// they come from the figure's data-tags attribute, which carries the
// same keywords FA renders on /view/ for that submission.
Tags []string
// CategorizedTags groups FA's prefixed system tags by category.
// On /view/-path Submissions FA emits these as tag-block entries inside
// div.submission-tags with prefixes s_ (species), c_ (character),
// a_/u_ (artist), and t_ (type). On listing-path Submissions the same
// prefixed tokens are parsed out of the figure's data-tags attribute;
// the a_ vs u_ distinction is lost there because FA collapses both into
// u_ in that flat list.
CategorizedTags CategorizedTags
FileURL string // absolute CDN URL; pass to Download
ThumbURL string
Width int // 0 if unknown / non-image
@@ -47,7 +69,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 +81,7 @@ func (c *Client) GetSubmission(ctx context.Context, id SubmissionID) (*Submissio
}
out = s
return nil
})
}, opts...)
if err != nil {
return nil, err
}
@@ -72,13 +94,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

@@ -156,6 +156,32 @@ func parseSubmission(id SubmissionID, doc *goquery.Document) (*Submission, error
}
})
// Prefixed system tags FA renders these as tag-block anchors with a
// data-tag-name attribute carrying a leading single-letter prefix:
// s_ species, c_ character, a_/u_ artist, t_ type.
// They are paired with a sibling <span class="tag-invalid"> and have no
// /search/ href, so they are skipped by the keyword pass above.
doc.Find("div.submission-tags a.tag-block[data-tag-name]").Each(func(_ int, a *goquery.Selection) {
raw := strings.TrimSpace(trimAttr(a, "data-tag-name"))
if len(raw) < 3 || raw[1] != '_' {
return
}
name := raw[2:]
if name == "" {
return
}
switch raw[0] {
case 's':
s.CategorizedTags.Species = append(s.CategorizedTags.Species, name)
case 'c':
s.CategorizedTags.Characters = append(s.CategorizedTags.Characters, name)
case 'a', 'u':
s.CategorizedTags.Artists = append(s.CategorizedTags.Artists, name)
case 't':
s.CategorizedTags.Types = append(s.CategorizedTags.Types, name)
}
})
// File URL FA renders a "Download" button in #submission-options that
// links to the canonical file for *every* submission type. For visual
// art it equals the #submissionImg source; for stories and music it's

View File

@@ -54,6 +54,10 @@ const syntheticSubmissionHTML = `<html><body>
<div>
<span class="tags"><span><a href="javascript:void(0);" class="tag-block"></a><a href="/search/@keywords wolf">wolf</a></span></span>
<span class="tags"><span><a href="javascript:void(0);" class="tag-block"></a><a href="/search/@keywords art">art</a></span></span>
<span class="tags"><span><a href="javascript:void(0);" data-tag-name="s_wolf" class="tag-block"></a><span class="tag-invalid">s_wolf</span></span></span>
<span class="tags"><span><a href="javascript:void(0);" data-tag-name="c_artwork_digital" class="tag-block"></a><span class="tag-invalid">c_artwork_digital</span></span></span>
<span class="tags"><span><a href="javascript:void(0);" data-tag-name="t_general_furry_art" class="tag-block"></a><span class="tag-invalid">t_general_furry_art</span></span></span>
<span class="tags"><span><a href="javascript:void(0);" data-tag-name="u_somefurry" class="tag-block"></a><span class="tag-invalid">u_somefurry</span></span></span>
</div>
</div>
@@ -110,6 +114,34 @@ func TestParseSubmission_Synthetic(t *testing.T) {
if !strings.Contains(sub.Description, "world") {
t.Errorf("Description missing expected content: %q", sub.Description)
}
catChecks := []struct {
name string
got []string
want []string
}{
{"Species", sub.CategorizedTags.Species, []string{"wolf"}},
{"Characters", sub.CategorizedTags.Characters, []string{"artwork_digital"}},
{"Types", sub.CategorizedTags.Types, []string{"general_furry_art"}},
{"Artists", sub.CategorizedTags.Artists, []string{"somefurry"}},
}
for _, c := range catChecks {
if !equalStrings(c.got, c.want) {
t.Errorf("CategorizedTags.%s = %v; want %v", c.name, c.got, c.want)
}
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// TestParseSubmission_FavoritedState verifies parseSubmission reports the

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -67,7 +67,12 @@
<!-- EU request: yes -->
<body class="c-bodyColor"
id="pageid-redirect" data-static-path="/themes/beta"
id="pageid-messagecenter-pms-view" data-static-path="/themes/beta"
data-user-blocklist=""
data-user-logged-in="1"
data-tag-blocklist="music"
data-tag-blocklist-hide-tagless="0"
data-tag-blocklist-nonce="a0484bd071eb18ddc52c45696f83e98306600844e096df29c41b770b7ef8da61"
>
<script type="text/javascript">
@@ -104,6 +109,11 @@
<div class="mobile-nav-content-container">
<div class="aligncenter">
<a href="/user/soxx-thefennec/"><img class="loggedin_user_avatar avatar" alt="SoXX-TheFennec" src="//a.furaffinity.net/1515442832/soxx-thefennec.gif"/></a>
<h2 style="margin-bottom:0"><a href="/user/soxx-thefennec/">SoXX-TheFennec</a></h2>
<a href="/user/soxx-thefennec/">Userpage</a> |
<a href="/msg/pms/">Notes</a> |
<a href="/controls/journal/">Journals</a> |
<a href="/plus/"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"> FA+</a> |
<a href="https://shop.furaffinity.net" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"> Shop</a>
<br />
@@ -111,6 +121,7 @@
<hr>
<h2><a href="/browse/">Browse</a></h2>
<h2><a href="/search/">Search</a></h2>
<h2><a href="/submit/">Upload</a></h2>
<div class="nav-ac-container">
<label for="mobile-menu-submenu-0"><h2 style="margin-top:0;padding-top:0">Support &#x25BC;</h2></label>
@@ -142,22 +153,65 @@
<h3>SUPPORT</h3>
<a href="/help/#contact">Contact Us</a><br />
<a href="/controls/troubletickets/">REPORT A PROBLEM</a><br />
<a href="https://status.furaffinity.net/">Site Status</a>
</article>
</div>
<div class="mobile-sfw-toggle">
<h2>SFW Mode</h2>
<div class="sfw-toggle type-slider slider-button-wrapper">
<input type="checkbox" id="sfw-toggle-mobile" class="slider-toggle" />
<label class="slider-viewport" for="sfw-toggle-mobile" title="Quick toggle to show or hide Mature and Adult submissions">
<div class="slider">
<div class="slider-button">&nbsp;</div>
<div class="slider-content left"><span>SFW</span></div>
<div class="slider-content right"><span>NSFW</span></div>
</div>
</label>
</div>
</div>
<div class="nav-ac-container">
<label for="mobile-menu-submenu-1"><h2 style="margin-top:0;padding-top:0">Settings &#x25BC;</h2></label>
<input id="mobile-menu-submenu-1" name="accordion-1" type="checkbox" />
<article class="nav-ac-content nav-ac-content-dropdown">
<h3>ACCOUNT INFORMATION</h3>
<a href="/controls/settings/">Account Settings</a><br>
<a href="/controls/site-settings/">Global Site Settings</a><br>
<a href="/controls/user-settings/">User Settings</a>
<h3>CUSTOMIZE USER PROFILE</h3>
<a href="/controls/profile/">Profile Info</a><br>
<a href="/controls/profilebanner/">Profile Banner</a><br>
<a href="/controls/contacts/">Contacts and Social Media</a><br>
<a href="/controls/avatar/">Avatar Management</a>
<h3>MANAGE MY CONTENT</h3>
<a href="/controls/submissions/">Submissions</a><br>
<a href="/controls/folders/submissions/">Folders</a><br>
<a href="/controls/journal/">Journals</a><br>
<a href="/controls/favorites/">Favorites</a><br>
<a href="/controls/buddylist/">Watches</a><br>
<a href="/controls/shouts/">Shouts</a><br>
<a href="/controls/badges/">Badges</a><br>
<a href="/controls/user-icons/">User Icons</a>
<h3>SECURITY</h3>
<a href="/controls/sessions/logins/">Active Sessions</a><br>
<a href="/controls/sessions/logs/">Activity Log</a><br>
<a href="/controls/sessions/labels/">Browser Labels</a>
</article>
</div>
<hr>
<hr>
<h2><div class="inline hideonmobile hideontablet">
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
</div>
<div class="inline hideondesktop">
<a href="/login">Log In</a><br>
<a href="/register">Create an Account</a>
</div>
<h2><form class="post-btn logout-link" method="post" action="/logout/"><button type="submit">Log Out</button><input type="hidden" name="key" value="0b178006ed9c0137089032690e59b1799185aef779fcc884630f2cc5a100bf57"/></form>
<script type="text/javascript">
_fajs.push(['init_logout_button', '.logout-link button']);
</script>
</h2>
@@ -169,6 +223,11 @@
</div>
<div class="mobile-notification-bar">
<a class="notification-container inline" href="/msg/submissions/" title="5,161 Submission Notifications">5161S</a>
<a class="notification-container inline" href="/msg/others/#journals" title="75 Journal Notifications">75J</a>
</div>
@@ -229,6 +288,7 @@
<h3>Support</h3>
<a href="/help/#contact">Contact Us</a>
<a href="/controls/troubletickets/">Report a Problem</a>
<a href="https://status.furaffinity.net/">Site Status</a>
</div>
</div>
@@ -254,18 +314,98 @@
<li class="no-sub">
<span class="top-heading"><div class="inline hideonmobile hideontablet">
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
</div>
<div class="inline hideondesktop">
<a href="/login">Log In</a><br>
<a href="/register">Create an Account</a>
</div>
</span>
<li class="message-bar-desktop">
<a class="notification-container inline" href="/msg/submissions/" title="5,161 Submission Notifications">5161S</a>
<a class="notification-container inline" href="/msg/others/#journals" title="75 Journal Notifications">75J</a>
</li>
</ul>
<li>
<div class="floatleft hideonmobile">
<a href="/user/soxx-thefennec"><img class="loggedin_user_avatar menubar-icon-resize avatar" style="cursor:pointer" alt="SoXX-TheFennec" src="//a.furaffinity.net/1515442832/soxx-thefennec.gif"/></a>
</div>
</li>
<li class="submenu-trigger">
<div class="floatleft hideonmobile">
<svg class="avatar-submenu-trigger banner-svg" xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24"><path d="M4 6h16v2H4zm0 5h16v2H4zm0 5h16v2H4z"></path></svg>
</div>
<a id="my-username" class="top-heading hideondesktop" href="#"><span class="hideondesktop">My FA ( </span>SoXX-TheFennec<span class="hideondesktop"> )</span></a>
<div class="dropdown dropdown-right">
<div class="dd-inner">
<div class="column">
<h3>Account</h3>
<a href="/user/soxx-thefennec/">My Userpage</a>
<a href="/msg/pms/">Check My Notes</a>
<a href="/controls/journal/">Create a Journal</a>
<a href="/commissions/soxx-thefennec/">My Commission Info</a>
<h3>Support Fur Affinity</h3>
<a href="/plus/">Subscribe to FA+ </a>
<a href="https://shop.furaffinity.net/" target="_blank">Merch Store</a>
<h3>Trouble Tickets</h3>
<a href="/controls/troubletickets/">Report a Problem</a>
<div class="mobile-sfw-toggle">
<h3 class="padding-top:10px">Toggle SFW</h3>
<div class="sfw-toggle type-slider slider-button-wrapper" style="position:relative;top:5px">
<input type="checkbox" id="sfw-toggle-mobile" class="slider-toggle" />
<label class="slider-viewport" for="sfw-toggle-mobile" title="Quick toggle to show or hide Mature and Adult submissions">
<div class="slider">
<div class="slider-button">&nbsp;</div>
<div class="slider-content left"><span>SFW</span></div>
<div class="slider-content right"><span>NSFW</span></div>
</div>
</label>
</div>
</div>
<hr>
<form class="post-btn logout-link" method="post" action="/logout/"><button type="submit">Log Out</button><input type="hidden" name="key" value="0b178006ed9c0137089032690e59b1799185aef779fcc884630f2cc5a100bf57"/></form>
<script type="text/javascript">
_fajs.push(['init_logout_button', '.logout-link button']);
</script>
</div>
</div>
</div>
</li>
<li class="submenu-trigger">
<a class="top-heading" href="#"><svg class="banner-svg" xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" style="transform: ;msFilter:;"><path d="M12 16c2.206 0 4-1.794 4-4s-1.794-4-4-4-4 1.794-4 4 1.794 4 4 4zm0-6c1.084 0 2 .916 2 2s-.916 2-2 2-2-.916-2-2 .916-2 2-2z"></path><path d="m2.845 16.136 1 1.73c.531.917 1.809 1.261 2.73.73l.529-.306A8.1 8.1 0 0 0 9 19.402V20c0 1.103.897 2 2 2h2c1.103 0 2-.897 2-2v-.598a8.132 8.132 0 0 0 1.896-1.111l.529.306c.923.53 2.198.188 2.731-.731l.999-1.729a2.001 2.001 0 0 0-.731-2.732l-.505-.292a7.718 7.718 0 0 0 0-2.224l.505-.292a2.002 2.002 0 0 0 .731-2.732l-.999-1.729c-.531-.92-1.808-1.265-2.731-.732l-.529.306A8.1 8.1 0 0 0 15 4.598V4c0-1.103-.897-2-2-2h-2c-1.103 0-2 .897-2 2v.598a8.132 8.132 0 0 0-1.896 1.111l-.529-.306c-.924-.531-2.2-.187-2.731.732l-.999 1.729a2.001 2.001 0 0 0 .731 2.732l.505.292a7.683 7.683 0 0 0 0 2.223l-.505.292a2.003 2.003 0 0 0-.731 2.733zm3.326-2.758A5.703 5.703 0 0 1 6 12c0-.462.058-.926.17-1.378a.999.999 0 0 0-.47-1.108l-1.123-.65.998-1.729 1.145.662a.997.997 0 0 0 1.188-.142 6.071 6.071 0 0 1 2.384-1.399A1 1 0 0 0 11 5.3V4h2v1.3a1 1 0 0 0 .708.956 6.083 6.083 0 0 1 2.384 1.399.999.999 0 0 0 1.188.142l1.144-.661 1 1.729-1.124.649a1 1 0 0 0-.47 1.108c.112.452.17.916.17 1.378 0 .461-.058.925-.171 1.378a1 1 0 0 0 .471 1.108l1.123.649-.998 1.729-1.145-.661a.996.996 0 0 0-1.188.142 6.071 6.071 0 0 1-2.384 1.399A1 1 0 0 0 13 18.7l.002 1.3H11v-1.3a1 1 0 0 0-.708-.956 6.083 6.083 0 0 1-2.384-1.399.992.992 0 0 0-1.188-.141l-1.144.662-1-1.729 1.124-.651a1 1 0 0 0 .471-1.108z"></path></svg></a>
<div class="dropdown dropdown-right">
<div class="dd-inner">
<div class="column">
<h3>Account Information</h3>
<a href="/controls/settings/">Account Settings</a>
<a href="/controls/site-settings/">Global Site Settings</a>
<a href="/controls/user-settings/">User Settings</a>
<h3>Customize User Profile</h3>
<a href="/controls/profile/">Profile Info</a>
<a href="/controls/profilebanner/">Profile Banner</a>
<a href="/controls/contacts/">Contacts & Social Media</a>
<a href="/controls/avatar/">Avatar Management</a>
<h3>Manage My Content</h3>
<a href="/controls/submissions/">Submissions</a>
<a href="/controls/folders/submissions/">Folders</a>
<a href="/controls/journal/">Journals</a>
<a href="/controls/favorites/">Favorites</a>
<a href="/controls/buddylist/">Watches</a>
<a href="/controls/shouts/">Shouts</a>
<a href="/controls/badges/">Badges</a>
<a href="/controls/user-icons/">User Icons</a>
<h3>Security</h3>
<a href="/controls/sessions/logins/">Active Sessions</a>
<a href="/controls/sessions/logs/">Activity Log</a>
<a href="/controls/sessions/labels/">Browser Labels</a>
</div>
</div>
</div>
</li>
</ul>
<script type="text/javascript">
_fajs.push(['init_sfw_button', '.sfw-toggle']);
</script>
@@ -291,25 +431,26 @@
</script>
<div class="news-block">
</div>
<div id="news" class="newsBlock" data-date="1779756930">
<strong>News:</strong><span class="hideondesktop hideontablet"><br></span> <a class="journal-news-link" href="/journal/11365688">VGen Challenge 6 Day Reminder + Swag Pickup (<span class="c-contentRating--general" alt="General rating" title="General rating">G</span>)</a>
<span class="jsClose newsBlock__closeBtn" title="Close"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="transform: ;msFilter:;" title="Dismiss" ><path d="M20 3H4c-1.103 0-2 .897-2 2v14c0 1.103.897 2 2 2h16c1.103 0 2-.897 2-2V5c0-1.103-.897-2-2-2zM4 19V7h16l.001 12H4z"></path><path d="m15.707 10.707-1.414-1.414L12 11.586 9.707 9.293l-1.414 1.414L10.586 13l-2.293 2.293 1.414 1.414L12 14.414l2.293 2.293 1.414-1.414L13.414 13z"></path></svg></span>
</div>
<script type="text/javascript">
_fajs.push(['init_news_block', 'news']);
</script>
</div>
<div id="main-window" class="footer-mobile-tweak g-wrapper">
<div id="header">
<!-- site banner -->
<site-banner >
<map name="banner-map">
<area
shape="rect"
coords="441,144,1042,197"
href="https://link.vgen.co/furaffinity"
onclick="javascript: return(confirm(`You've clicked on a usemap link that will redirect you to\n\nhttps://link.vgen.co/furaffinity\n\nAre you fine with being redirected?`))"
target="_blank" />
</map>
<a href="/">
<picture>
<source srcset="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.webp" type="image/webp">
<img usemap="#banner-map" src="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.jpg">
<source srcset="//d.furaffinity.net/media/banners/modern/fa-banner-spring-furality-20260531.webp" type="image/webp">
<img usemap="#banner-map" src="//d.furaffinity.net/media/banners/modern/fa-banner-spring-furality-20260531.jpg">
</picture>
</a>
</site-banner>
@@ -320,23 +461,174 @@
<div id="site-content">
<!-- /header -->
<!-- {redirect} -->
<div id="standardpage">
<section class="aligncenter notice-message user-submitted-links">
<div class="section-body alignleft">
<h2>System Message</h2>
<div id="message">
<section>
<div class="section-header">
<div class="message-center-note-information">
<div class="message-center-note-information avatar">
<a href="/user/vampexx/"><img class="avatar" src="//a.furaffinity.net/1741507375/vampexx.gif"/></a>
</div>
<div class="message-center-note-information addresses">
<h2>RE: TF YCH Bidding</h2>
Sent by
<div class="c-usernameBlock">
<a class="c-usernameBlock__displayName js-displayName-block" href="/user/vampexx/">
<span class="js-displayName">Vampexx</span>
</a>
<a class="c-usernameBlock__userName js-userName-block" href="/user/vampexx/">
<span><span class="c-usernameBlock__symbol" title="Member" alt="Member">~</span>vampexx</span>
</a>
</div>
&nbsp; <span class="popup_date" data-title-date="1" data-24-hour="0" data-time="1654111463" title="June 1, 2022 08:24:23 PM">4 years ago</span>
<br>
<div class="redirect-message">Please log in!</div>
To
<div class="c-usernameBlock">
<a class="c-usernameBlock__displayName js-displayName-block" href="/user/soxx-thefennec/">
<span class="js-displayName">SoXX-TheFennec</span>
</a>
<a class="c-usernameBlock__userName js-userName-block" href="/user/soxx-thefennec/">
<span><span class="c-usernameBlock__symbol" title="Member" alt="Member">~</span>soxx-thefennec</span>
</a>
</div> </div>
</div>
</div>
<div class="section-body">
<div class="user-submitted-links">
<div class="proceed-btn-container">
<a class="button standard go" href="/login/">Continue &raquo;</a>
<div class="noteWarningMessage noteWarningMessage--scam user-submitted-links">
<div class="noteWarningMessage__icon">
<img src="/themes/beta/img/icons/Error_l.png">
</div>
<div>
<h4>Do you know this person?</h4>
Verify the username and profile before doing business with them! Scammers often attempt to impersonate well-known artists.
<br />
If you encounter something suspicious, please report it using a <a href="/controls/troubletickets/">Trouble Ticket</a>.
<br />
Also, review our <a href="/fight_spam" target="_blank">Internet Safety and Scamming</a> page to keep yourself informed and safe while using the web!
</div>
</div>
actually, ill put you as 65 but say its Anon, then if they respond you can respond back<br />
—————————<br />
original post by <a href="/user/soxx-thefennec" class="linkusername"><span class="c-usernameBlockSimple username-underlined"><span class="c-usernameBlockSimple__displayName" title="soxx-thefennec">SoXX-TheFennec</span></span></a>:<br />
<br />
oh wow how tf did I miss read that yeah then 65 XD<br />
<br />
—————————<br />
original post by <a href="/user/vampexx" class="linkusername"><span class="c-usernameBlockSimple username-underlined"><span class="c-usernameBlockSimple__displayName" title="vampexx">Vampexx</span></span></a>:<br />
<br />
Hey, wanted to let you know that you biddedon the min bid, not the starting bid, SB is 60 X3 </div>
<div class="section-options">
<div class="inline note-view toggle">
<form id="note-actions" action="/msg/pms/" method="post">
<input type="hidden" name="manage_notes" value="1" />
<input type="hidden" name="items[]" value="131012623" />
<button class="button standard priority_high" type="submit" name="set_prio" value="high">High</button>
<button class="button standard priority_medium" type="submit" name="set_prio" value="medium">Medium</button>
<button class="button standard priority_low" type="submit" name="set_prio" value="low">Low</button>
<button class="button standard priority_none" type="submit" name="set_prio" value="none">None</button>
<button class="button standard move_archive" type="submit" name="move_to" value="archive">Archive</button>
</form>
</div>
</div>
</div>
</section>
</div>
<form method="post" name="note-form" id="note-form" action="/msg/send/">
<input type="hidden" name="key" value="b52ba5d0d5f0071bc47df3bb0645c5926c15e245a17c76f6b108a3915fe378c9" />
<section>
<div class="section-body">
<h2>Reply</h2>
<div>
<span class="noteresponse inline">Recipient:</span>
<input type="text" name="to" value="vampexx" maxLength="150" class="textbox"/>
</div>
<div class="p5t">
<span class="noteresponse inline">Subject:</span>
<input type="text" name="subject" value="RE: RE: TF YCH Bidding" maxLength="150" class="textbox "/>
</div>
<div class="p5t">
<span class="noteresponsespacer" ><i class="bbcodeformat b hand" title="Bold (CTRL+B)" onclick="performInsert(this, '[b]', '[/b]');"></i>
<i class="bbcodeformat i hand" title="Italic (CTRL+I)" onclick="performInsert(this, '[i]', '[/i]');"></i>
<i class="bbcodeformat u hand" title="Underlined (CTRL+U)" onclick="performInsert(this, '[u]', '[/u]');"></i>
&nbsp;&nbsp;&nbsp;
<i class="bbcodeformat align_left hand" title="Align Left" onclick="performInsert(this, '[left]', '[/left]');"></i>
<i class="bbcodeformat align_center hand" title="Align Center" onclick="performInsert(this, '[center]', '[/center]');"></i>
<i class="bbcodeformat align_right hand" title="Align Right" onclick="performInsert(this, '[right]', '[/right]');"></i>
</span>
</div>
<div style="display:table; width:100%; margin-bottom:8px;">
<div class="user-submitted-links" style="display:table-cell;width:auto">
<small style="display: block; margin-bottom: 6px;">Please write your reply at the top of the reply box.</small>
<textarea id="JSMessage_view" name="message" rows="17" class="textarea">
&mdash;&mdash;&mdash;&mdash;&mdash;&mdash;&mdash;&mdash;&mdash;
original post by Vampexx (@vampexx):
actually, ill put you as 65 but say its Anon, then if they respond you can respond back
&mdash;&mdash;&mdash;&mdash;&mdash;&mdash;&mdash;&mdash;&mdash;
original post by @SoXX-TheFennec:
oh wow how tf did I miss read that yeah then 65 XD
</textarea>
<p style="text-align: left; font-size: 0.8em; margin-top: 2px;">Help with <a href="/help/#tags-and-codes">Tags & Codes</a> formatting.</p>
</div>
</div>
<div class="section-options">
<input class="button standard" type="submit" value="Reply"/>
</div>
</div>
</section>
</form>
<script type="text/javascript">
_fajs.push(['init_bbcode_hotkeys', 'JSMessage_view']);
_fajs.push(function(){
// disable submit button on form submit
$('note-form').observe('submit', function(evt) {
// disable the button to prevent multiple clicks and thus multiple requests
var btn = $('note-form').down('input[type="submit"]');
btn.value = 'Sending...';
btn.disabled = true;
btn.addClassName('disabled');
window.setTimeout(function(){
btn.value = 'Reply';
btn.disabled = false;
btn.removeClassName('disabled');
}, 3000);
});
// prevent possible cached button state on page reload
var btn = $('note-form').down('input[type="submit"]');
btn.value = 'Reply';
btn.disabled = false;
});
</script>
</div>
<!-- /<div id="site-content"> -->
@@ -371,11 +663,11 @@
</div>
<div class="online-stats">
88574 <strong><span title="Measured in the last 900 seconds">Users online</span></strong> &mdash;
3334 <strong>guests</strong>,
8900 <strong>registered</strong>
and 76340 <strong>other</strong>
<!-- Online Counter Last Update: Sun, 24 May 2026 04:31:01 -0700 -->
91668 <strong><span title="Measured in the last 900 seconds">Users online</span></strong> &mdash;
4823 <strong>guests</strong>,
14001 <strong>registered</strong>
and 72844 <strong>other</strong>
<!-- Online Counter Last Update: Tue, 02 Jun 2026 12:27:00 -0700 -->
</div>
<small>Limit bot activity to periods with less than 10k registered users online.</small>
@@ -383,8 +675,8 @@
<strong>&copy; 2005-2026 Frost Dragon Art LLC</strong>
<div class="footnote">
Server Time: May 24, 2026 04:31 AM<br />
Page generated in 0.009 seconds<br />[ 26.5% PHP, 73.5% SQL ] (9 queries)<br />
Server Time: Jun 2, 2026 12:27 PM<br />
Page generated in 0.018 seconds<br />[ 42.4% PHP, 57.6% SQL ] (24 queries)<br />
</div>
</div>
</div>
@@ -415,7 +707,7 @@
<script src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js" crossorigin="anonymous"></script>
<script type="text/javascript">
var server_timestamp = 1779622312;
var server_timestamp = 1780428442;
var client_timestamp = Date.now() / 1000;
var server_timestamp_delta = server_timestamp - client_timestamp;
var sfw_cookie_name = 'sfw';
@@ -424,7 +716,7 @@
//
document.addEventListener("DOMContentLoaded", (event) => {
//
const ad_manager = new adManager({"sizeConfig":[{"labels":["desktopWide"],"mediaQuery":"(min-width: 1090px)","sizesSupported":[[728,90],[300,250],[300,168],[300,600],[160,600]]},{"labels":["desktopNarrow"],"mediaQuery":"(min-width: 740px) and (max-width: 1089px)","sizesSupported":[[728,90],[300,250],[300,168]]},{"labels":["mobile"],"mediaQuery":"(min-width: 0px) and (max-width: 739px)","sizesSupported":[[320,50],[300,50],[320,100]]}],"slotConfig":{"header_middle":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"above_content":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"sidebar":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]},"sidebar_tall":{"containerSize":{"desktopWide":[300,600],"desktopNarrow":[300,600],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_left":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right_top":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"footer_right_bottom":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"header_right_left":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"header_right_right":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_top":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_bottom":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"front_page":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"c-videoAd":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]}},"providerConfig":{"inhouse":{"domain":"https:\/\/rv.furaffinity.net","dataPath":"\/live\/www\/delivery\/spc.php","dataVariableName":"OA_output"}},"adConfig":{"inhouse":{"header_middle":{"default":{"tagId":40,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":56,"tagSize":[320,50]}}},"above_content":{"default":{"tagId":25,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":53,"tagSize":[320,50]}}},"sidebar":{"default":{"tagId":49,"tagSize":[300,250]}},"sidebar_tall":{"default":{"tagId":49,"tagSize":[300,250]}},"footer_left":{"default":{"tagId":28,"tagSize":[300,250]}},"footer_right":{"default":{"tagId":74,"tagSize":[300,250]}},"footer_right_top":{"default":{"tagId":62,"tagSize":[320,50]}},"footer_right_bottom":{"default":{"tagId":59,"tagSize":[320,50]}},"header_right_left":{"default":{"tagId":65,"tagSize":[320,50]}},"header_right_right":{"default":{"tagId":68,"tagSize":[320,50]}},"sidebar_top":{"default":{"tagId":65,"tagSize":[320,50]}},"sidebar_bottom":{"default":{"tagId":68,"tagSize":[320,50]}},"front_page":{"default":{"tagId":77,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":78,"tagSize":[320,50]}}},"c-videoAd":{"default":{"tagId":71,"tagSize":[320,50]}}}},"extraMetadata":{"adsenseClient":"ca-pub-3495616356562362","forceLoadConfigs":["c-videoAd"]}}, true);
const ad_manager = new adManager({"sizeConfig":[{"labels":["desktopWide"],"mediaQuery":"(min-width: 1090px)","sizesSupported":[[728,90],[300,250],[300,168],[300,600],[160,600]]},{"labels":["desktopNarrow"],"mediaQuery":"(min-width: 740px) and (max-width: 1089px)","sizesSupported":[[728,90],[300,250],[300,168]]},{"labels":["mobile"],"mediaQuery":"(min-width: 0px) and (max-width: 739px)","sizesSupported":[[320,50],[300,50],[320,100]]}],"slotConfig":{"header_middle":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"above_content":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"sidebar":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]},"sidebar_tall":{"containerSize":{"desktopWide":[300,600],"desktopNarrow":[300,600],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_left":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right_top":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"footer_right_bottom":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"header_right_left":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"header_right_right":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_top":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_bottom":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"front_page":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"c-videoAd":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]}},"providerConfig":{"inhouse":{"domain":"https:\/\/rv.furaffinity.net","dataPath":"\/live\/www\/delivery\/spc.php","dataVariableName":"OA_output"}},"adConfig":{"inhouse":{"header_middle":{"default":{"tagId":42,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":58,"tagSize":[320,50]}}},"above_content":{"default":{"tagId":27,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":55,"tagSize":[320,50]}}},"sidebar":{"default":{"tagId":51,"tagSize":[300,250]}},"sidebar_tall":{"default":{"tagId":51,"tagSize":[300,250]}},"footer_left":{"default":{"tagId":30,"tagSize":[300,250]}},"footer_right":{"default":{"tagId":72,"tagSize":[300,250]}},"footer_right_top":{"default":{"tagId":64,"tagSize":[320,50]}},"footer_right_bottom":{"default":{"tagId":61,"tagSize":[320,50]}},"header_right_left":{"default":{"tagId":67,"tagSize":[320,50]}},"header_right_right":{"default":{"tagId":70,"tagSize":[320,50]}},"sidebar_top":{"default":{"tagId":67,"tagSize":[320,50]}},"sidebar_bottom":{"default":{"tagId":70,"tagSize":[320,50]}},"front_page":{"default":{"tagId":75,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":80,"tagSize":[320,50]}}},"c-videoAd":{"default":{"tagId":71,"tagSize":[320,50]}}}},"extraMetadata":{"adsenseClient":"ca-pub-3495616356562362","forceLoadConfigs":["c-videoAd"]}}, true);
});
</script>

View File

@@ -84,6 +84,11 @@
<!-- EU request: yes -->
<body class="c-bodyColor"
id="pageid-gallery" data-static-path="/themes/beta"
data-user-blocklist=""
data-user-logged-in="1"
data-tag-blocklist="music"
data-tag-blocklist-hide-tagless="0"
data-tag-blocklist-nonce="73dd0bd0afb4415ee108c6230a8ff451a7d4865518c65a019550211e4f30a92c"
>
<script type="text/javascript">
@@ -120,6 +125,11 @@
<div class="mobile-nav-content-container">
<div class="aligncenter">
<a href="/user/soxx-thefennec/"><img class="loggedin_user_avatar avatar" alt="SoXX-TheFennec" src="//a.furaffinity.net/1515442832/soxx-thefennec.gif"/></a>
<h2 style="margin-bottom:0"><a href="/user/soxx-thefennec/">SoXX-TheFennec</a></h2>
<a href="/user/soxx-thefennec/">Userpage</a> |
<a href="/msg/pms/">Notes</a> |
<a href="/controls/journal/">Journals</a> |
<a href="/plus/"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"> FA+</a> |
<a href="https://shop.furaffinity.net" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"> Shop</a>
<br />
@@ -127,6 +137,7 @@
<hr>
<h2><a href="/browse/">Browse</a></h2>
<h2><a href="/search/">Search</a></h2>
<h2><a href="/submit/">Upload</a></h2>
<div class="nav-ac-container">
<label for="mobile-menu-submenu-0"><h2 style="margin-top:0;padding-top:0">Support &#x25BC;</h2></label>
@@ -158,22 +169,65 @@
<h3>SUPPORT</h3>
<a href="/help/#contact">Contact Us</a><br />
<a href="/controls/troubletickets/">REPORT A PROBLEM</a><br />
<a href="https://status.furaffinity.net/">Site Status</a>
</article>
</div>
<div class="mobile-sfw-toggle">
<h2>SFW Mode</h2>
<div class="sfw-toggle type-slider slider-button-wrapper">
<input type="checkbox" id="sfw-toggle-mobile" class="slider-toggle" />
<label class="slider-viewport" for="sfw-toggle-mobile" title="Quick toggle to show or hide Mature and Adult submissions">
<div class="slider">
<div class="slider-button">&nbsp;</div>
<div class="slider-content left"><span>SFW</span></div>
<div class="slider-content right"><span>NSFW</span></div>
</div>
</label>
</div>
</div>
<div class="nav-ac-container">
<label for="mobile-menu-submenu-1"><h2 style="margin-top:0;padding-top:0">Settings &#x25BC;</h2></label>
<input id="mobile-menu-submenu-1" name="accordion-1" type="checkbox" />
<article class="nav-ac-content nav-ac-content-dropdown">
<h3>ACCOUNT INFORMATION</h3>
<a href="/controls/settings/">Account Settings</a><br>
<a href="/controls/site-settings/">Global Site Settings</a><br>
<a href="/controls/user-settings/">User Settings</a>
<h3>CUSTOMIZE USER PROFILE</h3>
<a href="/controls/profile/">Profile Info</a><br>
<a href="/controls/profilebanner/">Profile Banner</a><br>
<a href="/controls/contacts/">Contacts and Social Media</a><br>
<a href="/controls/avatar/">Avatar Management</a>
<h3>MANAGE MY CONTENT</h3>
<a href="/controls/submissions/">Submissions</a><br>
<a href="/controls/folders/submissions/">Folders</a><br>
<a href="/controls/journal/">Journals</a><br>
<a href="/controls/favorites/">Favorites</a><br>
<a href="/controls/buddylist/">Watches</a><br>
<a href="/controls/shouts/">Shouts</a><br>
<a href="/controls/badges/">Badges</a><br>
<a href="/controls/user-icons/">User Icons</a>
<h3>SECURITY</h3>
<a href="/controls/sessions/logins/">Active Sessions</a><br>
<a href="/controls/sessions/logs/">Activity Log</a><br>
<a href="/controls/sessions/labels/">Browser Labels</a>
</article>
</div>
<hr>
<hr>
<h2><div class="inline hideonmobile hideontablet">
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
</div>
<div class="inline hideondesktop">
<a href="/login">Log In</a><br>
<a href="/register">Create an Account</a>
</div>
<h2><form class="post-btn logout-link" method="post" action="/logout/"><button type="submit">Log Out</button><input type="hidden" name="key" value="deedfd5c0c9487a7aada91b469657f08debbabf69b0aab22cb3c20f5cc50a2ab"/></form>
<script type="text/javascript">
_fajs.push(['init_logout_button', '.logout-link button']);
</script>
</h2>
@@ -185,6 +239,11 @@
</div>
<div class="mobile-notification-bar">
<a class="notification-container inline" href="/msg/submissions/" title="5,161 Submission Notifications">5161S</a>
<a class="notification-container inline" href="/msg/others/#journals" title="75 Journal Notifications">75J</a>
</div>
@@ -245,6 +304,7 @@
<h3>Support</h3>
<a href="/help/#contact">Contact Us</a>
<a href="/controls/troubletickets/">Report a Problem</a>
<a href="https://status.furaffinity.net/">Site Status</a>
</div>
</div>
@@ -270,18 +330,98 @@
<li class="no-sub">
<span class="top-heading"><div class="inline hideonmobile hideontablet">
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
</div>
<div class="inline hideondesktop">
<a href="/login">Log In</a><br>
<a href="/register">Create an Account</a>
</div>
</span>
<li class="message-bar-desktop">
<a class="notification-container inline" href="/msg/submissions/" title="5,161 Submission Notifications">5161S</a>
<a class="notification-container inline" href="/msg/others/#journals" title="75 Journal Notifications">75J</a>
</li>
</ul>
<li>
<div class="floatleft hideonmobile">
<a href="/user/soxx-thefennec"><img class="loggedin_user_avatar menubar-icon-resize avatar" style="cursor:pointer" alt="SoXX-TheFennec" src="//a.furaffinity.net/1515442832/soxx-thefennec.gif"/></a>
</div>
</li>
<li class="submenu-trigger">
<div class="floatleft hideonmobile">
<svg class="avatar-submenu-trigger banner-svg" xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24"><path d="M4 6h16v2H4zm0 5h16v2H4zm0 5h16v2H4z"></path></svg>
</div>
<a id="my-username" class="top-heading hideondesktop" href="#"><span class="hideondesktop">My FA ( </span>SoXX-TheFennec<span class="hideondesktop"> )</span></a>
<div class="dropdown dropdown-right">
<div class="dd-inner">
<div class="column">
<h3>Account</h3>
<a href="/user/soxx-thefennec/">My Userpage</a>
<a href="/msg/pms/">Check My Notes</a>
<a href="/controls/journal/">Create a Journal</a>
<a href="/commissions/soxx-thefennec/">My Commission Info</a>
<h3>Support Fur Affinity</h3>
<a href="/plus/">Subscribe to FA+ </a>
<a href="https://shop.furaffinity.net/" target="_blank">Merch Store</a>
<h3>Trouble Tickets</h3>
<a href="/controls/troubletickets/">Report a Problem</a>
<div class="mobile-sfw-toggle">
<h3 class="padding-top:10px">Toggle SFW</h3>
<div class="sfw-toggle type-slider slider-button-wrapper" style="position:relative;top:5px">
<input type="checkbox" id="sfw-toggle-mobile" class="slider-toggle" />
<label class="slider-viewport" for="sfw-toggle-mobile" title="Quick toggle to show or hide Mature and Adult submissions">
<div class="slider">
<div class="slider-button">&nbsp;</div>
<div class="slider-content left"><span>SFW</span></div>
<div class="slider-content right"><span>NSFW</span></div>
</div>
</label>
</div>
</div>
<hr>
<form class="post-btn logout-link" method="post" action="/logout/"><button type="submit">Log Out</button><input type="hidden" name="key" value="deedfd5c0c9487a7aada91b469657f08debbabf69b0aab22cb3c20f5cc50a2ab"/></form>
<script type="text/javascript">
_fajs.push(['init_logout_button', '.logout-link button']);
</script>
</div>
</div>
</div>
</li>
<li class="submenu-trigger">
<a class="top-heading" href="#"><svg class="banner-svg" xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" style="transform: ;msFilter:;"><path d="M12 16c2.206 0 4-1.794 4-4s-1.794-4-4-4-4 1.794-4 4 1.794 4 4 4zm0-6c1.084 0 2 .916 2 2s-.916 2-2 2-2-.916-2-2 .916-2 2-2z"></path><path d="m2.845 16.136 1 1.73c.531.917 1.809 1.261 2.73.73l.529-.306A8.1 8.1 0 0 0 9 19.402V20c0 1.103.897 2 2 2h2c1.103 0 2-.897 2-2v-.598a8.132 8.132 0 0 0 1.896-1.111l.529.306c.923.53 2.198.188 2.731-.731l.999-1.729a2.001 2.001 0 0 0-.731-2.732l-.505-.292a7.718 7.718 0 0 0 0-2.224l.505-.292a2.002 2.002 0 0 0 .731-2.732l-.999-1.729c-.531-.92-1.808-1.265-2.731-.732l-.529.306A8.1 8.1 0 0 0 15 4.598V4c0-1.103-.897-2-2-2h-2c-1.103 0-2 .897-2 2v.598a8.132 8.132 0 0 0-1.896 1.111l-.529-.306c-.924-.531-2.2-.187-2.731.732l-.999 1.729a2.001 2.001 0 0 0 .731 2.732l.505.292a7.683 7.683 0 0 0 0 2.223l-.505.292a2.003 2.003 0 0 0-.731 2.733zm3.326-2.758A5.703 5.703 0 0 1 6 12c0-.462.058-.926.17-1.378a.999.999 0 0 0-.47-1.108l-1.123-.65.998-1.729 1.145.662a.997.997 0 0 0 1.188-.142 6.071 6.071 0 0 1 2.384-1.399A1 1 0 0 0 11 5.3V4h2v1.3a1 1 0 0 0 .708.956 6.083 6.083 0 0 1 2.384 1.399.999.999 0 0 0 1.188.142l1.144-.661 1 1.729-1.124.649a1 1 0 0 0-.47 1.108c.112.452.17.916.17 1.378 0 .461-.058.925-.171 1.378a1 1 0 0 0 .471 1.108l1.123.649-.998 1.729-1.145-.661a.996.996 0 0 0-1.188.142 6.071 6.071 0 0 1-2.384 1.399A1 1 0 0 0 13 18.7l.002 1.3H11v-1.3a1 1 0 0 0-.708-.956 6.083 6.083 0 0 1-2.384-1.399.992.992 0 0 0-1.188-.141l-1.144.662-1-1.729 1.124-.651a1 1 0 0 0 .471-1.108z"></path></svg></a>
<div class="dropdown dropdown-right">
<div class="dd-inner">
<div class="column">
<h3>Account Information</h3>
<a href="/controls/settings/">Account Settings</a>
<a href="/controls/site-settings/">Global Site Settings</a>
<a href="/controls/user-settings/">User Settings</a>
<h3>Customize User Profile</h3>
<a href="/controls/profile/">Profile Info</a>
<a href="/controls/profilebanner/">Profile Banner</a>
<a href="/controls/contacts/">Contacts & Social Media</a>
<a href="/controls/avatar/">Avatar Management</a>
<h3>Manage My Content</h3>
<a href="/controls/submissions/">Submissions</a>
<a href="/controls/folders/submissions/">Folders</a>
<a href="/controls/journal/">Journals</a>
<a href="/controls/favorites/">Favorites</a>
<a href="/controls/buddylist/">Watches</a>
<a href="/controls/shouts/">Shouts</a>
<a href="/controls/badges/">Badges</a>
<a href="/controls/user-icons/">User Icons</a>
<h3>Security</h3>
<a href="/controls/sessions/logins/">Active Sessions</a>
<a href="/controls/sessions/logs/">Activity Log</a>
<a href="/controls/sessions/labels/">Browser Labels</a>
</div>
</div>
</div>
</li>
</ul>
<script type="text/javascript">
_fajs.push(['init_sfw_button', '.sfw-toggle']);
</script>
@@ -307,7 +447,16 @@
</script>
<div class="news-block">
</div>
<div id="news" class="newsBlock" data-date="1779756930">
<strong>News:</strong><span class="hideondesktop hideontablet"><br></span> <a class="journal-news-link" href="/journal/11365688">VGen Challenge 6 Day Reminder + Swag Pickup (<span class="c-contentRating--general" alt="General rating" title="General rating">G</span>)</a>
<span class="jsClose newsBlock__closeBtn" title="Close"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="transform: ;msFilter:;" title="Dismiss" ><path d="M20 3H4c-1.103 0-2 .897-2 2v14c0 1.103.897 2 2 2h16c1.103 0 2-.897 2-2V5c0-1.103-.897-2-2-2zM4 19V7h16l.001 12H4z"></path><path d="m15.707 10.707-1.414-1.414L12 11.586 9.707 9.293l-1.414 1.414L10.586 13l-2.293 2.293 1.414 1.414L12 14.414l2.293 2.293 1.414-1.414L13.414 13z"></path></svg></span>
</div>
<script type="text/javascript">
_fajs.push(['init_news_block', 'news']);
</script>
</div>
<div id="main-window" class="footer-mobile-tweak g-wrapper">
@@ -357,7 +506,7 @@
<div class="font-small">
<span class="user-title">
Fursuit Maker | <span class="hideonmobile">Registered:</span> <span class="popup_date" data-title-date="0" data-24-hour="0" data-time="1443468107" title="10 years ago" disabled>September 28, 2015 03:21:47 PM</span> </span>
Fursuit Maker | <span class="hideonmobile">Registered:</span> <span class="popup_date" data-title-date="0" data-24-hour="0" data-time="1443468107" title="10 years ago" disabled>September 28, 2015 08:21:47 PM</span> </span>
</div>
<userpage-nav-links>
@@ -377,7 +526,7 @@
<userpage-nav-interface-buttons>
<a class="button standard samewidth go" style="text-transform: capitalize;" id="watch-button" href="/watch/kazucreations/?key=">Watch</a>
<a class="button standard samewidth stop" style="text-transform: capitalize;" id="watch-button" href="/unwatch/kazucreations/?key=38c21dd8f8eb22a2497f68d1f7a901fc132a6e13c09d8004dec4627bcf7f36c2">Unwatch</a>
<a class="button standard" href="/newpm/kazucreations/"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="transform: ;msFilter:;"><path d="M20 4H4c-1.103 0-2 .897-2 2v12c0 1.103.897 2 2 2h16c1.103 0 2-.897 2-2V6c0-1.103-.897-2-2-2zm0 2v.511l-8 6.223-8-6.222V6h16zM4 18V9.044l7.386 5.745a.994.994 0 0 0 1.228 0L20 9.044 20.002 18H4z"></path></svg></a>
</userpage-nav-interface-buttons>
@@ -584,11 +733,11 @@
</div>
<div class="online-stats">
88574 <strong><span title="Measured in the last 900 seconds">Users online</span></strong> &mdash;
3334 <strong>guests</strong>,
8900 <strong>registered</strong>
and 76340 <strong>other</strong>
<!-- Online Counter Last Update: Sun, 24 May 2026 04:31:01 -0700 -->
91668 <strong><span title="Measured in the last 900 seconds">Users online</span></strong> &mdash;
4823 <strong>guests</strong>,
14001 <strong>registered</strong>
and 72844 <strong>other</strong>
<!-- Online Counter Last Update: Tue, 02 Jun 2026 12:27:00 -0700 -->
</div>
<small>Limit bot activity to periods with less than 10k registered users online.</small>
@@ -596,8 +745,8 @@
<strong>&copy; 2005-2026 Frost Dragon Art LLC</strong>
<div class="footnote">
Server Time: May 24, 2026 04:31 AM<br />
Page generated in 0.015 seconds<br />[ 38.7% PHP, 61.3% SQL ] (21 queries)<br />
Server Time: Jun 2, 2026 12:27 PM<br />
Page generated in 0.025 seconds<br />[ 24.9% PHP, 75.1% SQL ] (29 queries)<br />
</div>
</div>
</div>
@@ -628,7 +777,7 @@
<script src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js" crossorigin="anonymous"></script>
<script type="text/javascript">
var server_timestamp = 1779622302;
var server_timestamp = 1780428434;
var client_timestamp = Date.now() / 1000;
var server_timestamp_delta = server_timestamp - client_timestamp;
var sfw_cookie_name = 'sfw';
@@ -637,7 +786,7 @@
//
document.addEventListener("DOMContentLoaded", (event) => {
//
const ad_manager = new adManager({"sizeConfig":[{"labels":["desktopWide"],"mediaQuery":"(min-width: 1090px)","sizesSupported":[[728,90],[300,250],[300,168],[300,600],[160,600]]},{"labels":["desktopNarrow"],"mediaQuery":"(min-width: 740px) and (max-width: 1089px)","sizesSupported":[[728,90],[300,250],[300,168]]},{"labels":["mobile"],"mediaQuery":"(min-width: 0px) and (max-width: 739px)","sizesSupported":[[320,50],[300,50],[320,100]]}],"slotConfig":{"header_middle":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"above_content":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"sidebar":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]},"sidebar_tall":{"containerSize":{"desktopWide":[300,600],"desktopNarrow":[300,600],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_left":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right_top":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"footer_right_bottom":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"header_right_left":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"header_right_right":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_top":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_bottom":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"front_page":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"c-videoAd":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]}},"providerConfig":{"inhouse":{"domain":"https:\/\/rv.furaffinity.net","dataPath":"\/live\/www\/delivery\/spc.php","dataVariableName":"OA_output"}},"adConfig":{"inhouse":{"header_middle":{"default":{"tagId":40,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":56,"tagSize":[320,50]}}},"above_content":{"default":{"tagId":25,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":53,"tagSize":[320,50]}}},"sidebar":{"default":{"tagId":49,"tagSize":[300,250]}},"sidebar_tall":{"default":{"tagId":49,"tagSize":[300,250]}},"footer_left":{"default":{"tagId":28,"tagSize":[300,250]}},"footer_right":{"default":{"tagId":74,"tagSize":[300,250]}},"footer_right_top":{"default":{"tagId":62,"tagSize":[320,50]}},"footer_right_bottom":{"default":{"tagId":59,"tagSize":[320,50]}},"header_right_left":{"default":{"tagId":65,"tagSize":[320,50]}},"header_right_right":{"default":{"tagId":68,"tagSize":[320,50]}},"sidebar_top":{"default":{"tagId":65,"tagSize":[320,50]}},"sidebar_bottom":{"default":{"tagId":68,"tagSize":[320,50]}},"front_page":{"default":{"tagId":77,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":78,"tagSize":[320,50]}}},"c-videoAd":{"default":{"tagId":71,"tagSize":[320,50]}}}},"extraMetadata":{"adsenseClient":"ca-pub-3495616356562362","forceLoadConfigs":["c-videoAd"]}}, true);
const ad_manager = new adManager({"sizeConfig":[{"labels":["desktopWide"],"mediaQuery":"(min-width: 1090px)","sizesSupported":[[728,90],[300,250],[300,168],[300,600],[160,600]]},{"labels":["desktopNarrow"],"mediaQuery":"(min-width: 740px) and (max-width: 1089px)","sizesSupported":[[728,90],[300,250],[300,168]]},{"labels":["mobile"],"mediaQuery":"(min-width: 0px) and (max-width: 739px)","sizesSupported":[[320,50],[300,50],[320,100]]}],"slotConfig":{"header_middle":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"above_content":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"sidebar":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]},"sidebar_tall":{"containerSize":{"desktopWide":[300,600],"desktopNarrow":[300,600],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_left":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right_top":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"footer_right_bottom":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"header_right_left":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"header_right_right":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_top":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_bottom":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"front_page":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"c-videoAd":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]}},"providerConfig":{"inhouse":{"domain":"https:\/\/rv.furaffinity.net","dataPath":"\/live\/www\/delivery\/spc.php","dataVariableName":"OA_output"}},"adConfig":{"inhouse":{"header_middle":{"default":{"tagId":42,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":58,"tagSize":[320,50]}}},"above_content":{"default":{"tagId":27,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":55,"tagSize":[320,50]}}},"sidebar":{"default":{"tagId":51,"tagSize":[300,250]}},"sidebar_tall":{"default":{"tagId":51,"tagSize":[300,250]}},"footer_left":{"default":{"tagId":30,"tagSize":[300,250]}},"footer_right":{"default":{"tagId":72,"tagSize":[300,250]}},"footer_right_top":{"default":{"tagId":64,"tagSize":[320,50]}},"footer_right_bottom":{"default":{"tagId":61,"tagSize":[320,50]}},"header_right_left":{"default":{"tagId":67,"tagSize":[320,50]}},"header_right_right":{"default":{"tagId":70,"tagSize":[320,50]}},"sidebar_top":{"default":{"tagId":67,"tagSize":[320,50]}},"sidebar_bottom":{"default":{"tagId":70,"tagSize":[320,50]}},"front_page":{"default":{"tagId":75,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":80,"tagSize":[320,50]}}},"c-videoAd":{"default":{"tagId":71,"tagSize":[320,50]}}}},"extraMetadata":{"adsenseClient":"ca-pub-3495616356562362","forceLoadConfigs":["c-videoAd"]}}, true);
});
</script>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1784
testdata/html/user.html vendored

File diff suppressed because one or more lines are too long

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
}

View File

@@ -148,8 +148,8 @@ func TestParseUser_RealFixture(t *testing.T) {
if u.Stats.Favorites != 180 {
t.Errorf("Stats.Favorites = %d; want 180", u.Stats.Favorites)
}
if u.Stats.Views != 1176 {
t.Errorf("Stats.Views = %d; want 1176", u.Stats.Views)
if u.Stats.Views != 1184 {
t.Errorf("Stats.Views = %d; want 1184", u.Stats.Views)
}
if u.Stats.Comments != 85 {
t.Errorf("Stats.Comments = %d; want 85", u.Stats.Comments)