FA's /favorites/{user}/ pagination is cursor-addressed by the fave-ID
of the last item on the previous page (e.g.
/favorites/{user}/1951234825/next), not by sequential integers. The
previous URL builder generated /favorites/{user}/{N}/ for N>=2; FA
interpreted that as a malformed cursor and silently returned page 1,
which caused the Favorites iterator to loop forever and the new
FavoritesPage to report HasNext=true on every call.
Changes:
- urls.Favorites(name) returns the first-page URL; new
urls.FavoritesCursor(name, cursor) builds /favorites/.../next URLs.
- FavoritesPage now takes a cursor string; empty = first page.
Returns ListingPage.NextPage as the opaque fave-ID for the next call.
- ListingPage gains NextPage string (decimal page number for
Gallery/Scraps, fave-ID cursor for Favorites) and drops the Page int
field that conflated those two notions.
- Client.Favorites iterator now walks cursors internally; StartPage
is ignored for favorites (documented).
- detectNextPage / nextPageURL now parse the form action so the same
helper works for both page-number and cursor pagination.
- Added regression test that fails on the infinite-loop bug.
- Example: examples/favorites_page demonstrates cursor walking.
163 lines
5.2 KiB
Go
163 lines
5.2 KiB
Go
// Package urls is the single source of truth for every FA URL the SDK
|
|
// constructs. Centralising route building here keeps fragile path
|
|
// concatenation out of the public API and makes the site's URL scheme
|
|
// trivial to swap (e.g., were FA to move endpoints).
|
|
package urls
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Host is the canonical FA host. Exported so callers can override for
|
|
// proxies or local mirrors, but the production value is what every
|
|
// builder below uses.
|
|
const Host = "https://www.furaffinity.net"
|
|
|
|
// Submission returns the canonical URL for viewing a submission.
|
|
func Submission(id int64) string {
|
|
return Host + "/view/" + strconv.FormatInt(id, 10) + "/"
|
|
}
|
|
|
|
// User returns the URL for a user's profile page.
|
|
func User(name string) string {
|
|
return Host + "/user/" + safeName(name) + "/"
|
|
}
|
|
|
|
// Gallery returns the URL for a user's main gallery page.
|
|
func Gallery(name string, page int) string {
|
|
return Host + "/gallery/" + safeName(name) + "/" + pageSegment(page)
|
|
}
|
|
|
|
// Scraps returns the URL for a user's scraps page.
|
|
func Scraps(name string, page int) string {
|
|
return Host + "/scraps/" + safeName(name) + "/" + pageSegment(page)
|
|
}
|
|
|
|
// Favorites returns the URL for the first page of a user's favorites.
|
|
// FA paginates favorites with a fave-ID cursor (see [FavoritesCursor]),
|
|
// not sequential page numbers — passing /favorites/{user}/{N}/ with a
|
|
// small integer N silently falls back to the first page. Use this for
|
|
// the first page only; follow the cursor returned in [ListingPage].NextPage
|
|
// for subsequent pages.
|
|
func Favorites(name string) string {
|
|
return Host + "/favorites/" + safeName(name) + "/"
|
|
}
|
|
|
|
// FavoritesCursor returns the URL for a follow-up favorites page,
|
|
// addressed by the fave-ID cursor FA emits on the previous page's "Next"
|
|
// form (e.g. /favorites/{user}/1951234825/next). The cursor is opaque
|
|
// to the SDK — pass through whatever [ListingPage].NextPage gave you.
|
|
func FavoritesCursor(name, cursor string) string {
|
|
if cursor == "" {
|
|
return Favorites(name)
|
|
}
|
|
return Host + "/favorites/" + safeName(name) + "/" + cursor + "/next"
|
|
}
|
|
|
|
// Journal returns the URL for a single journal entry.
|
|
func Journal(id int64) string {
|
|
return Host + "/journal/" + strconv.FormatInt(id, 10) + "/"
|
|
}
|
|
|
|
// UserJournals returns the URL for a user's journals listing.
|
|
func UserJournals(name string, page int) string {
|
|
return Host + "/journals/" + safeName(name) + "/" + pageSegment(page)
|
|
}
|
|
|
|
// MsgSubmissions returns the URL for the new-submission inbox. Requires auth.
|
|
func MsgSubmissions() string {
|
|
return Host + "/msg/submissions/"
|
|
}
|
|
|
|
// InboxPageSize is FA's fixed page size for the submission inbox its
|
|
// pagination links and "Next N" label are always built around 72 items.
|
|
const InboxPageSize = 72
|
|
|
|
// MsgSubmissionsCursor returns the URL for the new-submission inbox page
|
|
// that begins just below submission id FA's "new~{id}@72" cursor scheme.
|
|
// Used to keep crawling when FA omits the rendered "Next 72" link.
|
|
func MsgSubmissionsCursor(id int64) string {
|
|
return Host + "/msg/submissions/new~" +
|
|
strconv.FormatInt(id, 10) + "@" +
|
|
strconv.Itoa(InboxPageSize) + "/"
|
|
}
|
|
|
|
// MsgOthers returns the URL for the watch/journal/comment/fav notifications
|
|
// page. Requires auth.
|
|
func MsgOthers() string {
|
|
return Host + "/msg/others/"
|
|
}
|
|
|
|
// MsgPMs returns the URL for the private-message inbox. Requires auth.
|
|
func MsgPMs() string {
|
|
return Host + "/msg/pms/"
|
|
}
|
|
|
|
// ViewMessage returns the URL for a single private message (note) by ID.
|
|
// Requires auth.
|
|
func ViewMessage(id int64) string {
|
|
return Host + "/viewmessage/" + strconv.FormatInt(id, 10) + "/"
|
|
}
|
|
|
|
// Search returns the URL for a keyword search. FA accepts the query string
|
|
// directly; pagination is a query param rather than a path segment.
|
|
func Search(query string, page int) string {
|
|
u := Host + "/search/"
|
|
q := url.Values{}
|
|
q.Set("q", query)
|
|
if page > 1 {
|
|
q.Set("page", strconv.Itoa(page))
|
|
}
|
|
if e := q.Encode(); e != "" {
|
|
u += "?" + e
|
|
}
|
|
return u
|
|
}
|
|
|
|
// Browse returns the URL for /browse/ with optional page index. FA's
|
|
// browse UI navigates via POST forms, but a GET with ?page=N is honoured
|
|
// for the rendered results page, which is all this SDK needs.
|
|
func Browse(page int) string {
|
|
if page <= 1 {
|
|
return Host + "/browse/"
|
|
}
|
|
return Host + "/browse/?page=" + strconv.Itoa(page)
|
|
}
|
|
|
|
// safeName lower-cases and URL-escapes a username segment. FA folds names
|
|
// to lowercase for URL routing.
|
|
func safeName(name string) string {
|
|
return url.PathEscape(strings.ToLower(strings.TrimSpace(name)))
|
|
}
|
|
|
|
// pageSegment renders a 1-based page index as a trailing path segment.
|
|
// Returns the empty string for page <= 1 so the first page URL matches the
|
|
// canonical form FA emits in its own "next page" links.
|
|
func pageSegment(page int) string {
|
|
if page <= 1 {
|
|
return ""
|
|
}
|
|
return strconv.Itoa(page) + "/"
|
|
}
|
|
|
|
// AbsoluteCDN turns an //d.furaffinity.net/... or /art/... reference into a
|
|
// fully qualified https URL. Returns s unchanged if it already has a scheme.
|
|
func AbsoluteCDN(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
switch {
|
|
case s == "":
|
|
return ""
|
|
case strings.HasPrefix(s, "http://"), strings.HasPrefix(s, "https://"):
|
|
return s
|
|
case strings.HasPrefix(s, "//"):
|
|
return "https:" + s
|
|
case strings.HasPrefix(s, "/"):
|
|
return Host + s
|
|
default:
|
|
return fmt.Sprintf("%s/%s", Host, s)
|
|
}
|
|
}
|