Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f4767966a | |||
| a2fc1b7e32 |
62
gallery.go
62
gallery.go
@@ -12,7 +12,8 @@ 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, reqOpts ...Option) 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, reqOpts)
|
return c.listGallerySection(ctx, name, urls.Gallery, opts, reqOpts)
|
||||||
@@ -30,6 +31,50 @@ func (c *Client) Favorites(ctx context.Context, name string, opts ListOptions, r
|
|||||||
return c.listGallerySection(ctx, name, urls.Favorites, opts, reqOpts)
|
return c.listGallerySection(ctx, name, urls.Favorites, opts, reqOpts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GalleryPage fetches a single page of /gallery/{name}/ and returns the
|
||||||
|
// items along with whether more pages exist. Pages are 1-based; pass 0 or
|
||||||
|
// 1 for the first page. Use this when driving pagination manually
|
||||||
|
// (resuming from a checkpoint, distributing pages across workers); use
|
||||||
|
// [Client.Gallery] when you just want every item in order.
|
||||||
|
func (c *Client) GalleryPage(ctx context.Context, name string, page int, reqOpts ...Option) (*ListingPage, error) {
|
||||||
|
return c.fetchListingPage(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.fetchListingPage(ctx, name, page, urls.Scraps, reqOpts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FavoritesPage is the single-page counterpart to [Client.Favorites]. See
|
||||||
|
// [Client.GalleryPage] for usage notes.
|
||||||
|
func (c *Client) FavoritesPage(ctx context.Context, name string, page int, reqOpts ...Option) (*ListingPage, error) {
|
||||||
|
return c.fetchListingPage(ctx, name, page, urls.Favorites, reqOpts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchListingPage is the shared per-page primitive used by
|
||||||
|
// GalleryPage / ScrapsPage / FavoritesPage and the iterator engine.
|
||||||
|
func (c *Client) fetchListingPage(
|
||||||
|
ctx context.Context,
|
||||||
|
name string,
|
||||||
|
page int,
|
||||||
|
urlFn func(string, int) string,
|
||||||
|
reqOpts []Option,
|
||||||
|
) (*ListingPage, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
out := &ListingPage{Page: page}
|
||||||
|
err := c.fetch(ctx, urlFn(name, page), func(doc *goquery.Document) error {
|
||||||
|
out.Items, out.HasNext = parseGalleryPage(doc, c.cfg.jsonListings)
|
||||||
|
return nil
|
||||||
|
}, reqOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// listGallerySection is the shared engine for Gallery / Scraps / Favorites.
|
// listGallerySection is the shared engine for Gallery / Scraps / Favorites.
|
||||||
// urlFn picks the section-specific URL builder; the rest of the pagination
|
// urlFn picks the section-specific URL builder; the rest of the pagination
|
||||||
// machinery is identical across all three sections.
|
// machinery is identical across all three sections.
|
||||||
@@ -47,28 +92,21 @@ func (c *Client) listGallerySection(
|
|||||||
if opts.reachedLimit(pagesFetched) {
|
if opts.reachedLimit(pagesFetched) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var (
|
lp, err := c.fetchListingPage(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
|
|
||||||
}, reqOpts...)
|
|
||||||
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++
|
||||||
|
|||||||
149
gallery_page_test.go
Normal file
149
gallery_page_test.go
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeGalleryPage builds a minimal gallery-page response with two figures.
|
||||||
|
// hasNext controls whether the "Next" anchor is included so detectNextPage
|
||||||
|
// flips.
|
||||||
|
func fakeGalleryPage(startID int, hasNext bool) 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 hasNext {
|
||||||
|
b.WriteString(`<a class="button standard" href="/gallery/u/2/">Next</a>`)
|
||||||
|
}
|
||||||
|
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, true)))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/gallery/u/2/", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
requests.Add(1)
|
||||||
|
_, _ = w.Write([]byte(fakeGalleryPage(2000, false)))
|
||||||
|
})
|
||||||
|
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.Page != 1 {
|
||||||
|
t.Errorf("first.Page = %d; want 1", first.Page)
|
||||||
|
}
|
||||||
|
if !first.HasNext {
|
||||||
|
t.Error("first.HasNext = false; want true")
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
// data-tags routed through to the page method too.
|
||||||
|
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.Page != 2 {
|
||||||
|
t.Errorf("last.Page = %d; want 2", last.Page)
|
||||||
|
}
|
||||||
|
|
||||||
|
if requests.Load() != 2 {
|
||||||
|
t.Errorf("requests = %d; want 2", requests.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGalleryPage_ZeroPageDefaultsToOne(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/gallery/u/", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(fakeGalleryPage(1, false)))
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
|
||||||
|
page, err := client.GalleryPage(context.Background(), "u", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GalleryPage(0): %v", err)
|
||||||
|
}
|
||||||
|
if page.Page != 1 {
|
||||||
|
t.Errorf("page.Page = %d; want 1 (zero should normalise)", page.Page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, false)))
|
||||||
|
})
|
||||||
|
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_HitsFavoritesRoute(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/favorites/u/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
_, _ = w.Write([]byte(fakeGalleryPage(1, true)))
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
|
||||||
|
p, err := client.FavoritesPage(context.Background(), "u", 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FavoritesPage: %v", err)
|
||||||
|
}
|
||||||
|
if gotPath != "/favorites/u/" {
|
||||||
|
t.Errorf("gotPath = %q; want /favorites/u/", gotPath)
|
||||||
|
}
|
||||||
|
if !p.HasNext {
|
||||||
|
t.Error("p.HasNext = false; want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,6 +85,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 +114,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))
|
||||||
|
|||||||
@@ -6,6 +6,24 @@ 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, the 1-based page number that produced
|
||||||
|
// them, and whether FA exposed a "next page" link.
|
||||||
|
//
|
||||||
|
// 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 use the same primitive
|
||||||
|
// internally.
|
||||||
|
type ListingPage struct {
|
||||||
|
Items []*Submission
|
||||||
|
HasNext bool
|
||||||
|
Page int // 1-based page number this result corresponds to
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -35,10 +35,19 @@ 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 []string
|
// Tags holds the user-supplied keyword tags. On /view/-path Submissions
|
||||||
|
// these come from div.submission-tags anchors. On listing-path
|
||||||
|
// Submissions (Gallery/Scraps/Favorites/Browse/Search/SubmissionInbox)
|
||||||
|
// they come from the figure's data-tags attribute, which carries the
|
||||||
|
// same keywords FA renders on /view/ for that submission.
|
||||||
|
Tags []string
|
||||||
// CategorizedTags groups FA's prefixed system tags by category.
|
// CategorizedTags groups FA's prefixed system tags by category.
|
||||||
// FA emits these as tag-block entries inside div.submission-tags with
|
// On /view/-path Submissions FA emits these as tag-block entries inside
|
||||||
// prefixes s_ (species), c_ (character), a_/u_ (artist), and t_ (type).
|
// 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
|
CategorizedTags CategorizedTags
|
||||||
FileURL string // absolute CDN URL; pass to Download
|
FileURL string // absolute CDN URL; pass to Download
|
||||||
ThumbURL string
|
ThumbURL string
|
||||||
|
|||||||
Reference in New Issue
Block a user