Files
go-fa-api/listing_json.go
2026-05-25 22:27:18 +02:00

69 lines
2.4 KiB
Go

package fa
import (
"encoding/json"
"strings"
"github.com/PuerkitoBio/goquery"
)
// listingJSONEntry is one row of FA's embedded js-submissionData blob —
// a JSON dictionary every listing page emits with stable metadata for
// each visible submission. The blob is keyed by submission ID (string)
// and survives most HTML reshuffles because it's consumed by FA's own
// front-end JS.
//
// Not every field appears on every page; description is sometimes truncated
// to ~500 chars with a trailing "…". We use this purely as a hinting
// source the HTML figure scrape remains authoritative for fields the
// JSON doesn't carry (ID, rating, thumbnail).
type listingJSONEntry struct {
Title string `json:"title"`
Description string `json:"description"` // BBCode source (may be truncated)
Username string `json:"username"` // display name with original casing
Lower string `json:"lower"` // URL-safe lowercase login
AvatarMtime string `json:"avatar_mtime"`
}
// listingJSONMap is the parsed js-submissionData blob: SubmissionID → entry.
type listingJSONMap map[SubmissionID]listingJSONEntry
// readListingJSON pulls and parses the <script id="js-submissionData">
// blob from doc. Returns nil if the tag is absent or the JSON is
// malformed the caller then falls back to HTML-only scraping. The
// JSON is keyed by string in the source; we deserialize directly into a
// SubmissionID map by exploiting the typed int unmarshal path.
func readListingJSON(doc *goquery.Document) listingJSONMap {
raw := strings.TrimSpace(doc.Find("script#js-submissionData").First().Text())
if raw == "" {
return nil
}
// Decode into string-keyed map first because Go's json package only
// accepts string-convertible keys from JSON object keys.
var stringKeyed map[string]listingJSONEntry
if err := json.Unmarshal([]byte(raw), &stringKeyed); err != nil {
return nil
}
out := make(listingJSONMap, len(stringKeyed))
for k, v := range stringKeyed {
id, err := parseID[SubmissionID](k)
if err != nil || id == 0 {
continue
}
out[id] = v
}
if len(out) == 0 {
return nil
}
return out
}
// avatarURLFromMtime builds FA's avatar CDN URL from the mtime + lowercase
// login. Returns "" when either piece is missing.
func avatarURLFromMtime(lower, mtime string) string {
if lower == "" || mtime == "" {
return ""
}
return "https://a.furaffinity.net/" + mtime + "/" + lower + ".gif"
}