219 lines
6.0 KiB
Go
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()
|
|
}
|