inital commit
This commit is contained in:
147
internal/urls/routes.go
Normal file
147
internal/urls/routes.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// 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 a user's favorites page. FA uses a numeric
|
||||
// page parameter; the first page is 1.
|
||||
func Favorites(name string, page int) string {
|
||||
return Host + "/favorites/" + safeName(name) + "/" + pageSegment(page)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
65
internal/urls/routes_test.go
Normal file
65
internal/urls/routes_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package urls
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSubmission(t *testing.T) {
|
||||
got := Submission(42)
|
||||
want := "https://www.furaffinity.net/view/42/"
|
||||
if got != want {
|
||||
t.Errorf("Submission(42) = %q; want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUser_LowercasesAndEscapes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, in, want string
|
||||
}{
|
||||
{"plain", "SomeUser", "https://www.furaffinity.net/user/someuser/"},
|
||||
{"trim", " Mixed ", "https://www.furaffinity.net/user/mixed/"},
|
||||
{"unicode safe", "über", "https://www.furaffinity.net/user/%C3%BCber/"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := User(tc.in); got != tc.want {
|
||||
t.Errorf("User(%q) = %q; want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGallery_PageSegments(t *testing.T) {
|
||||
cases := map[int]string{
|
||||
1: "https://www.furaffinity.net/gallery/me/",
|
||||
2: "https://www.furaffinity.net/gallery/me/2/",
|
||||
10: "https://www.furaffinity.net/gallery/me/10/",
|
||||
}
|
||||
for page, want := range cases {
|
||||
if got := Gallery("me", page); got != want {
|
||||
t.Errorf("Gallery(me, %d) = %q; want %q", page, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgSubmissionsCursor(t *testing.T) {
|
||||
got := MsgSubmissionsCursor(65032289)
|
||||
want := "https://www.furaffinity.net/msg/submissions/new~65032289@72/"
|
||||
if got != want {
|
||||
t.Errorf("MsgSubmissionsCursor(65032289) = %q; want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbsoluteCDN(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "",
|
||||
"https://d.example/x.png": "https://d.example/x.png",
|
||||
"http://d.example/x.png": "http://d.example/x.png",
|
||||
"//d.furaffinity.net/art/x.png": "https://d.furaffinity.net/art/x.png",
|
||||
"/view/1/": "https://www.furaffinity.net/view/1/",
|
||||
"art/foo": "https://www.furaffinity.net/art/foo",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := AbsoluteCDN(in); got != want {
|
||||
t.Errorf("AbsoluteCDN(%q) = %q; want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user