inital commit
This commit is contained in:
148
browse.go
Normal file
148
browse.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package fa
|
||||
|
||||
import (
|
||||
"context"
|
||||
"iter"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
|
||||
"git.anthrove.art/public/go-fa-api/internal/urls"
|
||||
)
|
||||
|
||||
// browsePerPage is FA's default page size for /browse/. We use it as the
|
||||
// "this page is full → there's likely another one" threshold because the
|
||||
// browse feed paginates via POST forms in the UI; detectNextPage's anchor-
|
||||
// based heuristic doesn't fire on browse responses.
|
||||
const browsePerPage = 72
|
||||
|
||||
// BrowseOptions configures /browse/ filters. Zero-value defaults match
|
||||
// FA's web UI default: all ratings on, all categories, all art types,
|
||||
// any species, 72 per page.
|
||||
//
|
||||
// The filter fields use FA's internal numeric IDs (1 = "All" / "Any" for
|
||||
// category, atype, species). The exact list of valid values is huge and
|
||||
// changes over time; for now we expose them as raw ints rather than
|
||||
// pretending to enumerate them callers wanting a typed Category enum
|
||||
// should grab the value off the browse form's <option> elements.
|
||||
type BrowseOptions struct {
|
||||
// Ratings restricts results to these rating levels. nil/empty = all
|
||||
// three (general + mature + adult).
|
||||
Ratings []Rating
|
||||
|
||||
// Category, ArtType, Species are FA's internal numeric IDs. Zero or 1
|
||||
// means "All / Any" (the default).
|
||||
Category int
|
||||
ArtType int
|
||||
Species int
|
||||
|
||||
// PerPage is the page size. Valid values match the web UI: 24/36/48/60/72.
|
||||
// Zero defaults to 72.
|
||||
PerPage int
|
||||
|
||||
// StartPage is the 1-based page to begin iteration on.
|
||||
StartPage int
|
||||
|
||||
// MaxPages bounds the iterator. Zero = unbounded (be careful the feed
|
||||
// is effectively infinite).
|
||||
MaxPages int
|
||||
}
|
||||
|
||||
// Browse iterates FA's global front-page feed (/browse/), newest first.
|
||||
// Returns the same partially-populated *Submission shape as [Client.Gallery]:
|
||||
// ID, Title, Author, ThumbURL, Rating. Call [Client.GetSubmission] with
|
||||
// the ID to load the full record.
|
||||
//
|
||||
// The feed is effectively unbounded (millions of submissions across the
|
||||
// site). Always set [BrowseOptions.MaxPages] in production code; without
|
||||
// it the iterator will keep fetching until FA returns a partial page.
|
||||
//
|
||||
// Note: FA paginates browse via POST forms in the UI; this iterator
|
||||
// instead uses GET with ?page=N, which is honoured for the rendered HTML.
|
||||
// "Next page exists?" is inferred from item count: a full page
|
||||
// (browsePerPage items) means we keep going; anything less is the tail.
|
||||
func (c *Client) Browse(ctx context.Context, opts BrowseOptions) iter.Seq2[*Submission, error] {
|
||||
return func(yield func(*Submission, error) bool) {
|
||||
page := opts.StartPage
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pagesFetched := 0
|
||||
for {
|
||||
if opts.MaxPages > 0 && pagesFetched >= opts.MaxPages {
|
||||
return
|
||||
}
|
||||
pageURL := buildBrowseURL(page, opts)
|
||||
var items []*Submission
|
||||
err := c.fetch(ctx, pageURL, func(doc *goquery.Document) error {
|
||||
items, _ = parseGalleryPage(doc, c.cfg.jsonListings)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
yield(nil, err)
|
||||
return
|
||||
}
|
||||
pagesFetched++
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
for _, s := range items {
|
||||
if !yield(s, nil) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(items) < browsePerPage {
|
||||
return
|
||||
}
|
||||
page++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// buildBrowseURL constructs a /browse/ URL with the given page and any
|
||||
// non-default filter fields. The form fields match what FA's UI POSTs on
|
||||
// the front-page form; we send them as GET params instead.
|
||||
func buildBrowseURL(page int, opts BrowseOptions) string {
|
||||
// Without any filters set, the simple path-form URL works.
|
||||
hasFilters := len(opts.Ratings) != 0 ||
|
||||
opts.Category > 1 || opts.ArtType > 1 || opts.Species > 1 ||
|
||||
opts.PerPage > 0
|
||||
if !hasFilters {
|
||||
return urls.Browse(page)
|
||||
}
|
||||
|
||||
v := url.Values{}
|
||||
if page > 1 {
|
||||
v.Set("page", strconv.Itoa(page))
|
||||
}
|
||||
if opts.PerPage > 0 {
|
||||
v.Set("perpage", strconv.Itoa(opts.PerPage))
|
||||
}
|
||||
if opts.Category > 0 {
|
||||
v.Set("cat", strconv.Itoa(opts.Category))
|
||||
}
|
||||
if opts.ArtType > 0 {
|
||||
v.Set("atype", strconv.Itoa(opts.ArtType))
|
||||
}
|
||||
if opts.Species > 0 {
|
||||
v.Set("species", strconv.Itoa(opts.Species))
|
||||
}
|
||||
|
||||
ratings := opts.Ratings
|
||||
if len(ratings) == 0 {
|
||||
ratings = []Rating{RatingGeneral, RatingMature, RatingAdult}
|
||||
}
|
||||
for _, r := range ratings {
|
||||
switch r {
|
||||
case RatingGeneral:
|
||||
v.Set("rating_general", "1")
|
||||
case RatingMature:
|
||||
v.Set("rating_mature", "1")
|
||||
case RatingAdult:
|
||||
v.Set("rating_adult", "1")
|
||||
}
|
||||
}
|
||||
|
||||
return urls.Host + "/browse/?" + v.Encode()
|
||||
}
|
||||
Reference in New Issue
Block a user