Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95193fb66d | |||
| 83487e531a | |||
| 8f4767966a | |||
| a2fc1b7e32 | |||
| bc2d27f702 | |||
| 2d6e73a800 | |||
| 79e8a35732 | |||
| 5cb196940d | |||
| 25800bc753 | |||
| 20fcad7fbb | |||
| 02479212bc |
0
.gitingore → .gitignore
vendored
0
.gitingore → .gitignore
vendored
268
SDK_ISSUES.md
268
SDK_ISSUES.md
@@ -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).
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
90
examples/categorized_tags/main.go
Normal file
90
examples/categorized_tags/main.go
Normal 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
|
||||
}
|
||||
79
examples/favorites_page/main.go
Normal file
79
examples/favorites_page/main.go
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
141
gallery.go
141
gallery.go
@@ -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
198
gallery_page_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
43
scripts/refresh-user-fixture.sh
Executable 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 ./...
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
1295
testdata/html/browse.html
vendored
1295
testdata/html/browse.html
vendored
File diff suppressed because one or more lines are too long
3241
testdata/html/comments_submission.html
vendored
3241
testdata/html/comments_submission.html
vendored
File diff suppressed because one or more lines are too long
1810
testdata/html/favorites_page1.html
vendored
1810
testdata/html/favorites_page1.html
vendored
File diff suppressed because one or more lines are too long
704
testdata/html/gallery_page1.html
vendored
704
testdata/html/gallery_page1.html
vendored
File diff suppressed because one or more lines are too long
1199
testdata/html/gallery_page_last.html
vendored
1199
testdata/html/gallery_page_last.html
vendored
File diff suppressed because one or more lines are too long
319
testdata/html/journals_listing_page1.html
vendored
319
testdata/html/journals_listing_page1.html
vendored
File diff suppressed because one or more lines are too long
1615
testdata/html/msg_others.html
vendored
1615
testdata/html/msg_others.html
vendored
File diff suppressed because it is too large
Load Diff
1766
testdata/html/msg_pms.html
vendored
1766
testdata/html/msg_pms.html
vendored
File diff suppressed because it is too large
Load Diff
2249
testdata/html/msg_submissions.html
vendored
2249
testdata/html/msg_submissions.html
vendored
File diff suppressed because one or more lines are too long
390
testdata/html/note_view.html
vendored
390
testdata/html/note_view.html
vendored
@@ -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 ▼</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"> </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 ▼</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"> </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>
|
||||
<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 »</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>
|
||||
|
||||
<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">
|
||||
|
||||
|
||||
—————————
|
||||
original post by Vampexx (@vampexx):
|
||||
|
||||
actually, ill put you as 65 but say its Anon, then if they respond you can respond back
|
||||
—————————
|
||||
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> —
|
||||
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> —
|
||||
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>© 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>
|
||||
|
||||
213
testdata/html/scraps_page1.html
vendored
213
testdata/html/scraps_page1.html
vendored
@@ -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 ▼</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"> </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 ▼</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"> </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> —
|
||||
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> —
|
||||
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>© 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>
|
||||
|
||||
1245
testdata/html/search_results.html
vendored
1245
testdata/html/search_results.html
vendored
File diff suppressed because one or more lines are too long
3241
testdata/html/submission.html
vendored
3241
testdata/html/submission.html
vendored
File diff suppressed because one or more lines are too long
1784
testdata/html/user.html
vendored
1784
testdata/html/user.html
vendored
File diff suppressed because one or more lines are too long
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user