package fa import ( "context" "iter" "strconv" "github.com/PuerkitoBio/goquery" "git.anthrove.art/public/go-fa-api/internal/urls" ) // Gallery iterates the submissions in a user's main gallery, newest first. // // Each yielded *Submission carries only the fields visible on the listing // 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. func (c *Client) Gallery(ctx context.Context, name string, opts ListOptions, reqOpts ...Option) iter.Seq2[*Submission, error] { return c.listPagedSection(ctx, name, urls.Gallery, opts, reqOpts) } // Scraps iterates the user's scraps folder. Same yield shape as Gallery. func (c *Client) Scraps(ctx context.Context, name string, opts ListOptions, reqOpts ...Option) iter.Seq2[*Submission, error] { return c.listPagedSection(ctx, name, urls.Scraps, opts, reqOpts) } // Favorites iterates the user's favorited submissions. The yielded // *Submission's Author field reflects the original artist (not the user // whose favorites we are walking). // // Favorites use a fave-ID cursor for pagination, not sequential page // numbers, so [ListOptions.StartPage] is ignored — the walk always // begins at the newest favorite. [ListOptions.MaxPages] still bounds // the crawl. func (c *Client) Favorites(ctx context.Context, name string, opts ListOptions, reqOpts ...Option) iter.Seq2[*Submission, error] { return func(yield func(*Submission, error) bool) { cursor := "" pagesFetched := 0 for { if opts.reachedLimit(pagesFetched) { return } lp, err := c.FavoritesPage(ctx, name, cursor, reqOpts...) if err != nil { yield(nil, err) return } pagesFetched++ if len(lp.Items) == 0 { return } for _, s := range lp.Items { if !yield(s, nil) { return } } if !lp.HasNext { return } cursor = lp.NextPage } } } // 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. // // On a non-final page the returned [ListingPage].NextPage is the next // page number as a decimal string ("2", "3", …) — pass it back to the // next call after [strconv.Atoi], or treat it as opaque. func (c *Client) GalleryPage(ctx context.Context, name string, page int, reqOpts ...Option) (*ListingPage, error) { return c.fetchNumberedPage(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.fetchNumberedPage(ctx, name, page, urls.Scraps, reqOpts) } // FavoritesPage fetches a single page of /favorites/{name}/, addressed // by the cursor FA emitted on the previous page (empty string for the // first page). FA paginates favorites with a fave-ID cursor — not a // sequential page number — so the caller must walk forward by passing // the returned [ListingPage].NextPage value into the next call. Passing // a guessed cursor (e.g. "2") makes FA silently return the first page // and the loop will not terminate. func (c *Client) FavoritesPage(ctx context.Context, name string, cursor string, reqOpts ...Option) (*ListingPage, error) { out := &ListingPage{} err := c.fetch(ctx, urls.FavoritesCursor(name, cursor), func(doc *goquery.Document) error { items, nextURL, hasNext := parseListingPage(doc, c.cfg.jsonListings) out.Items = items out.HasNext = hasNext if hasNext { out.NextPage = favoritesCursorFromURL(nextURL) // If the markup was unrecognisable, refuse to claim a next // page rather than re-fetching the first one in a loop. if out.NextPage == "" { out.HasNext = false } } return nil }, reqOpts...) if err != nil { return nil, err } return out, nil } // fetchNumberedPage is the shared primitive for page-number-based // listings (Gallery / Scraps). urlFn picks the section-specific URL // builder; the rest of the pagination machinery is identical. func (c *Client) fetchNumberedPage( ctx context.Context, name string, page int, urlFn func(string, int) string, reqOpts []Option, ) (*ListingPage, error) { if page < 1 { page = 1 } out := &ListingPage{} err := c.fetch(ctx, urlFn(name, page), func(doc *goquery.Document) error { items, _, hasNext := parseListingPage(doc, c.cfg.jsonListings) out.Items = items out.HasNext = hasNext if hasNext { out.NextPage = strconv.Itoa(page + 1) } return nil }, reqOpts...) if err != nil { return nil, err } return out, nil } // listPagedSection is the shared engine for the page-number-based // listing iterators (Gallery / Scraps). Favorites has its own loop in // [Client.Favorites] because its pagination is cursor-based. func (c *Client) listPagedSection( ctx context.Context, name string, urlFn func(string, int) string, opts ListOptions, reqOpts []Option, ) iter.Seq2[*Submission, error] { return func(yield func(*Submission, error) bool) { page := opts.firstPage() pagesFetched := 0 for { if opts.reachedLimit(pagesFetched) { return } lp, err := c.fetchNumberedPage(ctx, name, page, urlFn, reqOpts) if err != nil { yield(nil, err) return } pagesFetched++ if len(lp.Items) == 0 { return } for _, s := range lp.Items { if !yield(s, nil) { return } } if !lp.HasNext { return } page++ } } }