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() }