inital commit
This commit is contained in:
218
search.go
Normal file
218
search.go
Normal file
@@ -0,0 +1,218 @@
|
||||
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) 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
|
||||
})
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user