Files
go-fa-api/search.go
2026-05-26 20:21:55 +02:00

219 lines
6.0 KiB
Go

package fa
import (
"context"
"iter"
"net/url"
"strconv"
"time"
"github.com/PuerkitoBio/goquery"
"git.anthrove.art/public/go-fa-api/internal/urls"
)
// SearchType is the submission media type filter on /search/. FA's form
// emits a separate `type-{name}=1` checkbox per type; this enum maps to
// those field names verbatim.
type SearchType string
const (
SearchTypeArt SearchType = "art"
SearchTypeMusic SearchType = "music"
SearchTypeFlash SearchType = "flash"
SearchTypeStory SearchType = "story"
SearchTypePhoto SearchType = "photo"
SearchTypePoetry SearchType = "poetry"
)
// SearchMode controls how FA interprets the query string: AND all terms,
// OR any term, or its "extended" syntax (the default in the web UI).
type SearchMode string
const (
SearchModeAll SearchMode = "all"
SearchModeAny SearchMode = "any"
SearchModeExtended SearchMode = "extended"
)
// SearchOrder is the result-sort key.
type SearchOrder string
const (
SearchOrderRelevancy SearchOrder = "relevancy"
SearchOrderDate SearchOrder = "date"
SearchOrderPopularity SearchOrder = "popularity"
)
// SearchRange is the "posted within" window. SearchRangeManual lets the
// caller specify [SearchOptions.RangeFrom] / [SearchOptions.RangeTo].
type SearchRange string
const (
SearchRange1Day SearchRange = "1day"
SearchRange3Days SearchRange = "3days"
SearchRange7Days SearchRange = "7days"
SearchRange30Days SearchRange = "30days"
SearchRange90Days SearchRange = "90days"
SearchRange1Year SearchRange = "1year"
SearchRange3Years SearchRange = "3years"
SearchRange5Years SearchRange = "5years"
SearchRangeAll SearchRange = "all"
SearchRangeManual SearchRange = "manual"
)
// SearchOptions is the full filter configuration for [Client.Search].
// Zero-value defaults match what the web UI shows: extended-mode query,
// relevancy-desc order, 5-year window, 72/page, all ratings and types
// enabled. Override any field to narrow the search.
type SearchOptions struct {
// Ratings restricts results to these rating levels. nil/empty = all
// three (general + mature + adult), matching the web default.
Ratings []Rating
// Types restricts results to these media types. nil/empty = all six.
Types []SearchType
// Mode selects all-words / any-words / extended. Empty defaults to
// extended (FA's web default).
Mode SearchMode
// OrderBy is the sort key. Empty defaults to relevancy.
OrderBy SearchOrder
// OrderAsc swaps order direction. Default is descending.
OrderAsc bool
// Range is the time window. Empty defaults to 5years (FA's default).
Range SearchRange
// RangeFrom / RangeTo are only consulted when Range == SearchRangeManual.
RangeFrom time.Time
RangeTo time.Time
// PerPage is the page size. Allowed values on the web UI are 24/36/48/60/72.
// Zero defaults to 72.
PerPage int
// StartPage is the 1-based page to begin iteration on. Zero/1 = first page.
StartPage int
// MaxPages bounds the number of pages the iterator will request. Zero
// = unbounded; iteration stops when FA serves an empty page or omits
// the "Next" link.
MaxPages int
}
// Search runs a /search/?q=... query and returns an iterator over the
// matching submissions. Each yielded *Submission carries ID, Title,
// Author, ThumbURL, and Rating from the search results page (call
// [Client.GetSubmission] with the ID for the full record).
//
// Pagination follows FA's "Next" anchor page-numbered GET, capped by
// [SearchOptions.MaxPages] when set.
//
// Search works anonymously for most queries; some adult-content searches
// require login and will surface as [ErrUnauthorized] via the system-
// message classifier.
func (c *Client) Search(ctx context.Context, query string, opts SearchOptions, reqOpts ...Option) 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 := buildSearchURL(query, page, opts)
var (
items []*Submission
hasNext bool
)
err := c.fetch(ctx, pageURL, func(doc *goquery.Document) error {
items, hasNext = parseSearchResults(doc, c.cfg.jsonListings)
return nil
}, reqOpts...)
if err != nil {
yield(nil, err)
return
}
pagesFetched++
if len(items) == 0 {
return
}
for _, s := range items {
if !yield(s, nil) {
return
}
}
if !hasNext {
return
}
page++
}
}
}
// buildSearchURL constructs a /search/ URL with the query plus every
// non-default filter field the web UI sends. The form does method=GET so
// the encoded params land directly in the URL.
func buildSearchURL(query string, page int, opts SearchOptions) string {
v := url.Values{}
v.Set("q", query)
if page > 1 {
v.Set("page", strconv.Itoa(page))
}
if opts.PerPage > 0 {
v.Set("perpage", strconv.Itoa(opts.PerPage))
}
if opts.OrderBy != "" {
v.Set("order-by", string(opts.OrderBy))
}
if opts.OrderAsc {
v.Set("order-direction", "asc")
}
if opts.Mode != "" {
v.Set("mode", string(opts.Mode))
}
if opts.Range != "" {
v.Set("range", string(opts.Range))
}
if opts.Range == SearchRangeManual {
if !opts.RangeFrom.IsZero() {
v.Set("range_from", opts.RangeFrom.Format("2006-01-02"))
}
if !opts.RangeTo.IsZero() {
v.Set("range_to", opts.RangeTo.Format("2006-01-02"))
}
}
// Ratings: nil means all three are sent (FA's default state).
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")
}
}
// Types: nil means all six. Send only the ones the caller requested.
types := opts.Types
if len(types) == 0 {
types = []SearchType{SearchTypeArt, SearchTypeMusic, SearchTypeFlash, SearchTypeStory, SearchTypePhoto, SearchTypePoetry}
}
for _, t := range types {
v.Set("type-"+string(t), "1")
}
return urls.Host + "/search/?" + v.Encode()
}