Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95193fb66d | |||
| 83487e531a | |||
| 8f4767966a | |||
| a2fc1b7e32 | |||
| bc2d27f702 | |||
| 2d6e73a800 | |||
| 79e8a35732 | |||
| 5cb196940d | |||
| 25800bc753 | |||
| 20fcad7fbb | |||
| 02479212bc | |||
| 0ae20aa68d |
189
.gitignore
vendored
Normal file
189
.gitignore
vendored
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# Created by https://www.toptal.com/developers/gitignore/api/go,goland+all,linux,windows,macos
|
||||||
|
# Edit at https://www.toptal.com/developers/gitignore?templates=go,goland+all,linux,windows,macos
|
||||||
|
|
||||||
|
### Go ###
|
||||||
|
# If you prefer the allow list template instead of the deny list, see community template:
|
||||||
|
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||||
|
#
|
||||||
|
# Binaries for programs and plugins
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directories (remove the comment below to include it)
|
||||||
|
# vendor/
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
|
||||||
|
### GoLand+all ###
|
||||||
|
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||||
|
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||||
|
|
||||||
|
# User-specific stuff
|
||||||
|
.idea/**/workspace.xml
|
||||||
|
.idea/**/tasks.xml
|
||||||
|
.idea/**/usage.statistics.xml
|
||||||
|
.idea/**/dictionaries
|
||||||
|
.idea/**/shelf
|
||||||
|
|
||||||
|
# AWS User-specific
|
||||||
|
.idea/**/aws.xml
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
.idea/**/contentModel.xml
|
||||||
|
|
||||||
|
# Sensitive or high-churn files
|
||||||
|
.idea/**/dataSources/
|
||||||
|
.idea/**/dataSources.ids
|
||||||
|
.idea/**/dataSources.local.xml
|
||||||
|
.idea/**/sqlDataSources.xml
|
||||||
|
.idea/**/dynamic.xml
|
||||||
|
.idea/**/uiDesigner.xml
|
||||||
|
.idea/**/dbnavigator.xml
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
.idea/**/gradle.xml
|
||||||
|
.idea/**/libraries
|
||||||
|
|
||||||
|
# Gradle and Maven with auto-import
|
||||||
|
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||||
|
# since they will be recreated, and may cause churn. Uncomment if using
|
||||||
|
# auto-import.
|
||||||
|
# .idea/artifacts
|
||||||
|
# .idea/compiler.xml
|
||||||
|
# .idea/jarRepositories.xml
|
||||||
|
# .idea/modules.xml
|
||||||
|
# .idea/*.iml
|
||||||
|
# .idea/modules
|
||||||
|
# *.iml
|
||||||
|
# *.ipr
|
||||||
|
|
||||||
|
# CMake
|
||||||
|
cmake-build-*/
|
||||||
|
|
||||||
|
# Mongo Explorer plugin
|
||||||
|
.idea/**/mongoSettings.xml
|
||||||
|
|
||||||
|
# File-based project format
|
||||||
|
*.iws
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
out/
|
||||||
|
|
||||||
|
# mpeltonen/sbt-idea plugin
|
||||||
|
.idea_modules/
|
||||||
|
|
||||||
|
# JIRA plugin
|
||||||
|
atlassian-ide-plugin.xml
|
||||||
|
|
||||||
|
# Cursive Clojure plugin
|
||||||
|
.idea/replstate.xml
|
||||||
|
|
||||||
|
# SonarLint plugin
|
||||||
|
.idea/sonarlint/
|
||||||
|
|
||||||
|
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||||
|
com_crashlytics_export_strings.xml
|
||||||
|
crashlytics.properties
|
||||||
|
crashlytics-build.properties
|
||||||
|
fabric.properties
|
||||||
|
|
||||||
|
# Editor-based Rest Client
|
||||||
|
.idea/httpRequests
|
||||||
|
|
||||||
|
# Android studio 3.1+ serialized cache file
|
||||||
|
.idea/caches/build_file_checksums.ser
|
||||||
|
|
||||||
|
### GoLand+all Patch ###
|
||||||
|
# Ignore everything but code style settings and run configurations
|
||||||
|
# that are supposed to be shared within teams.
|
||||||
|
|
||||||
|
.idea/*
|
||||||
|
|
||||||
|
!.idea/codeStyles
|
||||||
|
!.idea/runConfigurations
|
||||||
|
|
||||||
|
### Linux ###
|
||||||
|
*~
|
||||||
|
|
||||||
|
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||||
|
.fuse_hidden*
|
||||||
|
|
||||||
|
# KDE directory preferences
|
||||||
|
.directory
|
||||||
|
|
||||||
|
# Linux trash folder which might appear on any partition or disk
|
||||||
|
.Trash-*
|
||||||
|
|
||||||
|
# .nfs files are created when an open file is removed but is still being accessed
|
||||||
|
.nfs*
|
||||||
|
|
||||||
|
### macOS ###
|
||||||
|
# General
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
|
||||||
|
# Icon must end with two \r
|
||||||
|
Icon
|
||||||
|
|
||||||
|
|
||||||
|
# Thumbnails
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Files that might appear in the root of a volume
|
||||||
|
.DocumentRevisions-V100
|
||||||
|
.fseventsd
|
||||||
|
.Spotlight-V100
|
||||||
|
.TemporaryItems
|
||||||
|
.Trashes
|
||||||
|
.VolumeIcon.icns
|
||||||
|
.com.apple.timemachine.donotpresent
|
||||||
|
|
||||||
|
# Directories potentially created on remote AFP share
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
Network Trash Folder
|
||||||
|
Temporary Items
|
||||||
|
.apdisk
|
||||||
|
|
||||||
|
### macOS Patch ###
|
||||||
|
# iCloud generated files
|
||||||
|
*.icloud
|
||||||
|
|
||||||
|
### Windows ###
|
||||||
|
# Windows thumbnail cache files
|
||||||
|
Thumbs.db
|
||||||
|
Thumbs.db:encryptable
|
||||||
|
ehthumbs.db
|
||||||
|
ehthumbs_vista.db
|
||||||
|
|
||||||
|
# Dump file
|
||||||
|
*.stackdump
|
||||||
|
|
||||||
|
# Folder config file
|
||||||
|
[Dd]esktop.ini
|
||||||
|
|
||||||
|
# Recycle Bin used on file shares
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
|
||||||
|
# Windows Installer files
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
# Windows shortcuts
|
||||||
|
*.lnk
|
||||||
|
|
||||||
|
# End of https://www.toptal.com/developers/gitignore/api/go,goland+all,linux,windows,macos
|
||||||
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).
|
|
||||||
34
actions.go
34
actions.go
@@ -20,20 +20,20 @@ import (
|
|||||||
// on the "+Fav" anchor on the submission page. We fetch the submission to
|
// on the "+Fav" anchor on the submission page. We fetch the submission to
|
||||||
// scrape the key, then follow the link. The whole exchange happens
|
// scrape the key, then follow the link. The whole exchange happens
|
||||||
// through the rate-limited transport.
|
// through the rate-limited transport.
|
||||||
func (c *Client) Fav(ctx context.Context, id SubmissionID) error {
|
func (c *Client) Fav(ctx context.Context, id SubmissionID, opts ...Option) error {
|
||||||
return c.toggleFavorite(ctx, id, true)
|
return c.toggleFavorite(ctx, id, true, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unfav removes a submission from the logged-in user's favorites.
|
// Unfav removes a submission from the logged-in user's favorites.
|
||||||
// Idempotent: if not favorited, no-op.
|
// Idempotent: if not favorited, no-op.
|
||||||
func (c *Client) Unfav(ctx context.Context, id SubmissionID) error {
|
func (c *Client) Unfav(ctx context.Context, id SubmissionID, opts ...Option) error {
|
||||||
return c.toggleFavorite(ctx, id, false)
|
return c.toggleFavorite(ctx, id, false, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// toggleFavorite implements both Fav and Unfav: scrape the per-state link
|
// toggleFavorite implements both Fav and Unfav: scrape the per-state link
|
||||||
// off the submission page and follow it. wantFav=true means "fav if not
|
// off the submission page and follow it. wantFav=true means "fav if not
|
||||||
// already faved"; wantFav=false means "unfav if currently faved".
|
// already faved"; wantFav=false means "unfav if currently faved".
|
||||||
func (c *Client) toggleFavorite(ctx context.Context, id SubmissionID, wantFav bool) error {
|
func (c *Client) toggleFavorite(ctx context.Context, id SubmissionID, wantFav bool, reqOpts []Option) error {
|
||||||
if id <= 0 {
|
if id <= 0 {
|
||||||
return fmt.Errorf("fa: toggleFavorite: id must be > 0")
|
return fmt.Errorf("fa: toggleFavorite: id must be > 0")
|
||||||
}
|
}
|
||||||
@@ -54,30 +54,30 @@ func (c *Client) toggleFavorite(ctx context.Context, id SubmissionID, wantFav bo
|
|||||||
return fmt.Errorf("%w: submission %d: no fav/unfav link on page (not logged in?)", ErrUnauthorized, id)
|
return fmt.Errorf("%w: submission %d: no fav/unfav link on page (not logged in?)", ErrUnauthorized, id)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
}, reqOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if alreadyInDesiredState {
|
if alreadyInDesiredState {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return c.followAction(ctx, actionURL)
|
return c.followAction(ctx, actionURL, reqOpts...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch starts watching a user. Idempotent: if already watching, no-op.
|
// Watch starts watching a user. Idempotent: if already watching, no-op.
|
||||||
// The key + endpoint live on a button on the user's profile (or any page
|
// The key + endpoint live on a button on the user's profile (or any page
|
||||||
// that user is the "owner" of, like a journal). We scrape the profile.
|
// that user is the "owner" of, like a journal). We scrape the profile.
|
||||||
func (c *Client) Watch(ctx context.Context, name string) error {
|
func (c *Client) Watch(ctx context.Context, name string, opts ...Option) error {
|
||||||
return c.toggleWatch(ctx, name, true)
|
return c.toggleWatch(ctx, name, true, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unwatch stops watching a user. Idempotent.
|
// Unwatch stops watching a user. Idempotent.
|
||||||
func (c *Client) Unwatch(ctx context.Context, name string) error {
|
func (c *Client) Unwatch(ctx context.Context, name string, opts ...Option) error {
|
||||||
return c.toggleWatch(ctx, name, false)
|
return c.toggleWatch(ctx, name, false, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// toggleWatch fetches the user page, picks watch or unwatch link by state.
|
// toggleWatch fetches the user page, picks watch or unwatch link by state.
|
||||||
func (c *Client) toggleWatch(ctx context.Context, name string, wantWatch bool) error {
|
func (c *Client) toggleWatch(ctx context.Context, name string, wantWatch bool, reqOpts []Option) error {
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return fmt.Errorf("fa: toggleWatch: empty name")
|
return fmt.Errorf("fa: toggleWatch: empty name")
|
||||||
@@ -99,14 +99,14 @@ func (c *Client) toggleWatch(ctx context.Context, name string, wantWatch bool) e
|
|||||||
return fmt.Errorf("%w: user %q: no watch/unwatch link on page", ErrUnauthorized, name)
|
return fmt.Errorf("%w: user %q: no watch/unwatch link on page", ErrUnauthorized, name)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
}, reqOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if alreadyInDesiredState {
|
if alreadyInDesiredState {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return c.followAction(ctx, actionURL)
|
return c.followAction(ctx, actionURL, reqOpts...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// findFavLinks scans a submission view page for the "+Fav" and "−Fav"
|
// findFavLinks scans a submission view page for the "+Fav" and "−Fav"
|
||||||
@@ -155,10 +155,11 @@ func findWatchLinks(doc *goquery.Document, name string) (watchURL, unwatchURL st
|
|||||||
// the transport+classifier; 5xx becomes HTTPError. We don't parse the
|
// the transport+classifier; 5xx becomes HTTPError. We don't parse the
|
||||||
// response body FA's success states are too varied to verify reliably
|
// response body FA's success states are too varied to verify reliably
|
||||||
// from HTML alone.
|
// from HTML alone.
|
||||||
func (c *Client) followAction(ctx context.Context, actionURL string) error {
|
func (c *Client) followAction(ctx context.Context, actionURL string, opts ...Option) error {
|
||||||
if actionURL == "" {
|
if actionURL == "" {
|
||||||
return fmt.Errorf("fa: followAction: empty URL")
|
return fmt.Errorf("fa: followAction: empty URL")
|
||||||
}
|
}
|
||||||
|
ctx = c.applyRequestOptions(ctx, opts)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, actionURL, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, actionURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -192,7 +193,8 @@ func (c *Client) followAction(ctx context.Context, actionURL string) error {
|
|||||||
//
|
//
|
||||||
// Returns the response body for callers that need to parse a confirmation
|
// Returns the response body for callers that need to parse a confirmation
|
||||||
// (e.g., to extract a newly-posted comment's ID).
|
// (e.g., to extract a newly-posted comment's ID).
|
||||||
func (c *Client) postForm(ctx context.Context, rawURL string, form url.Values) ([]byte, error) {
|
func (c *Client) postForm(ctx context.Context, rawURL string, form url.Values, opts ...Option) ([]byte, error) {
|
||||||
|
ctx = c.applyRequestOptions(ctx, opts)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, strings.NewReader(form.Encode()))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, strings.NewReader(form.Encode()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -364,16 +364,25 @@ func TestFindFavLinks_RealFixture(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read doc: %v", err)
|
t.Fatalf("read doc: %v", err)
|
||||||
}
|
}
|
||||||
// submission.html was captured for submission 65052636 (a "+Fav" page).
|
// submission.html was captured for submission 12345678. FA renders either
|
||||||
favURL, unfavURL := findFavLinks(doc, 65052636)
|
// a +Fav or a -Fav anchor depending on the capturing account's current
|
||||||
if favURL == "" {
|
// state never both, never neither (when authenticated). The test stays
|
||||||
t.Error("findFavLinks: favURL empty +Fav anchor not found in real markup")
|
// 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)
|
||||||
}
|
}
|
||||||
if !strings.Contains(favURL, "/fav/65052636/") || !strings.Contains(favURL, "key=") {
|
case unfavURL != "":
|
||||||
t.Errorf("findFavLinks: favURL = %q; want a /fav/65052636/?key=... URL", favURL)
|
if !strings.Contains(unfavURL, "/unfav/12345678/") || !strings.Contains(unfavURL, "key=") {
|
||||||
|
t.Errorf("findFavLinks: unfavURL = %q; want a /unfav/12345678/?key=... URL", unfavURL)
|
||||||
}
|
}
|
||||||
if unfavURL != "" {
|
|
||||||
t.Errorf("findFavLinks: unfavURL = %q; want empty on a not-yet-faved page", unfavURL)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ type BrowseOptions struct {
|
|||||||
// instead uses GET with ?page=N, which is honoured for the rendered HTML.
|
// instead uses GET with ?page=N, which is honoured for the rendered HTML.
|
||||||
// "Next page exists?" is inferred from item count: a full page
|
// "Next page exists?" is inferred from item count: a full page
|
||||||
// (browsePerPage items) means we keep going; anything less is the tail.
|
// (browsePerPage items) means we keep going; anything less is the tail.
|
||||||
func (c *Client) Browse(ctx context.Context, opts BrowseOptions) iter.Seq2[*Submission, error] {
|
func (c *Client) Browse(ctx context.Context, opts BrowseOptions, reqOpts ...Option) iter.Seq2[*Submission, error] {
|
||||||
return func(yield func(*Submission, error) bool) {
|
return func(yield func(*Submission, error) bool) {
|
||||||
page := opts.StartPage
|
page := opts.StartPage
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
@@ -78,7 +78,7 @@ func (c *Client) Browse(ctx context.Context, opts BrowseOptions) iter.Seq2[*Subm
|
|||||||
err := c.fetch(ctx, pageURL, func(doc *goquery.Document) error {
|
err := c.fetch(ctx, pageURL, func(doc *goquery.Document) error {
|
||||||
items, _ = parseGalleryPage(doc, c.cfg.jsonListings)
|
items, _ = parseGalleryPage(doc, c.cfg.jsonListings)
|
||||||
return nil
|
return nil
|
||||||
})
|
}, reqOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
yield(nil, err)
|
yield(nil, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -142,7 +142,8 @@ func seedJar(jar http.CookieJar, fa Cookies, cf CFCookies, sfw SFWMode) {
|
|||||||
// Context cancellation propagates through the http.Request and the rate
|
// Context cancellation propagates through the http.Request and the rate
|
||||||
// limiter a cancelled ctx surfaces from Wait or from the underlying
|
// limiter a cancelled ctx surfaces from Wait or from the underlying
|
||||||
// transport, depending on which phase the request is in.
|
// transport, depending on which phase the request is in.
|
||||||
func (c *Client) fetch(ctx context.Context, rawURL string, parse func(doc *goquery.Document) error) error {
|
func (c *Client) fetch(ctx context.Context, rawURL string, parse func(doc *goquery.Document) error, opts ...Option) error {
|
||||||
|
ctx = c.applyRequestOptions(ctx, opts)
|
||||||
clone := c.collector.Clone()
|
clone := c.collector.Clone()
|
||||||
clone.SetClient(c.http)
|
clone.SetClient(c.http)
|
||||||
clone.SetCookieJar(c.jar)
|
clone.SetCookieJar(c.jar)
|
||||||
|
|||||||
12
comment.go
12
comment.go
@@ -28,25 +28,25 @@ type Comment struct {
|
|||||||
// Comments aren't paginated, so this iterator performs one fetch and then
|
// Comments aren't paginated, so this iterator performs one fetch and then
|
||||||
// yields each comment in document order; early termination still avoids
|
// yields each comment in document order; early termination still avoids
|
||||||
// processing the rest of the slice.
|
// processing the rest of the slice.
|
||||||
func (c *Client) SubmissionComments(ctx context.Context, id SubmissionID) iter.Seq2[*Comment, error] {
|
func (c *Client) SubmissionComments(ctx context.Context, id SubmissionID, opts ...Option) iter.Seq2[*Comment, error] {
|
||||||
return c.yieldComments(ctx, urls.Submission(int64(id)))
|
return c.yieldComments(ctx, urls.Submission(int64(id)), opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// JournalComments yields every comment on a journal page. Same iteration
|
// JournalComments yields every comment on a journal page. Same iteration
|
||||||
// shape as [Client.SubmissionComments].
|
// shape as [Client.SubmissionComments].
|
||||||
func (c *Client) JournalComments(ctx context.Context, id JournalID) iter.Seq2[*Comment, error] {
|
func (c *Client) JournalComments(ctx context.Context, id JournalID, opts ...Option) iter.Seq2[*Comment, error] {
|
||||||
return c.yieldComments(ctx, urls.Journal(int64(id)))
|
return c.yieldComments(ctx, urls.Journal(int64(id)), opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// yieldComments performs the single fetch shared by submission and journal
|
// yieldComments performs the single fetch shared by submission and journal
|
||||||
// comment iterators, then yields parsed comments to the caller.
|
// comment iterators, then yields parsed comments to the caller.
|
||||||
func (c *Client) yieldComments(ctx context.Context, pageURL string) iter.Seq2[*Comment, error] {
|
func (c *Client) yieldComments(ctx context.Context, pageURL string, opts []Option) iter.Seq2[*Comment, error] {
|
||||||
return func(yield func(*Comment, error) bool) {
|
return func(yield func(*Comment, error) bool) {
|
||||||
var comments []*Comment
|
var comments []*Comment
|
||||||
err := c.fetch(ctx, pageURL, func(doc *goquery.Document) error {
|
err := c.fetch(ctx, pageURL, func(doc *goquery.Document) error {
|
||||||
comments = parseComments(doc)
|
comments = parseComments(doc)
|
||||||
return nil
|
return nil
|
||||||
})
|
}, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
yield(nil, err)
|
yield(nil, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -24,26 +24,26 @@ type PostCommentOptions struct {
|
|||||||
// URL with fields `action=reply`, `replyto=<parent>`, `reply=<body>`.
|
// URL with fields `action=reply`, `replyto=<parent>`, `reply=<body>`.
|
||||||
// There is no separate per-form CSRF key auth cookies + Cloudflare
|
// There is no separate per-form CSRF key auth cookies + Cloudflare
|
||||||
// clearance are the only gates.
|
// clearance are the only gates.
|
||||||
func (c *Client) PostSubmissionComment(ctx context.Context, id SubmissionID, body string, opts PostCommentOptions) error {
|
func (c *Client) PostSubmissionComment(ctx context.Context, id SubmissionID, body string, opts PostCommentOptions, reqOpts ...Option) error {
|
||||||
if id <= 0 {
|
if id <= 0 {
|
||||||
return fmt.Errorf("fa: PostSubmissionComment: id must be > 0")
|
return fmt.Errorf("fa: PostSubmissionComment: id must be > 0")
|
||||||
}
|
}
|
||||||
return c.postCommentForm(ctx, urls.Submission(int64(id)), body, opts)
|
return c.postCommentForm(ctx, urls.Submission(int64(id)), body, opts, reqOpts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostJournalComment posts a comment on a journal. Same form shape as
|
// PostJournalComment posts a comment on a journal. Same form shape as
|
||||||
// submissions; the form action just points at /journal/{id}/.
|
// submissions; the form action just points at /journal/{id}/.
|
||||||
func (c *Client) PostJournalComment(ctx context.Context, id JournalID, body string, opts PostCommentOptions) error {
|
func (c *Client) PostJournalComment(ctx context.Context, id JournalID, body string, opts PostCommentOptions, reqOpts ...Option) error {
|
||||||
if id <= 0 {
|
if id <= 0 {
|
||||||
return fmt.Errorf("fa: PostJournalComment: id must be > 0")
|
return fmt.Errorf("fa: PostJournalComment: id must be > 0")
|
||||||
}
|
}
|
||||||
return c.postCommentForm(ctx, urls.Journal(int64(id)), body, opts)
|
return c.postCommentForm(ctx, urls.Journal(int64(id)), body, opts, reqOpts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// postCommentForm builds the field set #add_comment_form sends. Shared
|
// postCommentForm builds the field set #add_comment_form sends. Shared
|
||||||
// between submission and journal comment posting because FA renders an
|
// between submission and journal comment posting because FA renders an
|
||||||
// identical form on both pages.
|
// identical form on both pages.
|
||||||
func (c *Client) postCommentForm(ctx context.Context, pageURL, body string, opts PostCommentOptions) error {
|
func (c *Client) postCommentForm(ctx context.Context, pageURL, body string, opts PostCommentOptions, reqOpts []Option) error {
|
||||||
if body == "" {
|
if body == "" {
|
||||||
return fmt.Errorf("fa: PostComment: empty body")
|
return fmt.Errorf("fa: PostComment: empty body")
|
||||||
}
|
}
|
||||||
@@ -57,6 +57,6 @@ func (c *Client) postCommentForm(ctx context.Context, pageURL, body string, opts
|
|||||||
}
|
}
|
||||||
v.Set("reply", body)
|
v.Set("reply", body)
|
||||||
v.Set("submit", "Post Comment")
|
v.Set("submit", "Post Comment")
|
||||||
_, err := c.postForm(ctx, pageURL, v)
|
_, err := c.postForm(ctx, pageURL, v, reqOpts...)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
113
examples/multiuser/main.go
Normal file
113
examples/multiuser/main.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
// multiuser demonstrates the per-request credentials pattern: a single
|
||||||
|
// fa.Client (one IP, one shared rate limiter) servicing many end users by
|
||||||
|
// passing their cookies as per-call Options that override the client's
|
||||||
|
// defaults.
|
||||||
|
//
|
||||||
|
// Run with one or more FA accounts encoded as comma-separated A:B:CF
|
||||||
|
// tuples, e.g.
|
||||||
|
//
|
||||||
|
// FA_USERS="aCookieA:bCookieA:cfClearanceA,aCookieB:bCookieB:" \
|
||||||
|
// FA_UA="Mozilla/5.0 ..." \
|
||||||
|
// go run ./examples/multiuser 12345678
|
||||||
|
//
|
||||||
|
// The CF clearance is optional per user (empty third field is fine). FA_UA
|
||||||
|
// must match the UA the cf_clearance cookies were issued under.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
fa "git.anthrove.art/public/go-fa-api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserCreds is whatever your storage layer hands back per end user.
|
||||||
|
type UserCreds struct {
|
||||||
|
Label string
|
||||||
|
A, B string
|
||||||
|
CFClearance string
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
log.Fatalf("usage: %s <submission-id>", os.Args[0])
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(os.Args[1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid submission id: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw := os.Getenv("FA_USERS")
|
||||||
|
if raw == "" {
|
||||||
|
log.Fatal("FA_USERS must be set (comma-separated A:B:CF tuples)")
|
||||||
|
}
|
||||||
|
users := parseUsers(raw)
|
||||||
|
if len(users) == 0 {
|
||||||
|
log.Fatal("FA_USERS parsed to zero users")
|
||||||
|
}
|
||||||
|
|
||||||
|
ua := envOr("FA_UA", "go-fa-api-example/0.1")
|
||||||
|
|
||||||
|
// One client. Built once at startup. The single rate limiter inside is
|
||||||
|
// what protects the shared egress IP from Cloudflare bans no matter
|
||||||
|
// how many users we add, the combined request rate stays at WithRPS.
|
||||||
|
client := fa.New(
|
||||||
|
fa.WithUserAgent(ua),
|
||||||
|
fa.WithRequestsPerSecond(1),
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, u := range users {
|
||||||
|
start := time.Now()
|
||||||
|
sub, err := client.GetSubmission(ctx, fa.SubmissionID(id),
|
||||||
|
// Per-call overrides win over the client's defaults. The shared
|
||||||
|
// limiter still gates this request, so user B cannot starve user
|
||||||
|
// A and the combined rate never exceeds WithRequestsPerSecond.
|
||||||
|
fa.WithCookies(fa.Cookies{A: u.A, B: u.B}),
|
||||||
|
fa.WithCloudflare(fa.CFCookies{Clearance: u.CFClearance}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[%s] GetSubmission: %v", u.Label, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf("[%s] %s by %s (%s) fetched in %v\n",
|
||||||
|
u.Label, sub.Title, sub.Author.DisplayName, sub.Rating, time.Since(start).Round(time.Millisecond))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUsers(raw string) []UserCreds {
|
||||||
|
var out []UserCreds
|
||||||
|
for i, part := range strings.Split(raw, ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.SplitN(part, ":", 3)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
log.Printf("FA_USERS[%d]: skipping malformed entry %q", i, part)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
u := UserCreds{
|
||||||
|
Label: fmt.Sprintf("user%d", i+1),
|
||||||
|
A: strings.TrimSpace(fields[0]),
|
||||||
|
B: strings.TrimSpace(fields[1]),
|
||||||
|
}
|
||||||
|
if len(fields) == 3 {
|
||||||
|
u.CFClearance = strings.TrimSpace(fields[2])
|
||||||
|
}
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ func main() {
|
|||||||
fa.WithUserAgent(envOr("FA_UA", "go-fa-api-example/0.1")),
|
fa.WithUserAgent(envOr("FA_UA", "go-fa-api-example/0.1")),
|
||||||
)
|
)
|
||||||
|
|
||||||
n, err := client.Notifications(context.Background())
|
n, err := client.Notifications(context.Background(), fa.NotificationsOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Notifications: %v", err)
|
log.Fatalf("Notifications: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ func TestRefreshFixtures(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "favorites_page1.html",
|
name: "favorites_page1.html",
|
||||||
url: urls.Favorites(favoritesUser, 1),
|
url: urls.Favorites(favoritesUser),
|
||||||
requires: []string{favoritesUser},
|
requires: []string{favoritesUser},
|
||||||
notes: "favorites per-item Author should be the original artist",
|
notes: "favorites per-item Author should be the original artist",
|
||||||
},
|
},
|
||||||
|
|||||||
148
gallery.go
148
gallery.go
@@ -3,6 +3,7 @@ package fa
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"iter"
|
"iter"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/PuerkitoBio/goquery"
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
|
||||||
@@ -12,32 +13,142 @@ import (
|
|||||||
// Gallery iterates the submissions in a user's main gallery, newest first.
|
// Gallery iterates the submissions in a user's main gallery, newest first.
|
||||||
//
|
//
|
||||||
// Each yielded *Submission carries only the fields visible on the listing
|
// 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.
|
// [Client.GetSubmission] with the ID to load the full record.
|
||||||
func (c *Client) Gallery(ctx context.Context, name string, opts ListOptions) iter.Seq2[*Submission, error] {
|
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)
|
return c.listPagedSection(ctx, name, urls.Gallery, opts, reqOpts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scraps iterates the user's scraps folder. Same yield shape as Gallery.
|
// Scraps iterates the user's scraps folder. Same yield shape as Gallery.
|
||||||
func (c *Client) Scraps(ctx context.Context, name string, opts ListOptions) iter.Seq2[*Submission, error] {
|
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)
|
return c.listPagedSection(ctx, name, urls.Scraps, opts, reqOpts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Favorites iterates the user's favorited submissions. The yielded
|
// Favorites iterates the user's favorited submissions. The yielded
|
||||||
// *Submission's Author field reflects the original artist (not the user
|
// *Submission's Author field reflects the original artist (not the user
|
||||||
// whose favorites we are walking).
|
// whose favorites we are walking).
|
||||||
func (c *Client) Favorites(ctx context.Context, name string, opts ListOptions) iter.Seq2[*Submission, error] {
|
//
|
||||||
return c.listGallerySection(ctx, name, urls.Favorites, opts)
|
// Favorites use a fave-ID cursor for pagination, not sequential page
|
||||||
|
// numbers, so [ListOptions.StartPage] is ignored — the walk always
|
||||||
|
// begins at the newest favorite. [ListOptions.MaxPages] still bounds
|
||||||
|
// the crawl.
|
||||||
|
func (c *Client) Favorites(ctx context.Context, name string, opts ListOptions, reqOpts ...Option) iter.Seq2[*Submission, error] {
|
||||||
|
return func(yield func(*Submission, error) bool) {
|
||||||
|
cursor := ""
|
||||||
|
pagesFetched := 0
|
||||||
|
for {
|
||||||
|
if opts.reachedLimit(pagesFetched) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lp, err := c.FavoritesPage(ctx, name, cursor, reqOpts...)
|
||||||
|
if err != nil {
|
||||||
|
yield(nil, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pagesFetched++
|
||||||
|
if len(lp.Items) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, s := range lp.Items {
|
||||||
|
if !yield(s, nil) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !lp.HasNext {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cursor = lp.NextPage
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// listGallerySection is the shared engine for Gallery / Scraps / Favorites.
|
// GalleryPage fetches a single page of /gallery/{name}/ and returns the
|
||||||
// urlFn picks the section-specific URL builder; the rest of the pagination
|
// items along with whether more pages exist. Pages are 1-based; pass 0 or
|
||||||
// machinery is identical across all three sections.
|
// 1 for the first page. Use this when driving pagination manually
|
||||||
func (c *Client) listGallerySection(
|
// (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,
|
ctx context.Context,
|
||||||
name string,
|
name string,
|
||||||
urlFn func(string, int) string,
|
urlFn func(string, int) string,
|
||||||
opts ListOptions,
|
opts ListOptions,
|
||||||
|
reqOpts []Option,
|
||||||
) iter.Seq2[*Submission, error] {
|
) iter.Seq2[*Submission, error] {
|
||||||
return func(yield func(*Submission, error) bool) {
|
return func(yield func(*Submission, error) bool) {
|
||||||
page := opts.firstPage()
|
page := opts.firstPage()
|
||||||
@@ -46,28 +157,21 @@ func (c *Client) listGallerySection(
|
|||||||
if opts.reachedLimit(pagesFetched) {
|
if opts.reachedLimit(pagesFetched) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var (
|
lp, err := c.fetchNumberedPage(ctx, name, page, urlFn, reqOpts)
|
||||||
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
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
yield(nil, err)
|
yield(nil, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pagesFetched++
|
pagesFetched++
|
||||||
if len(items) == 0 {
|
if len(lp.Items) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, s := range items {
|
for _, s := range lp.Items {
|
||||||
if !yield(s, nil) {
|
if !yield(s, nil) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !hasNext {
|
if !lp.HasNext {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
page++
|
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]
|
// pure HTML the same behaviour as before [WithExperimentalJSONListings]
|
||||||
// existed.
|
// existed.
|
||||||
func parseGalleryPage(doc *goquery.Document, useJSON bool) (items []*Submission, hasNext bool) {
|
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
|
var jsonData listingJSONMap
|
||||||
if useJSON {
|
if useJSON {
|
||||||
jsonData = readListingJSON(doc)
|
jsonData = readListingJSON(doc)
|
||||||
@@ -28,8 +37,8 @@ func parseGalleryPage(doc *goquery.Document, useJSON bool) (items []*Submission,
|
|||||||
items = append(items, s)
|
items = append(items, s)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
hasNext = detectNextPage(doc)
|
nextURL, hasNext = nextPageURL(doc)
|
||||||
return items, hasNext
|
return items, nextURL, hasNext
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseGalleryFigure lifts a single submission preview from a
|
// 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.
|
// JSON enrichment preferred sources for the fields it carries.
|
||||||
if jsonData != nil {
|
if jsonData != nil {
|
||||||
if entry, ok := jsonData[id]; ok {
|
if entry, ok := jsonData[id]; ok {
|
||||||
@@ -105,3 +123,35 @@ func parseGalleryFigure(sel *goquery.Selection, jsonData listingJSONMap) *Submis
|
|||||||
|
|
||||||
return s
|
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) {
|
func TestParseGalleryPage_RealFixture(t *testing.T) {
|
||||||
raw := loadFixture(t, "gallery_page1.html")
|
raw := loadFixture(t, "gallery_page1.html")
|
||||||
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
||||||
|
|||||||
4
inbox.go
4
inbox.go
@@ -34,7 +34,7 @@ import (
|
|||||||
// ListOptions.StartPage is ignored the inbox is cursor-paginated by
|
// ListOptions.StartPage is ignored the inbox is cursor-paginated by
|
||||||
// FA (the "Next 72" link encodes a from-id), not page-numbered, so there
|
// FA (the "Next 72" link encodes a from-id), not page-numbered, so there
|
||||||
// is nothing meaningful to start from.
|
// is nothing meaningful to start from.
|
||||||
func (c *Client) SubmissionInbox(ctx context.Context, opts ListOptions) iter.Seq2[*Submission, error] {
|
func (c *Client) SubmissionInbox(ctx context.Context, opts ListOptions, reqOpts ...Option) iter.Seq2[*Submission, error] {
|
||||||
return func(yield func(*Submission, error) bool) {
|
return func(yield func(*Submission, error) bool) {
|
||||||
nextURL := urls.MsgSubmissions()
|
nextURL := urls.MsgSubmissions()
|
||||||
pagesFetched := 0
|
pagesFetched := 0
|
||||||
@@ -58,7 +58,7 @@ func (c *Client) SubmissionInbox(ctx context.Context, opts ListOptions) iter.Seq
|
|||||||
err := c.fetch(ctx, nextURL, func(doc *goquery.Document) error {
|
err := c.fetch(ctx, nextURL, func(doc *goquery.Document) error {
|
||||||
items, next = parseSubmissionInboxPage(doc, c.cfg.jsonListings)
|
items, next = parseSubmissionInboxPage(doc, c.cfg.jsonListings)
|
||||||
return nil
|
return nil
|
||||||
})
|
}, reqOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
yield(nil, err)
|
yield(nil, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -36,10 +36,25 @@ func Scraps(name string, page int) string {
|
|||||||
return Host + "/scraps/" + safeName(name) + "/" + pageSegment(page)
|
return Host + "/scraps/" + safeName(name) + "/" + pageSegment(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Favorites returns the URL for a user's favorites page. FA uses a numeric
|
// Favorites returns the URL for the first page of a user's favorites.
|
||||||
// page parameter; the first page is 1.
|
// FA paginates favorites with a fave-ID cursor (see [FavoritesCursor]),
|
||||||
func Favorites(name string, page int) string {
|
// not sequential page numbers — passing /favorites/{user}/{N}/ with a
|
||||||
return Host + "/favorites/" + safeName(name) + "/" + pageSegment(page)
|
// 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.
|
// Journal returns the URL for a single journal entry.
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type Journal struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetJournal fetches a journal entry by its numeric ID.
|
// GetJournal fetches a journal entry by its numeric ID.
|
||||||
func (c *Client) GetJournal(ctx context.Context, id JournalID) (*Journal, error) {
|
func (c *Client) GetJournal(ctx context.Context, id JournalID, opts ...Option) (*Journal, error) {
|
||||||
if id <= 0 {
|
if id <= 0 {
|
||||||
return nil, fmt.Errorf("fa: GetJournal: id must be > 0")
|
return nil, fmt.Errorf("fa: GetJournal: id must be > 0")
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ func (c *Client) GetJournal(ctx context.Context, id JournalID) (*Journal, error)
|
|||||||
}
|
}
|
||||||
out = j
|
out = j
|
||||||
return nil
|
return nil
|
||||||
})
|
}, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@ func (c *Client) GetJournal(ctx context.Context, id JournalID) (*Journal, error)
|
|||||||
// each [Journal] preview (full body included on FA's listing page).
|
// each [Journal] preview (full body included on FA's listing page).
|
||||||
//
|
//
|
||||||
// Use [ListOptions.MaxPages] to bound the crawl.
|
// Use [ListOptions.MaxPages] to bound the crawl.
|
||||||
func (c *Client) UserJournals(ctx context.Context, name string, opts ListOptions) iter.Seq2[*Journal, error] {
|
func (c *Client) UserJournals(ctx context.Context, name string, opts ListOptions, reqOpts ...Option) iter.Seq2[*Journal, error] {
|
||||||
return func(yield func(*Journal, error) bool) {
|
return func(yield func(*Journal, error) bool) {
|
||||||
page := opts.firstPage()
|
page := opts.firstPage()
|
||||||
pagesFetched := 0
|
pagesFetched := 0
|
||||||
@@ -60,7 +60,7 @@ func (c *Client) UserJournals(ctx context.Context, name string, opts ListOptions
|
|||||||
err := c.fetch(ctx, urls.UserJournals(name, page), func(doc *goquery.Document) error {
|
err := c.fetch(ctx, urls.UserJournals(name, page), func(doc *goquery.Document) error {
|
||||||
items, hasNext = parseUserJournalsPage(doc)
|
items, hasNext = parseUserJournalsPage(doc)
|
||||||
return nil
|
return nil
|
||||||
})
|
}, reqOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
yield(nil, err)
|
yield(nil, err)
|
||||||
return
|
return
|
||||||
|
|||||||
8
notes.go
8
notes.go
@@ -51,7 +51,7 @@ type Note struct {
|
|||||||
// (follow-the-Next-link), not page-numbered fetches.
|
// (follow-the-Next-link), not page-numbered fetches.
|
||||||
//
|
//
|
||||||
// Requires a logged-in client.
|
// Requires a logged-in client.
|
||||||
func (c *Client) Notes(ctx context.Context, opts ListOptions) iter.Seq2[*NotePreview, error] {
|
func (c *Client) Notes(ctx context.Context, opts ListOptions, reqOpts ...Option) iter.Seq2[*NotePreview, error] {
|
||||||
return func(yield func(*NotePreview, error) bool) {
|
return func(yield func(*NotePreview, error) bool) {
|
||||||
nextURL := urls.MsgPMs()
|
nextURL := urls.MsgPMs()
|
||||||
pagesFetched := 0
|
pagesFetched := 0
|
||||||
@@ -66,7 +66,7 @@ func (c *Client) Notes(ctx context.Context, opts ListOptions) iter.Seq2[*NotePre
|
|||||||
err := c.fetch(ctx, nextURL, func(doc *goquery.Document) error {
|
err := c.fetch(ctx, nextURL, func(doc *goquery.Document) error {
|
||||||
items, next = parseNotesInboxPage(doc)
|
items, next = parseNotesInboxPage(doc)
|
||||||
return nil
|
return nil
|
||||||
})
|
}, reqOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
yield(nil, err)
|
yield(nil, err)
|
||||||
return
|
return
|
||||||
@@ -87,7 +87,7 @@ func (c *Client) Notes(ctx context.Context, opts ListOptions) iter.Seq2[*NotePre
|
|||||||
|
|
||||||
// GetNote fetches a single note (private message) by ID. Requires a
|
// GetNote fetches a single note (private message) by ID. Requires a
|
||||||
// logged-in client.
|
// logged-in client.
|
||||||
func (c *Client) GetNote(ctx context.Context, id NoteID) (*Note, error) {
|
func (c *Client) GetNote(ctx context.Context, id NoteID, opts ...Option) (*Note, error) {
|
||||||
if id <= 0 {
|
if id <= 0 {
|
||||||
return nil, fmt.Errorf("fa: GetNote: id must be > 0")
|
return nil, fmt.Errorf("fa: GetNote: id must be > 0")
|
||||||
}
|
}
|
||||||
@@ -99,7 +99,7 @@ func (c *Client) GetNote(ctx context.Context, id NoteID) (*Note, error) {
|
|||||||
}
|
}
|
||||||
out = n
|
out = n
|
||||||
return nil
|
return nil
|
||||||
})
|
}, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,11 +52,17 @@ func parseNoteListItem(item *goquery.Selection) *NotePreview {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Note ID lives in the href: /msg/pms/{folder}/{id}/#message. Strip the
|
// 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 {
|
if i := strings.Index(href, "#"); i != -1 {
|
||||||
href = href[:i]
|
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.
|
// 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") {
|
if class, _ := subjectLink.Attr("class"); strings.Contains(class, "note-unread") || strings.Contains(class, "unread") && !strings.Contains(class, "note-read") {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
// Returns nil on success. ErrUnauthorized when not logged in.
|
// Returns nil on success. ErrUnauthorized when not logged in.
|
||||||
// *SystemMessageError when FA rejects the send (recipient blocked, rate
|
// *SystemMessageError when FA rejects the send (recipient blocked, rate
|
||||||
// limited, etc.).
|
// limited, etc.).
|
||||||
func (c *Client) SendNote(ctx context.Context, to string, subject string, body string) error {
|
func (c *Client) SendNote(ctx context.Context, to string, subject string, body string, opts ...Option) error {
|
||||||
to = strings.TrimSpace(to)
|
to = strings.TrimSpace(to)
|
||||||
if to == "" {
|
if to == "" {
|
||||||
return fmt.Errorf("fa: SendNote: empty recipient")
|
return fmt.Errorf("fa: SendNote: empty recipient")
|
||||||
@@ -42,7 +42,7 @@ func (c *Client) SendNote(ctx context.Context, to string, subject string, body s
|
|||||||
return fmt.Errorf("%w: SendNote: could not locate form key on /msg/pms/", ErrUnauthorized)
|
return fmt.Errorf("%w: SendNote: could not locate form key on /msg/pms/", ErrUnauthorized)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
}, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ func (c *Client) SendNote(ctx context.Context, to string, subject string, body s
|
|||||||
v.Set("subject", subject)
|
v.Set("subject", subject)
|
||||||
v.Set("message", body)
|
v.Set("message", body)
|
||||||
v.Set("send", "Send Note")
|
v.Set("send", "Send Note")
|
||||||
_, err = c.postForm(ctx, urls.Host+"/msg/send/", v)
|
_, err = c.postForm(ctx, urls.Host+"/msg/send/", v, opts...)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,55 +69,41 @@ type ShoutNotification struct {
|
|||||||
PostedAt time.Time
|
PostedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotificationsOption tunes a single [Client.Notifications] call.
|
// NotificationsOptions tunes a single [Client.Notifications] call. Match
|
||||||
type NotificationsOption func(*notificationsConfig)
|
// the pattern used by [BrowseOptions] / [SearchOptions] / [ListOptions]: a
|
||||||
|
// plain struct of zero-default fields, kept distinct from the per-request
|
||||||
|
// [Option] values that swap creds.
|
||||||
|
type NotificationsOptions struct {
|
||||||
|
// ResolveAvatars makes [Client.Notifications] fill in author avatar
|
||||||
|
// URLs that FurAffinity omits from the /msg/others/ markup.
|
||||||
|
//
|
||||||
|
// FA does not render an avatar image on every notification row
|
||||||
|
// journal notifications in particular are a purely textual list, so
|
||||||
|
// their author UserRef comes back with AvatarURL == "". When this is
|
||||||
|
// set, the SDK resolves authors by fetching each one's profile page.
|
||||||
|
//
|
||||||
|
// Each distinct author costs one extra HTTP request, deduplicated
|
||||||
|
// within the call and serialized through the client's rate limiter.
|
||||||
|
ResolveAvatars bool
|
||||||
|
|
||||||
type notificationsConfig struct {
|
// AvatarLimit caps how many distinct authors are resolved when
|
||||||
resolveAvatars bool
|
// ResolveAvatars is true. Authors are visited journals-first; any
|
||||||
avatarLimit int
|
// author past the limit keeps AvatarURL == "". Zero or negative
|
||||||
}
|
// means unlimited. Has no effect when ResolveAvatars is false.
|
||||||
|
AvatarLimit int
|
||||||
// WithResolvedAvatars makes [Client.Notifications] fill in author avatar
|
|
||||||
// URLs that FurAffinity omits from the /msg/others/ markup.
|
|
||||||
//
|
|
||||||
// FA does not render an avatar image on every notification row journal
|
|
||||||
// notifications in particular are a purely textual list, so their author
|
|
||||||
// UserRef comes back with AvatarURL == "". When this option is set, the
|
|
||||||
// SDK resolves authors by fetching each one's profile page and reading the
|
|
||||||
// avatar from it.
|
|
||||||
//
|
|
||||||
// Each distinct author costs one extra HTTP request, deduplicated within
|
|
||||||
// the call and serialized through the client's rate limiter. Because that
|
|
||||||
// limiter is global, total wall time is roughly (distinct authors) ×
|
|
||||||
// (rate interval) on a busy account, dozens of seconds. limit caps how
|
|
||||||
// many distinct authors are resolved: pass a small value (e.g. 12) to
|
|
||||||
// bound a cold load. Authors are resolved in notification order with
|
|
||||||
// journals first, so a Home "recent journals" feed still gets real
|
|
||||||
// avatars; any author past the limit keeps AvatarURL == "" (callers
|
|
||||||
// typically render an initials fallback). A limit <= 0 means unlimited.
|
|
||||||
//
|
|
||||||
// Per-author failures are ignored: a user whose page can't be fetched
|
|
||||||
// simply keeps an empty AvatarURL.
|
|
||||||
func WithResolvedAvatars(limit int) NotificationsOption {
|
|
||||||
return func(c *notificationsConfig) {
|
|
||||||
c.resolveAvatars = true
|
|
||||||
c.avatarLimit = limit
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notifications fetches /msg/others/ and returns the parsed notification
|
// Notifications fetches /msg/others/ and returns the parsed notification
|
||||||
// page. Requires a logged-in client; anonymous calls surface as
|
// page. Requires a logged-in client; anonymous calls surface as
|
||||||
// [ErrUnauthorized].
|
// [ErrUnauthorized].
|
||||||
//
|
//
|
||||||
// All categories are returned in a single fetch there is no pagination
|
// All categories are returned in a single fetch — there is no pagination
|
||||||
// on this page. Pass [WithResolvedAvatars] to additionally backfill author
|
// on this page. Set [NotificationsOptions.ResolveAvatars] to additionally
|
||||||
// avatars that FA omits from the page (see that option's docs).
|
// backfill author avatars that FA omits from the page.
|
||||||
func (c *Client) Notifications(ctx context.Context, opts ...NotificationsOption) (*Notifications, error) {
|
//
|
||||||
var cfg notificationsConfig
|
// reqOpts are per-request overrides (typically [WithCookies] etc. for the
|
||||||
for _, o := range opts {
|
// multi-tenant case where many users share one client and one rate limiter).
|
||||||
o(&cfg)
|
func (c *Client) Notifications(ctx context.Context, opts NotificationsOptions, reqOpts ...Option) (*Notifications, error) {
|
||||||
}
|
|
||||||
|
|
||||||
var out *Notifications
|
var out *Notifications
|
||||||
err := c.fetch(ctx, urls.MsgOthers(), func(doc *goquery.Document) error {
|
err := c.fetch(ctx, urls.MsgOthers(), func(doc *goquery.Document) error {
|
||||||
n, err := parseNotifications(doc)
|
n, err := parseNotifications(doc)
|
||||||
@@ -126,13 +112,13 @@ func (c *Client) Notifications(ctx context.Context, opts ...NotificationsOption)
|
|||||||
}
|
}
|
||||||
out = n
|
out = n
|
||||||
return nil
|
return nil
|
||||||
})
|
}, reqOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.resolveAvatars {
|
if opts.ResolveAvatars {
|
||||||
c.resolveNotificationAvatars(ctx, out, cfg.avatarLimit)
|
c.resolveNotificationAvatars(ctx, out, opts.AvatarLimit, reqOpts)
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -152,7 +138,7 @@ func (c *Client) Notifications(ctx context.Context, opts ...NotificationsOption)
|
|||||||
// Failures are deliberately swallowed: a single unreachable profile must
|
// Failures are deliberately swallowed: a single unreachable profile must
|
||||||
// not fail the whole notifications call, and a stale ctx simply leaves the
|
// not fail the whole notifications call, and a stale ctx simply leaves the
|
||||||
// remaining avatars empty.
|
// remaining avatars empty.
|
||||||
func (c *Client) resolveNotificationAvatars(ctx context.Context, n *Notifications, limit int) {
|
func (c *Client) resolveNotificationAvatars(ctx context.Context, n *Notifications, limit int, reqOpts []Option) {
|
||||||
if n == nil {
|
if n == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -166,7 +152,7 @@ func (c *Client) resolveNotificationAvatars(ctx context.Context, n *Notification
|
|||||||
if limit > 0 && len(cache) >= limit {
|
if limit > 0 && len(cache) >= limit {
|
||||||
return // fetch budget exhausted leave AvatarURL empty
|
return // fetch budget exhausted leave AvatarURL empty
|
||||||
}
|
}
|
||||||
avatar = c.fetchUserAvatar(ctx, ref.Name)
|
avatar = c.fetchUserAvatar(ctx, ref.Name, reqOpts)
|
||||||
cache[ref.Name] = avatar
|
cache[ref.Name] = avatar
|
||||||
}
|
}
|
||||||
ref.AvatarURL = avatar
|
ref.AvatarURL = avatar
|
||||||
@@ -194,7 +180,7 @@ func (c *Client) resolveNotificationAvatars(ctx context.Context, n *Notification
|
|||||||
// fetchUserAvatar fetches /user/{name}/ and returns just the profile
|
// fetchUserAvatar fetches /user/{name}/ and returns just the profile
|
||||||
// owner's avatar URL. It returns "" on any failure callers treat a
|
// owner's avatar URL. It returns "" on any failure callers treat a
|
||||||
// missing avatar as non-fatal.
|
// missing avatar as non-fatal.
|
||||||
func (c *Client) fetchUserAvatar(ctx context.Context, name string) string {
|
func (c *Client) fetchUserAvatar(ctx context.Context, name string, reqOpts []Option) string {
|
||||||
var avatar string
|
var avatar string
|
||||||
_ = c.fetch(ctx, urls.User(name), func(doc *goquery.Document) error {
|
_ = c.fetch(ctx, urls.User(name), func(doc *goquery.Document) error {
|
||||||
avatar = urls.AbsoluteCDN(firstNonEmpty(
|
avatar = urls.AbsoluteCDN(firstNonEmpty(
|
||||||
@@ -202,6 +188,6 @@ func (c *Client) fetchUserAvatar(ctx context.Context, name string) string {
|
|||||||
trimAttr(doc.Find("div.userpage-nav-avatar img").First(), "src"),
|
trimAttr(doc.Find("div.userpage-nav-avatar img").First(), "src"),
|
||||||
))
|
))
|
||||||
return nil
|
return nil
|
||||||
})
|
}, reqOpts...)
|
||||||
return avatar
|
return avatar
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func TestNotifications_ResolvesAvatars(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
client := newE2EClient(t, srv)
|
client := newE2EClient(t, srv)
|
||||||
n, err := client.Notifications(context.Background(), WithResolvedAvatars(0))
|
n, err := client.Notifications(context.Background(), NotificationsOptions{ResolveAvatars: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Notifications: %v", err)
|
t.Fatalf("Notifications: %v", err)
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ func TestNotifications_ResolvedAvatarsRespectsLimit(t *testing.T) {
|
|||||||
client := newE2EClient(t, srv)
|
client := newE2EClient(t, srv)
|
||||||
// Budget of 2 distinct authors. Journals resolve in document order, so
|
// Budget of 2 distinct authors. Journals resolve in document order, so
|
||||||
// authora + authorb get fetched; authorc is past the budget.
|
// authora + authorb get fetched; authorc is past the budget.
|
||||||
n, err := client.Notifications(context.Background(), WithResolvedAvatars(2))
|
n, err := client.Notifications(context.Background(), NotificationsOptions{ResolveAvatars: true, AvatarLimit: 2})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Notifications: %v", err)
|
t.Fatalf("Notifications: %v", err)
|
||||||
}
|
}
|
||||||
@@ -178,7 +178,7 @@ func TestNotifications_NoAvatarResolutionByDefault(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
client := newE2EClient(t, srv)
|
client := newE2EClient(t, srv)
|
||||||
n, err := client.Notifications(context.Background())
|
n, err := client.Notifications(context.Background(), NotificationsOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Notifications: %v", err)
|
t.Fatalf("Notifications: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,30 @@ import (
|
|||||||
"github.com/PuerkitoBio/goquery"
|
"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
|
// ListOptions configures the pagination of a simple iterator method like
|
||||||
// [Client.Gallery] or [Client.Notes]. Filtered iterators ([Client.Search],
|
// [Client.Gallery] or [Client.Notes]. Filtered iterators ([Client.Search],
|
||||||
// [Client.Browse]) use their own option structs that fold the same fields
|
// [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
|
// FA's beta theme renders pagination as either a Next form button or a
|
||||||
// hyperlink with a recognisable label.
|
// hyperlink with a recognisable label.
|
||||||
func detectNextPage(doc *goquery.Document) bool {
|
func detectNextPage(doc *goquery.Document) bool {
|
||||||
if doc.Find("form button.button.standard:contains('Next')").Length() > 0 {
|
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
|
return true
|
||||||
}
|
}
|
||||||
hit := false
|
action, _ = f.Attr("action")
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if action != "" {
|
||||||
|
return action, true
|
||||||
|
}
|
||||||
|
var href string
|
||||||
doc.Find("a.button.standard, a.button-link, a.pagination-next").EachWithBreak(func(_ int, sel *goquery.Selection) bool {
|
doc.Find("a.button.standard, a.button-link, a.pagination-next").EachWithBreak(func(_ int, sel *goquery.Selection) bool {
|
||||||
text := strings.ToLower(trimText(sel))
|
text := strings.ToLower(trimText(sel))
|
||||||
if strings.Contains(text, "next") || strings.Contains(text, "older") {
|
if strings.Contains(text, "next") || strings.Contains(text, "older") {
|
||||||
hit = true
|
href, _ = sel.Attr("href")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
return hit
|
if href == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return href, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// favoritesCursorFromURL extracts the fave-ID cursor segment from a
|
||||||
|
// /favorites/{user}/{cursor}/next URL. Returns "" if the URL does not
|
||||||
|
// match that shape (in which case the caller treats the listing as
|
||||||
|
// exhausted rather than chasing a malformed cursor).
|
||||||
|
func favoritesCursorFromURL(rawURL string) string {
|
||||||
|
// Strip query / fragment, then split. Favorites paths can be relative
|
||||||
|
// ("/favorites/u/123/next") or absolute — handle both.
|
||||||
|
rawURL = strings.TrimPrefix(rawURL, "https://www.furaffinity.net")
|
||||||
|
rawURL = strings.TrimPrefix(rawURL, "http://www.furaffinity.net")
|
||||||
|
if i := strings.IndexAny(rawURL, "?#"); i >= 0 {
|
||||||
|
rawURL = rawURL[:i]
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.Trim(rawURL, "/"), "/")
|
||||||
|
// Expect ["favorites", "{user}", "{cursor}", "next"].
|
||||||
|
if len(parts) != 4 || parts[0] != "favorites" || parts[3] != "next" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parts[2]
|
||||||
}
|
}
|
||||||
|
|||||||
98
request_options.go
Normal file
98
request_options.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reqOverrideKey scopes the per-request override on a context. The
|
||||||
|
// transport reads it; nothing outside this package can synthesize one.
|
||||||
|
type reqOverrideKey struct{}
|
||||||
|
|
||||||
|
// requestOverride is the resolved diff between the client's default
|
||||||
|
// config and the options passed to a single call. The transport applies
|
||||||
|
// it on every outbound request whose context carries one.
|
||||||
|
//
|
||||||
|
// Only request-level fields appear here. Client-only fields (rate limiter,
|
||||||
|
// http.Client, retries, parser flags) are deliberately absent: passing the
|
||||||
|
// corresponding Option to a call is a silent no-op, which is documented on
|
||||||
|
// each Option.
|
||||||
|
type requestOverride struct {
|
||||||
|
cookies Cookies
|
||||||
|
cf CFCookies
|
||||||
|
sfw SFWMode
|
||||||
|
userAgent string
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyRequestOptions resolves per-call options on top of the client's
|
||||||
|
// config and, if any request-level field actually changed, returns a
|
||||||
|
// context carrying a *requestOverride for the transport to read. When no
|
||||||
|
// options are passed or none of them touch request-level fields, the
|
||||||
|
// original context is returned unchanged so the hot path stays
|
||||||
|
// allocation-free.
|
||||||
|
func (c *Client) applyRequestOptions(ctx context.Context, opts []Option) context.Context {
|
||||||
|
if len(opts) == 0 {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
cfg := c.cfg
|
||||||
|
for _, o := range opts {
|
||||||
|
if o != nil {
|
||||||
|
o(&cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.cookies == c.cfg.cookies &&
|
||||||
|
cfg.cf == c.cfg.cf &&
|
||||||
|
cfg.sfw == c.cfg.sfw &&
|
||||||
|
cfg.userAgent == c.cfg.userAgent {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, reqOverrideKey{}, &requestOverride{
|
||||||
|
cookies: cfg.cookies,
|
||||||
|
cf: cfg.cf,
|
||||||
|
sfw: cfg.sfw,
|
||||||
|
userAgent: cfg.userAgent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestOverrideFrom extracts the override the transport should apply
|
||||||
|
// to this request, or nil if none was attached.
|
||||||
|
func requestOverrideFrom(ctx context.Context) *requestOverride {
|
||||||
|
if ctx == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ov, _ := ctx.Value(reqOverrideKey{}).(*requestOverride)
|
||||||
|
return ov
|
||||||
|
}
|
||||||
|
|
||||||
|
// touchesCookies reports whether this override should replace the Cookie
|
||||||
|
// header. A UA-only override leaves the header (and thus the jar's
|
||||||
|
// cookies) alone.
|
||||||
|
func (o *requestOverride) touchesCookies() bool {
|
||||||
|
return o.cookies.A != "" ||
|
||||||
|
o.cookies.B != "" ||
|
||||||
|
o.cf.Clearance != "" ||
|
||||||
|
o.sfw != SFWAuto
|
||||||
|
}
|
||||||
|
|
||||||
|
// cookieHeader renders the override's cookies into a single Cookie
|
||||||
|
// header value. The transport writes this verbatim, replacing whatever
|
||||||
|
// the shared cookie jar produced from c.http's jar.
|
||||||
|
func (o *requestOverride) cookieHeader() string {
|
||||||
|
var parts []string
|
||||||
|
if o.cookies.A != "" {
|
||||||
|
parts = append(parts, "a="+o.cookies.A)
|
||||||
|
}
|
||||||
|
if o.cookies.B != "" {
|
||||||
|
parts = append(parts, "b="+o.cookies.B)
|
||||||
|
}
|
||||||
|
if o.cf.Clearance != "" {
|
||||||
|
parts = append(parts, "cf_clearance="+o.cf.Clearance)
|
||||||
|
}
|
||||||
|
switch o.sfw {
|
||||||
|
case SFWOn:
|
||||||
|
parts = append(parts, "sfw=1")
|
||||||
|
case SFWOff:
|
||||||
|
parts = append(parts, "sfw=0")
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "; ")
|
||||||
|
}
|
||||||
138
request_options_test.go
Normal file
138
request_options_test.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRequestOverride_PerCallCookiesAndUA exercises the multi-tenant flow:
|
||||||
|
// one Client built with default creds, two calls in a row each carrying a
|
||||||
|
// different user's cookies via per-call Options. The server records the
|
||||||
|
// Cookie + User-Agent headers it saw on each request; the per-call values
|
||||||
|
// must override the client's, and the client's defaults must come back on
|
||||||
|
// a call that passes no overrides.
|
||||||
|
func TestRequestOverride_PerCallCookiesAndUA(t *testing.T) {
|
||||||
|
type seen struct {
|
||||||
|
cookie string
|
||||||
|
userAgent string
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
hits []seen
|
||||||
|
)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/view/1/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
hits = append(hits, seen{cookie: r.Header.Get("Cookie"), userAgent: r.Header.Get("User-Agent")})
|
||||||
|
mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
_, _ = w.Write([]byte(syntheticSubmissionHTML))
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Client built with default ("system") creds plus a default UA.
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
// Layer the client default cookies in via WithCookies so we can verify
|
||||||
|
// they appear when no override is passed.
|
||||||
|
clientWithDefaults := New(
|
||||||
|
WithHTTPClient(client.http),
|
||||||
|
WithRateLimit(0, 16),
|
||||||
|
WithMaxRetries(0),
|
||||||
|
WithUserAgent("default-ua/1.0"),
|
||||||
|
WithCookies(Cookies{A: "defaultA", B: "defaultB"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Call 1: no override → client defaults must apply.
|
||||||
|
if _, err := clientWithDefaults.GetSubmission(ctx, 1); err != nil {
|
||||||
|
t.Fatalf("call 1: %v", err)
|
||||||
|
}
|
||||||
|
// Call 2: per-user override (user-A creds + user-A UA).
|
||||||
|
if _, err := clientWithDefaults.GetSubmission(ctx, 1,
|
||||||
|
WithCookies(Cookies{A: "userA_a", B: "userA_b"}),
|
||||||
|
WithCloudflare(CFCookies{Clearance: "userA_cf"}),
|
||||||
|
WithUserAgent("userA-browser/1.0"),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("call 2: %v", err)
|
||||||
|
}
|
||||||
|
// Call 3: a different user.
|
||||||
|
if _, err := clientWithDefaults.GetSubmission(ctx, 1,
|
||||||
|
WithCookies(Cookies{A: "userB_a", B: "userB_b"}),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("call 3: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if len(hits) != 3 {
|
||||||
|
t.Fatalf("hits = %d; want 3", len(hits))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call 1: default cookies + default UA.
|
||||||
|
if !strings.Contains(hits[0].cookie, "a=defaultA") || !strings.Contains(hits[0].cookie, "b=defaultB") {
|
||||||
|
t.Errorf("call 1 cookie = %q; want default a/b", hits[0].cookie)
|
||||||
|
}
|
||||||
|
if hits[0].userAgent != "default-ua/1.0" {
|
||||||
|
t.Errorf("call 1 UA = %q; want default-ua/1.0", hits[0].userAgent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call 2: user-A creds replace the jar's defaults wholesale.
|
||||||
|
if !strings.Contains(hits[1].cookie, "a=userA_a") || !strings.Contains(hits[1].cookie, "b=userA_b") {
|
||||||
|
t.Errorf("call 2 cookie = %q; want user-A a/b", hits[1].cookie)
|
||||||
|
}
|
||||||
|
if !strings.Contains(hits[1].cookie, "cf_clearance=userA_cf") {
|
||||||
|
t.Errorf("call 2 cookie missing cf_clearance: %q", hits[1].cookie)
|
||||||
|
}
|
||||||
|
if strings.Contains(hits[1].cookie, "defaultA") || strings.Contains(hits[1].cookie, "defaultB") {
|
||||||
|
t.Errorf("call 2 cookie leaked client defaults: %q", hits[1].cookie)
|
||||||
|
}
|
||||||
|
if hits[1].userAgent != "userA-browser/1.0" {
|
||||||
|
t.Errorf("call 2 UA = %q; want userA-browser/1.0", hits[1].userAgent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call 3: user-B cookies override, but UA falls back to client default
|
||||||
|
// because the override did not touch it.
|
||||||
|
if !strings.Contains(hits[2].cookie, "a=userB_a") || !strings.Contains(hits[2].cookie, "b=userB_b") {
|
||||||
|
t.Errorf("call 3 cookie = %q; want user-B a/b", hits[2].cookie)
|
||||||
|
}
|
||||||
|
if hits[2].userAgent != "default-ua/1.0" {
|
||||||
|
t.Errorf("call 3 UA = %q; want default-ua/1.0 (no override)", hits[2].userAgent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequestOverride_NoOverrideMeansNoCtxValue is a cheap sanity check
|
||||||
|
// that applyRequestOptions short-circuits when nothing request-level
|
||||||
|
// actually changed (e.g. caller passed only client-only options).
|
||||||
|
func TestRequestOverride_NoOverrideMeansNoCtxValue(t *testing.T) {
|
||||||
|
c := New(WithCookies(Cookies{A: "x", B: "y"}))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// No options at all → same ctx.
|
||||||
|
if got := c.applyRequestOptions(ctx, nil); got != ctx {
|
||||||
|
t.Error("nil opts should pass through ctx unchanged")
|
||||||
|
}
|
||||||
|
// A client-only option that does not touch request-level fields.
|
||||||
|
got := c.applyRequestOptions(ctx, []Option{WithMaxRetries(7)})
|
||||||
|
if got != ctx {
|
||||||
|
t.Error("client-only option should not attach a request override")
|
||||||
|
}
|
||||||
|
// A request-level option that happens to equal the current value.
|
||||||
|
got = c.applyRequestOptions(ctx, []Option{WithCookies(Cookies{A: "x", B: "y"})})
|
||||||
|
if got != ctx {
|
||||||
|
t.Error("override matching client config should not attach")
|
||||||
|
}
|
||||||
|
// A real override.
|
||||||
|
got = c.applyRequestOptions(ctx, []Option{WithCookies(Cookies{A: "z", B: "w"})})
|
||||||
|
if got == ctx {
|
||||||
|
t.Error("real override should produce a new ctx")
|
||||||
|
}
|
||||||
|
if ov := requestOverrideFrom(got); ov == nil || ov.cookies.A != "z" {
|
||||||
|
t.Errorf("override ctx missing or wrong: %+v", ov)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
scripts/refresh-user-fixture.sh
Executable file
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 ./...
|
||||||
@@ -115,7 +115,7 @@ type SearchOptions struct {
|
|||||||
// Search works anonymously for most queries; some adult-content searches
|
// Search works anonymously for most queries; some adult-content searches
|
||||||
// require login and will surface as [ErrUnauthorized] via the system-
|
// require login and will surface as [ErrUnauthorized] via the system-
|
||||||
// message classifier.
|
// message classifier.
|
||||||
func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) iter.Seq2[*Submission, error] {
|
func (c *Client) Search(ctx context.Context, query string, opts SearchOptions, reqOpts ...Option) iter.Seq2[*Submission, error] {
|
||||||
return func(yield func(*Submission, error) bool) {
|
return func(yield func(*Submission, error) bool) {
|
||||||
page := opts.StartPage
|
page := opts.StartPage
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
@@ -134,7 +134,7 @@ func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) i
|
|||||||
err := c.fetch(ctx, pageURL, func(doc *goquery.Document) error {
|
err := c.fetch(ctx, pageURL, func(doc *goquery.Document) error {
|
||||||
items, hasNext = parseSearchResults(doc, c.cfg.jsonListings)
|
items, hasNext = parseSearchResults(doc, c.cfg.jsonListings)
|
||||||
return nil
|
return nil
|
||||||
})
|
}, reqOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
yield(nil, err)
|
yield(nil, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ import (
|
|||||||
"git.anthrove.art/public/go-fa-api/internal/urls"
|
"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}/.
|
// Submission is a fully resolved FA submission as seen on /view/{id}/.
|
||||||
type Submission struct {
|
type Submission struct {
|
||||||
ID SubmissionID
|
ID SubmissionID
|
||||||
@@ -26,7 +35,20 @@ type Submission struct {
|
|||||||
Gender Gender
|
Gender Gender
|
||||||
Description string // raw HTML; sanitise before rendering to a browser
|
Description string // raw HTML; sanitise before rendering to a browser
|
||||||
DescriptionText string // plaintext convenience
|
DescriptionText string // plaintext convenience
|
||||||
|
// 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
|
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
|
FileURL string // absolute CDN URL; pass to Download
|
||||||
ThumbURL string
|
ThumbURL string
|
||||||
Width int // 0 if unknown / non-image
|
Width int // 0 if unknown / non-image
|
||||||
@@ -47,7 +69,7 @@ type Submission struct {
|
|||||||
// Returns [ErrNotFound] if FA renders a "submission not found" system message,
|
// Returns [ErrNotFound] if FA renders a "submission not found" system message,
|
||||||
// [ErrUnauthorized] for restricted-visibility submissions when called
|
// [ErrUnauthorized] for restricted-visibility submissions when called
|
||||||
// without valid cookies, or a wrapped parse error if the markup has shifted.
|
// without valid cookies, or a wrapped parse error if the markup has shifted.
|
||||||
func (c *Client) GetSubmission(ctx context.Context, id SubmissionID) (*Submission, error) {
|
func (c *Client) GetSubmission(ctx context.Context, id SubmissionID, opts ...Option) (*Submission, error) {
|
||||||
if id <= 0 {
|
if id <= 0 {
|
||||||
return nil, fmt.Errorf("fa: GetSubmission: id must be > 0")
|
return nil, fmt.Errorf("fa: GetSubmission: id must be > 0")
|
||||||
}
|
}
|
||||||
@@ -59,7 +81,7 @@ func (c *Client) GetSubmission(ctx context.Context, id SubmissionID) (*Submissio
|
|||||||
}
|
}
|
||||||
out = s
|
out = s
|
||||||
return nil
|
return nil
|
||||||
})
|
}, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -72,13 +94,14 @@ func (c *Client) GetSubmission(ctx context.Context, id SubmissionID) (*Submissio
|
|||||||
//
|
//
|
||||||
// Returns the number of bytes written. Errors from the writer are wrapped
|
// Returns the number of bytes written. Errors from the writer are wrapped
|
||||||
// as-is; HTTP errors come back as [*HTTPError].
|
// as-is; HTTP errors come back as [*HTTPError].
|
||||||
func (c *Client) Download(ctx context.Context, sub *Submission, w io.Writer) (int64, error) {
|
func (c *Client) Download(ctx context.Context, sub *Submission, w io.Writer, opts ...Option) (int64, error) {
|
||||||
if sub == nil {
|
if sub == nil {
|
||||||
return 0, errors.New("fa: Download: nil submission")
|
return 0, errors.New("fa: Download: nil submission")
|
||||||
}
|
}
|
||||||
if sub.FileURL == "" {
|
if sub.FileURL == "" {
|
||||||
return 0, errors.New("fa: Download: submission has no FileURL")
|
return 0, errors.New("fa: Download: submission has no FileURL")
|
||||||
}
|
}
|
||||||
|
ctx = c.applyRequestOptions(ctx, opts)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sub.FileURL, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sub.FileURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
|
|||||||
@@ -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
|
// File URL FA renders a "Download" button in #submission-options that
|
||||||
// links to the canonical file for *every* submission type. For visual
|
// links to the canonical file for *every* submission type. For visual
|
||||||
// art it equals the #submissionImg source; for stories and music it's
|
// art it equals the #submissionImg source; for stories and music it's
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ const syntheticSubmissionHTML = `<html><body>
|
|||||||
<div>
|
<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 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);" 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -110,6 +114,34 @@ func TestParseSubmission_Synthetic(t *testing.T) {
|
|||||||
if !strings.Contains(sub.Description, "world") {
|
if !strings.Contains(sub.Description, "world") {
|
||||||
t.Errorf("Description missing expected content: %q", sub.Description)
|
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
|
// TestParseSubmission_FavoritedState verifies parseSubmission reports the
|
||||||
|
|||||||
1287
testdata/html/browse.html
vendored
1287
testdata/html/browse.html
vendored
File diff suppressed because one or more lines are too long
3229
testdata/html/comments_submission.html
vendored
3229
testdata/html/comments_submission.html
vendored
File diff suppressed because one or more lines are too long
1802
testdata/html/favorites_page1.html
vendored
1802
testdata/html/favorites_page1.html
vendored
File diff suppressed because one or more lines are too long
696
testdata/html/gallery_page1.html
vendored
696
testdata/html/gallery_page1.html
vendored
File diff suppressed because one or more lines are too long
1191
testdata/html/gallery_page_last.html
vendored
1191
testdata/html/gallery_page_last.html
vendored
File diff suppressed because one or more lines are too long
309
testdata/html/journals_listing_page1.html
vendored
309
testdata/html/journals_listing_page1.html
vendored
File diff suppressed because one or more lines are too long
1597
testdata/html/msg_others.html
vendored
1597
testdata/html/msg_others.html
vendored
File diff suppressed because it is too large
Load Diff
1762
testdata/html/msg_pms.html
vendored
1762
testdata/html/msg_pms.html
vendored
File diff suppressed because it is too large
Load Diff
2235
testdata/html/msg_submissions.html
vendored
2235
testdata/html/msg_submissions.html
vendored
File diff suppressed because one or more lines are too long
382
testdata/html/note_view.html
vendored
382
testdata/html/note_view.html
vendored
@@ -67,7 +67,12 @@
|
|||||||
|
|
||||||
<!-- EU request: yes -->
|
<!-- EU request: yes -->
|
||||||
<body class="c-bodyColor"
|
<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">
|
<script type="text/javascript">
|
||||||
@@ -104,6 +109,11 @@
|
|||||||
<div class="mobile-nav-content-container">
|
<div class="mobile-nav-content-container">
|
||||||
|
|
||||||
<div class="aligncenter">
|
<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="/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>
|
<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 />
|
<br />
|
||||||
@@ -111,6 +121,7 @@
|
|||||||
<hr>
|
<hr>
|
||||||
<h2><a href="/browse/">Browse</a></h2>
|
<h2><a href="/browse/">Browse</a></h2>
|
||||||
<h2><a href="/search/">Search</a></h2>
|
<h2><a href="/search/">Search</a></h2>
|
||||||
|
<h2><a href="/submit/">Upload</a></h2>
|
||||||
|
|
||||||
<div class="nav-ac-container">
|
<div class="nav-ac-container">
|
||||||
<label for="mobile-menu-submenu-0"><h2 style="margin-top:0;padding-top:0">Support ▼</h2></label>
|
<label for="mobile-menu-submenu-0"><h2 style="margin-top:0;padding-top:0">Support ▼</h2></label>
|
||||||
@@ -142,22 +153,65 @@
|
|||||||
|
|
||||||
<h3>SUPPORT</h3>
|
<h3>SUPPORT</h3>
|
||||||
<a href="/help/#contact">Contact Us</a><br />
|
<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>
|
<a href="https://status.furaffinity.net/">Site Status</a>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</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>
|
<hr>
|
||||||
|
|
||||||
<h2><div class="inline hideonmobile hideontablet">
|
<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>
|
||||||
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
|
<script type="text/javascript">
|
||||||
</div>
|
_fajs.push(['init_logout_button', '.logout-link button']);
|
||||||
|
</script>
|
||||||
<div class="inline hideondesktop">
|
|
||||||
<a href="/login">Log In</a><br>
|
|
||||||
<a href="/register">Create an Account</a>
|
|
||||||
</div>
|
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
|
||||||
@@ -170,6 +224,11 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<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>
|
<h3>Support</h3>
|
||||||
<a href="/help/#contact">Contact Us</a>
|
<a href="/help/#contact">Contact Us</a>
|
||||||
|
<a href="/controls/troubletickets/">Report a Problem</a>
|
||||||
<a href="https://status.furaffinity.net/">Site Status</a>
|
<a href="https://status.furaffinity.net/">Site Status</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -254,16 +314,96 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<li class="no-sub">
|
<li class="message-bar-desktop">
|
||||||
<span class="top-heading"><div class="inline hideonmobile hideontablet">
|
<a class="notification-container inline" href="/msg/submissions/" title="5,161 Submission Notifications">5161S</a>
|
||||||
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
|
<a class="notification-container inline" href="/msg/others/#journals" title="75 Journal Notifications">75J</a>
|
||||||
</div>
|
</li>
|
||||||
|
|
||||||
<div class="inline hideondesktop">
|
<li>
|
||||||
<a href="/login">Log In</a><br>
|
<div class="floatleft hideonmobile">
|
||||||
<a href="/register">Create an Account</a>
|
<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>
|
</div>
|
||||||
</span>
|
</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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
@@ -291,6 +431,15 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="news-block">
|
<div class="news-block">
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<div id="main-window" class="footer-mobile-tweak g-wrapper">
|
<div id="main-window" class="footer-mobile-tweak g-wrapper">
|
||||||
@@ -298,18 +447,10 @@
|
|||||||
<div id="header">
|
<div id="header">
|
||||||
<!-- site banner -->
|
<!-- site banner -->
|
||||||
<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="/">
|
<a href="/">
|
||||||
<picture>
|
<picture>
|
||||||
<source srcset="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.webp" type="image/webp">
|
<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-vgen-20260501.jpg">
|
<img usemap="#banner-map" src="//d.furaffinity.net/media/banners/modern/fa-banner-spring-furality-20260531.jpg">
|
||||||
</picture>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
</site-banner>
|
</site-banner>
|
||||||
@@ -320,23 +461,174 @@
|
|||||||
<div id="site-content">
|
<div id="site-content">
|
||||||
<!-- /header -->
|
<!-- /header -->
|
||||||
|
|
||||||
<!-- {redirect} -->
|
|
||||||
<div id="standardpage">
|
|
||||||
|
|
||||||
<section class="aligncenter notice-message user-submitted-links">
|
<div id="message">
|
||||||
<div class="section-body alignleft">
|
<section>
|
||||||
<h2>System Message</h2>
|
<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">
|
||||||
|
|
||||||
<div class="redirect-message">Please log in!</div>
|
<a class="c-usernameBlock__displayName js-displayName-block" href="/user/vampexx/">
|
||||||
|
<span class="js-displayName">Vampexx</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<div class="proceed-btn-container">
|
<a class="c-usernameBlock__userName js-userName-block" href="/user/vampexx/">
|
||||||
<a class="button standard go" href="/login/">Continue »</a>
|
<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>
|
||||||
|
|
||||||
|
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="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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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>
|
||||||
<!-- /<div id="site-content"> -->
|
<!-- /<div id="site-content"> -->
|
||||||
|
|
||||||
@@ -371,11 +663,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="online-stats">
|
<div class="online-stats">
|
||||||
88574 <strong><span title="Measured in the last 900 seconds">Users online</span></strong> —
|
91668 <strong><span title="Measured in the last 900 seconds">Users online</span></strong> —
|
||||||
3334 <strong>guests</strong>,
|
4823 <strong>guests</strong>,
|
||||||
8900 <strong>registered</strong>
|
14001 <strong>registered</strong>
|
||||||
and 76340 <strong>other</strong>
|
and 72844 <strong>other</strong>
|
||||||
<!-- Online Counter Last Update: Sun, 24 May 2026 04:31:01 -0700 -->
|
<!-- Online Counter Last Update: Tue, 02 Jun 2026 12:27:00 -0700 -->
|
||||||
</div>
|
</div>
|
||||||
<small>Limit bot activity to periods with less than 10k registered users online.</small>
|
<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>
|
<strong>© 2005-2026 Frost Dragon Art LLC</strong>
|
||||||
|
|
||||||
<div class="footnote">
|
<div class="footnote">
|
||||||
Server Time: May 24, 2026 04:31 AM<br />
|
Server Time: Jun 2, 2026 12:27 PM<br />
|
||||||
Page generated in 0.009 seconds<br />[ 26.5% PHP, 73.5% SQL ] (9 queries)<br />
|
Page generated in 0.018 seconds<br />[ 42.4% PHP, 57.6% SQL ] (24 queries)<br />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -415,7 +707,7 @@
|
|||||||
<script src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js" crossorigin="anonymous"></script>
|
<script src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
var server_timestamp = 1779622312;
|
var server_timestamp = 1780428442;
|
||||||
var client_timestamp = Date.now() / 1000;
|
var client_timestamp = Date.now() / 1000;
|
||||||
var server_timestamp_delta = server_timestamp - client_timestamp;
|
var server_timestamp_delta = server_timestamp - client_timestamp;
|
||||||
var sfw_cookie_name = 'sfw';
|
var sfw_cookie_name = 'sfw';
|
||||||
@@ -424,7 +716,7 @@
|
|||||||
//
|
//
|
||||||
document.addEventListener("DOMContentLoaded", (event) => {
|
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>
|
</script>
|
||||||
|
|||||||
205
testdata/html/scraps_page1.html
vendored
205
testdata/html/scraps_page1.html
vendored
@@ -84,6 +84,11 @@
|
|||||||
<!-- EU request: yes -->
|
<!-- EU request: yes -->
|
||||||
<body class="c-bodyColor"
|
<body class="c-bodyColor"
|
||||||
id="pageid-gallery" data-static-path="/themes/beta"
|
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">
|
<script type="text/javascript">
|
||||||
@@ -120,6 +125,11 @@
|
|||||||
<div class="mobile-nav-content-container">
|
<div class="mobile-nav-content-container">
|
||||||
|
|
||||||
<div class="aligncenter">
|
<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="/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>
|
<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 />
|
<br />
|
||||||
@@ -127,6 +137,7 @@
|
|||||||
<hr>
|
<hr>
|
||||||
<h2><a href="/browse/">Browse</a></h2>
|
<h2><a href="/browse/">Browse</a></h2>
|
||||||
<h2><a href="/search/">Search</a></h2>
|
<h2><a href="/search/">Search</a></h2>
|
||||||
|
<h2><a href="/submit/">Upload</a></h2>
|
||||||
|
|
||||||
<div class="nav-ac-container">
|
<div class="nav-ac-container">
|
||||||
<label for="mobile-menu-submenu-0"><h2 style="margin-top:0;padding-top:0">Support ▼</h2></label>
|
<label for="mobile-menu-submenu-0"><h2 style="margin-top:0;padding-top:0">Support ▼</h2></label>
|
||||||
@@ -158,22 +169,65 @@
|
|||||||
|
|
||||||
<h3>SUPPORT</h3>
|
<h3>SUPPORT</h3>
|
||||||
<a href="/help/#contact">Contact Us</a><br />
|
<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>
|
<a href="https://status.furaffinity.net/">Site Status</a>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</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>
|
<hr>
|
||||||
|
|
||||||
<h2><div class="inline hideonmobile hideontablet">
|
<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>
|
||||||
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
|
<script type="text/javascript">
|
||||||
</div>
|
_fajs.push(['init_logout_button', '.logout-link button']);
|
||||||
|
</script>
|
||||||
<div class="inline hideondesktop">
|
|
||||||
<a href="/login">Log In</a><br>
|
|
||||||
<a href="/register">Create an Account</a>
|
|
||||||
</div>
|
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
|
||||||
@@ -186,6 +240,11 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<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>
|
<h3>Support</h3>
|
||||||
<a href="/help/#contact">Contact Us</a>
|
<a href="/help/#contact">Contact Us</a>
|
||||||
|
<a href="/controls/troubletickets/">Report a Problem</a>
|
||||||
<a href="https://status.furaffinity.net/">Site Status</a>
|
<a href="https://status.furaffinity.net/">Site Status</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -270,16 +330,96 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<li class="no-sub">
|
<li class="message-bar-desktop">
|
||||||
<span class="top-heading"><div class="inline hideonmobile hideontablet">
|
<a class="notification-container inline" href="/msg/submissions/" title="5,161 Submission Notifications">5161S</a>
|
||||||
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
|
<a class="notification-container inline" href="/msg/others/#journals" title="75 Journal Notifications">75J</a>
|
||||||
</div>
|
</li>
|
||||||
|
|
||||||
<div class="inline hideondesktop">
|
<li>
|
||||||
<a href="/login">Log In</a><br>
|
<div class="floatleft hideonmobile">
|
||||||
<a href="/register">Create an Account</a>
|
<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>
|
</div>
|
||||||
</span>
|
</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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
@@ -307,6 +447,15 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="news-block">
|
<div class="news-block">
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<div id="main-window" class="footer-mobile-tweak g-wrapper">
|
<div id="main-window" class="footer-mobile-tweak g-wrapper">
|
||||||
@@ -357,7 +506,7 @@
|
|||||||
|
|
||||||
<div class="font-small">
|
<div class="font-small">
|
||||||
<span class="user-title">
|
<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>
|
</div>
|
||||||
|
|
||||||
<userpage-nav-links>
|
<userpage-nav-links>
|
||||||
@@ -377,7 +526,7 @@
|
|||||||
|
|
||||||
|
|
||||||
<userpage-nav-interface-buttons>
|
<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>
|
<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>
|
</userpage-nav-interface-buttons>
|
||||||
@@ -584,11 +733,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="online-stats">
|
<div class="online-stats">
|
||||||
88574 <strong><span title="Measured in the last 900 seconds">Users online</span></strong> —
|
91668 <strong><span title="Measured in the last 900 seconds">Users online</span></strong> —
|
||||||
3334 <strong>guests</strong>,
|
4823 <strong>guests</strong>,
|
||||||
8900 <strong>registered</strong>
|
14001 <strong>registered</strong>
|
||||||
and 76340 <strong>other</strong>
|
and 72844 <strong>other</strong>
|
||||||
<!-- Online Counter Last Update: Sun, 24 May 2026 04:31:01 -0700 -->
|
<!-- Online Counter Last Update: Tue, 02 Jun 2026 12:27:00 -0700 -->
|
||||||
</div>
|
</div>
|
||||||
<small>Limit bot activity to periods with less than 10k registered users online.</small>
|
<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>
|
<strong>© 2005-2026 Frost Dragon Art LLC</strong>
|
||||||
|
|
||||||
<div class="footnote">
|
<div class="footnote">
|
||||||
Server Time: May 24, 2026 04:31 AM<br />
|
Server Time: Jun 2, 2026 12:27 PM<br />
|
||||||
Page generated in 0.015 seconds<br />[ 38.7% PHP, 61.3% SQL ] (21 queries)<br />
|
Page generated in 0.025 seconds<br />[ 24.9% PHP, 75.1% SQL ] (29 queries)<br />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -628,7 +777,7 @@
|
|||||||
<script src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js" crossorigin="anonymous"></script>
|
<script src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
var server_timestamp = 1779622302;
|
var server_timestamp = 1780428434;
|
||||||
var client_timestamp = Date.now() / 1000;
|
var client_timestamp = Date.now() / 1000;
|
||||||
var server_timestamp_delta = server_timestamp - client_timestamp;
|
var server_timestamp_delta = server_timestamp - client_timestamp;
|
||||||
var sfw_cookie_name = 'sfw';
|
var sfw_cookie_name = 'sfw';
|
||||||
@@ -637,7 +786,7 @@
|
|||||||
//
|
//
|
||||||
document.addEventListener("DOMContentLoaded", (event) => {
|
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>
|
</script>
|
||||||
|
|||||||
1237
testdata/html/search_results.html
vendored
1237
testdata/html/search_results.html
vendored
File diff suppressed because one or more lines are too long
3229
testdata/html/submission.html
vendored
3229
testdata/html/submission.html
vendored
File diff suppressed because one or more lines are too long
1776
testdata/html/user.html
vendored
1776
testdata/html/user.html
vendored
File diff suppressed because one or more lines are too long
11
transport.go
11
transport.go
@@ -32,6 +32,17 @@ const defaultMaxRetries = 3
|
|||||||
// injects the User-Agent header, retries on transient failures, and
|
// injects the User-Agent header, retries on transient failures, and
|
||||||
// classifies Cloudflare challenges as non-retryable user-actionable errors.
|
// classifies Cloudflare challenges as non-retryable user-actionable errors.
|
||||||
func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
if ov := requestOverrideFrom(req.Context()); ov != nil {
|
||||||
|
if ov.userAgent != "" {
|
||||||
|
req.Header.Set("User-Agent", ov.userAgent)
|
||||||
|
}
|
||||||
|
if ov.touchesCookies() {
|
||||||
|
// The cookie jar already wrote a Cookie header by the time we
|
||||||
|
// reach RoundTrip; replace it wholesale so per-user creds win
|
||||||
|
// over whatever the shared jar would inject.
|
||||||
|
req.Header.Set("Cookie", ov.cookieHeader())
|
||||||
|
}
|
||||||
|
}
|
||||||
if t.userAgent != "" && req.Header.Get("User-Agent") == "" {
|
if t.userAgent != "" && req.Header.Get("User-Agent") == "" {
|
||||||
req.Header.Set("User-Agent", t.userAgent)
|
req.Header.Set("User-Agent", t.userAgent)
|
||||||
}
|
}
|
||||||
|
|||||||
4
user.go
4
user.go
@@ -33,7 +33,7 @@ type User struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetUser fetches a user profile by URL-safe name (FA's lowercase login form).
|
// GetUser fetches a user profile by URL-safe name (FA's lowercase login form).
|
||||||
func (c *Client) GetUser(ctx context.Context, name string) (*User, error) {
|
func (c *Client) GetUser(ctx context.Context, name string, opts ...Option) (*User, error) {
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return nil, fmt.Errorf("fa: GetUser: empty name")
|
return nil, fmt.Errorf("fa: GetUser: empty name")
|
||||||
@@ -46,7 +46,7 @@ func (c *Client) GetUser(ctx context.Context, name string) (*User, error) {
|
|||||||
}
|
}
|
||||||
out = u
|
out = u
|
||||||
return nil
|
return nil
|
||||||
})
|
}, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,8 +148,8 @@ func TestParseUser_RealFixture(t *testing.T) {
|
|||||||
if u.Stats.Favorites != 180 {
|
if u.Stats.Favorites != 180 {
|
||||||
t.Errorf("Stats.Favorites = %d; want 180", u.Stats.Favorites)
|
t.Errorf("Stats.Favorites = %d; want 180", u.Stats.Favorites)
|
||||||
}
|
}
|
||||||
if u.Stats.Views != 1176 {
|
if u.Stats.Views != 1184 {
|
||||||
t.Errorf("Stats.Views = %d; want 1176", u.Stats.Views)
|
t.Errorf("Stats.Views = %d; want 1184", u.Stats.Views)
|
||||||
}
|
}
|
||||||
if u.Stats.Comments != 85 {
|
if u.Stats.Comments != 85 {
|
||||||
t.Errorf("Stats.Comments = %d; want 85", u.Stats.Comments)
|
t.Errorf("Stats.Comments = %d; want 85", u.Stats.Comments)
|
||||||
|
|||||||
Reference in New Issue
Block a user