11 Commits
v0.0.2 ... main

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
32 changed files with 19779 additions and 2482 deletions

View File

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

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

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

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,28 +13,137 @@ 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, reqOpts ...Option) iter.Seq2[*Submission, error] {
return c.listGallerySection(ctx, name, urls.Gallery, opts, reqOpts)
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, reqOpts ...Option) iter.Seq2[*Submission, error] {
return c.listGallerySection(ctx, name, urls.Scraps, opts, reqOpts)
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).
//
// 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 c.listGallerySection(ctx, name, urls.Favorites, opts, reqOpts)
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,
@@ -47,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
}, reqOpts...)
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

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

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

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

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

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

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

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