mirror of
https://github.com/stashapp/stash.git
synced 2025-12-14 20:33:16 +01:00
* Simplify scraper listing
Introduce an enum, scraper.Kind, which explains what we are looking
for. Make it possible to match this from a scraper struct.
Use the enum to rewrite all the listing code to use the same code path.
* Use a map, nitpick ScrapePerformerList
Let the cache store a map from ID of a scraper to the scraper. This
improves lookups when there are many scrapers, making it practically
O(1) rather than O(n). If many scrapers are stored, this is faster.
Since range expressions work unchanged, we don't have to change much,
and things will still work.
make Kind a Stringer
Rename ScraperPerformerList -> ScraperPerformerQuery since that name
is used in the other scrapers, and we value consistency.
Tune ScraperPerformerQuery:
* Return static errors
* Use the new functionality
* When loading scrapers, do so directly
Rather than first walking the directory structure to obtain file paths,
fold the load directly in the the filepath walk. This makes the code
for more direct.
* Use static ErrNotFound
If a scraper isn't found, return one static error. This paves the way
for eventually doing our own error-presenter in gqlgen.
* Store the cache in the Resolver state
Putting the scraperCache directly in the resolver avoids the need to
call manager.GetInstance() all over the place to get access to the
scraper cache. The cache is stored by pointer, so it should be safe,
since the cache will just update its internal state rather than being
overwritten.
We can now utilize the resolver state to grab the cache where needed.
While here, pass context.Context from the resolver down into a function,
which removes a context.TODO()
* Introduce ScrapedContent
Create a union in the GraphQL schema for all scraped content. This
simplifies the internal implementation because we get variance on
the output content type.
Introduce a new type ScrapedContentType which signifies the scraped
content you want as a caller.
Use these to generalize the List interface and the URL scraping
interface.
* Simplify the scraper API
Introduce a new interface for scraping. This interface is then
used in the upper half of the scraper code, to make the code use one
code flow rather than multiple code flows. Variance is currently at
the old scraper structure.
Add extending interfaces for the different ways of invoking scrapes.
Use interface conversions to convert a scraper from the cache to a
scraper supporting the extra methods.
The return path returns models.ScrapedContent.
Write a general postProcess function in the scraper, handling all
ScrapedContent via type switching. This consolidates all postprocessing
code flows.
Introduce marhsallers in the resolver code for converting ScrapedContent
into the underlying concrete types. Use this to plug the existing
fields in the Query resolver, so everything still works.
* ScrapedContent: add more marshalling functions
Handle all marshalling of ScrapedContent through marhsalling functions.
Removes some hand-rolled early variants of it, and replaces it with
a canonical code flow.
* Support loadByName via scraper_s
In order to temporarily plug a hole in the current implementation, we
use the older implementation as a hook to get the newer implementation
to run.
Later on, this can serve as a guide for how to implement the lower level
bits inside the scrapers themselves. For now, it just enables support.
* Plug the remaining scraper functions for now
Since we would like to have a scraper which works in between refactors,
plug the lower level parts of the scraper for now. It avoids us having
to tackle this part just yet.
* Move postprocessing to its own file
There's enough postprocessing to clutter the main scrapers.go file.
Move all of this into a new file, postprocessing to make the API
simpler. It now lives in scrapers.go.
* Scraper: Invoke API consistency
scraper.Cache.ScrapeByName -> ScrapeName
* Fix scraping scenes by URL
Simple typo. While here, also make a single marshaller nil-aware.
* Introduce scraper groups, consolidate loadByURL
Rename `scraper_s` into `group`. A group is a group of scrapers with
the same identity. This corresponds to a single YAML file for a scraper
configuration. It defines a group which supports different types of
scraping contexts.
Move config into the group, and lift txnManager and globalConfig to
the group.
Because we now return models.ScrapedContent we can use interfaces to
get variance from the different underlying scrapers. Use a type
switch for the URL matcher candidates. And then again for the scrapers.
This consolidates all URL scraping paths into one.
While here, remove the urlMatcher interface which isn't needed. Also
clean up the remaining interfaces for url scraping and delete code
which has no purpose anymore.
* Consolidate fragment scraping in one code path
While here, abide the linters checks.
* Refactor loadByFragment
Give it the same treatment as loadByURL:
Step 1: find a scraperActionImpl which works for the data.
Step 2: use that to scrape
Most of this is simple analysis on the data at hand. It can be pushed
down further in a later commit, but for now we leave it here.
* Remove configScraper, autotag is a scraper
Remove the remains of the configScraper struct. It now lives on in the
group struct. Kill the remaining interfaces from the old implementation
while here.
Remove group.specification since it can now be handled by a simple
func call to spec().
Work through the autotag scraper. It now implements the scraper
interface, so it can be used as a scraper. This also simplifies the
autotag scraper quite a bit since it doens't have to implement a number
of unsupported func calls.
* Simplify the fragment scraper flow
* Pass the context
Eliminate a round of context.TODO() in the scraper code by passing
the calling context down into the subsystem. This will gracefully
allow for termination of remote calls if the client goes away for some
reason in GraphQL requests.
* Improve listScrapers in the schema
Support lists of types we accept.
* Be graceful on nil values in conversion
Supporting nil-values make the API more robust in the
case of partial results in a multi-scrape situation.
* Improve listScrapers: output at-most-once
Use the ID of a scraper to reduce the output set. If a scraper has
been included, don't include it again.
* Consolidate all API level errors into resolver.go
* Reorder files and functions:
scrapers.go -> cache.go:
It almost contains nothing but the cache code.
Move errors into scraper.go from here because
It is a better place to have them living right now
group.go:
All of the group structure. This can now go from
scraper.go, making it more lean. Move group create
from config_scraper to here.
config.go:
Move the `(c config) spec()` call to here.
config_scraper.go:
Empty file by now
* Name-update the scraper interfaces
Use 'via' rather than 'loadBy'.
The scrape happens via a given scrape method, so I think this is a nice
name for it.
* Rename scrapers for consistency.
While here, improve the error formatting, so different errors come
back differently.
* Nuke the freeones field from the GraphQL schema
* Fix autotag interfacing, refactor
The autotag scraper uses a pointer receiver, but the rest of the code
we use for scraping doesn't expect a pointer-receiver. Hence, to fix
the autotag scraper, we change it to be a value receiver, like the
rest of the code.
Fix: viaScene, and viaGallery.
While here, remove a couple of pointer-receiver methods which can be
trivially rewritten into plain functions.
* Protect against pointer interfaces
The underlying code can be a bit inconsistent in what it returns.
Introduce pointer-types in the postprocessing layer and handle them
accordingly for now. Once a better understanding of the lower levels
are understood, we can lift this.
* Move ErrConversion into the models package.
The conversion error pertains to the logic of converting models.
Because of this, it should move there, so it is centralized.
* Be consistent in scraper resolver error handling
If we have a static error
Err = errors.New(..)
Then use it wrapped at the start:
fmt.Errorf("%w: ...context...", Err)
This reads better.
While here, avoid using the underlying Atoi errors: they are verbose,
and like 99% of the time, the user know what is wrong from the input
string, so just give that back.
Also, remove the scraper id from the error contexts: it is implicit,
and the error wouldn't change if we used a different scraper, which
the error message would imply.
* Mark the list*Scrapers() API as deprecated
The same functionality is now present in listScrapers.
* Improve error formatting
Think about how each error is going to be used and tweak them to be
nicer.
* Return a sorted list of scrapers
This helps testing, it's closer to what we had, caches like stable data,
and it is easier for humans. It also makes the output stable, because
map iteration is randomized.
* Fix listScrapers calls to return in ID-order
Since we need the ordering to be by ID in all situations, it is easier
to just generalize the cache listScrapers call to support multiple
scraper types.
This avoids a de-dupe map up the chain, since every scraper is only
considered once. Sorting now happens in the cache listScrapers call.
Use this generalized function in all resolvers, which are now simple
passthroughs.
* Remove UpdateConfig from the scraper cache.
This isn't needed, so get rid of it.
* Pull a context into identify
Scraping scenes in the identify tasks now use a context from up the
call chain.
* Do not store the scraper cache in the resolver.
Scraper caches are updated through
manager.singleton•RefreshScraperCache, so we can't keep a pointer to
it in the resolver. Instead, solve this by adding a fetcher method to
the resolver type. This keeps it local to the resolver, while handling
the problem of updating caches in the configuration.
996 lines
24 KiB
Go
996 lines
24 KiB
Go
package scraper
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"reflect"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/stashapp/stash/pkg/logger"
|
|
"github.com/stashapp/stash/pkg/models"
|
|
"github.com/stashapp/stash/pkg/utils"
|
|
"gopkg.in/yaml.v2"
|
|
)
|
|
|
|
type mappedQuery interface {
|
|
runQuery(selector string) ([]string, error)
|
|
subScrape(value string) mappedQuery
|
|
}
|
|
|
|
type commonMappedConfig map[string]string
|
|
|
|
type mappedConfig map[string]mappedScraperAttrConfig
|
|
|
|
func (s mappedConfig) applyCommon(c commonMappedConfig, src string) string {
|
|
if c == nil {
|
|
return src
|
|
}
|
|
|
|
ret := src
|
|
for commonKey, commonVal := range c {
|
|
ret = strings.ReplaceAll(ret, commonKey, commonVal)
|
|
}
|
|
|
|
return ret
|
|
}
|
|
|
|
func (s mappedConfig) process(q mappedQuery, common commonMappedConfig) mappedResults {
|
|
var ret mappedResults
|
|
|
|
for k, attrConfig := range s {
|
|
|
|
if attrConfig.Fixed != "" {
|
|
// TODO - not sure if this needs to set _all_ indexes for the key
|
|
const i = 0
|
|
ret = ret.setKey(i, k, attrConfig.Fixed)
|
|
} else {
|
|
selector := attrConfig.Selector
|
|
selector = s.applyCommon(common, selector)
|
|
|
|
found, err := q.runQuery(selector)
|
|
if err != nil {
|
|
logger.Warnf("key '%v': %v", k, err)
|
|
}
|
|
|
|
if len(found) > 0 {
|
|
result := s.postProcess(q, attrConfig, found)
|
|
for i, text := range result {
|
|
ret = ret.setKey(i, k, text)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return ret
|
|
}
|
|
|
|
func (s mappedConfig) postProcess(q mappedQuery, attrConfig mappedScraperAttrConfig, found []string) []string {
|
|
// check if we're concatenating the results into a single result
|
|
var ret []string
|
|
if attrConfig.hasConcat() {
|
|
result := attrConfig.concatenateResults(found)
|
|
result = attrConfig.postProcess(result, q)
|
|
if attrConfig.hasSplit() {
|
|
results := attrConfig.splitString(result)
|
|
results = attrConfig.cleanResults(results)
|
|
return results
|
|
}
|
|
|
|
ret = []string{result}
|
|
} else {
|
|
for _, text := range found {
|
|
text = attrConfig.postProcess(text, q)
|
|
if attrConfig.hasSplit() {
|
|
return attrConfig.splitString(text)
|
|
}
|
|
|
|
ret = append(ret, text)
|
|
}
|
|
ret = attrConfig.cleanResults(ret)
|
|
}
|
|
|
|
return ret
|
|
}
|
|
|
|
type mappedSceneScraperConfig struct {
|
|
mappedConfig
|
|
|
|
Tags mappedConfig `yaml:"Tags"`
|
|
Performers mappedPerformerScraperConfig `yaml:"Performers"`
|
|
Studio mappedConfig `yaml:"Studio"`
|
|
Movies mappedConfig `yaml:"Movies"`
|
|
}
|
|
type _mappedSceneScraperConfig mappedSceneScraperConfig
|
|
|
|
const (
|
|
mappedScraperConfigSceneTags = "Tags"
|
|
mappedScraperConfigScenePerformers = "Performers"
|
|
mappedScraperConfigSceneStudio = "Studio"
|
|
mappedScraperConfigSceneMovies = "Movies"
|
|
)
|
|
|
|
func (s *mappedSceneScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
|
// HACK - unmarshal to map first, then remove known scene sub-fields, then
|
|
// remarshal to yaml and pass that down to the base map
|
|
parentMap := make(map[string]interface{})
|
|
if err := unmarshal(parentMap); err != nil {
|
|
return err
|
|
}
|
|
|
|
// move the known sub-fields to a separate map
|
|
thisMap := make(map[string]interface{})
|
|
|
|
thisMap[mappedScraperConfigSceneTags] = parentMap[mappedScraperConfigSceneTags]
|
|
thisMap[mappedScraperConfigScenePerformers] = parentMap[mappedScraperConfigScenePerformers]
|
|
thisMap[mappedScraperConfigSceneStudio] = parentMap[mappedScraperConfigSceneStudio]
|
|
thisMap[mappedScraperConfigSceneMovies] = parentMap[mappedScraperConfigSceneMovies]
|
|
|
|
delete(parentMap, mappedScraperConfigSceneTags)
|
|
delete(parentMap, mappedScraperConfigScenePerformers)
|
|
delete(parentMap, mappedScraperConfigSceneStudio)
|
|
delete(parentMap, mappedScraperConfigSceneMovies)
|
|
|
|
// re-unmarshal the sub-fields
|
|
yml, err := yaml.Marshal(thisMap)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// needs to be a different type to prevent infinite recursion
|
|
c := _mappedSceneScraperConfig{}
|
|
if err := yaml.Unmarshal(yml, &c); err != nil {
|
|
return err
|
|
}
|
|
|
|
*s = mappedSceneScraperConfig(c)
|
|
|
|
yml, err = yaml.Marshal(parentMap)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type mappedGalleryScraperConfig struct {
|
|
mappedConfig
|
|
|
|
Tags mappedConfig `yaml:"Tags"`
|
|
Performers mappedConfig `yaml:"Performers"`
|
|
Studio mappedConfig `yaml:"Studio"`
|
|
}
|
|
type _mappedGalleryScraperConfig mappedGalleryScraperConfig
|
|
|
|
func (s *mappedGalleryScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
|
// HACK - unmarshal to map first, then remove known scene sub-fields, then
|
|
// remarshal to yaml and pass that down to the base map
|
|
parentMap := make(map[string]interface{})
|
|
if err := unmarshal(parentMap); err != nil {
|
|
return err
|
|
}
|
|
|
|
// move the known sub-fields to a separate map
|
|
thisMap := make(map[string]interface{})
|
|
|
|
thisMap[mappedScraperConfigSceneTags] = parentMap[mappedScraperConfigSceneTags]
|
|
thisMap[mappedScraperConfigScenePerformers] = parentMap[mappedScraperConfigScenePerformers]
|
|
thisMap[mappedScraperConfigSceneStudio] = parentMap[mappedScraperConfigSceneStudio]
|
|
|
|
delete(parentMap, mappedScraperConfigSceneTags)
|
|
delete(parentMap, mappedScraperConfigScenePerformers)
|
|
delete(parentMap, mappedScraperConfigSceneStudio)
|
|
|
|
// re-unmarshal the sub-fields
|
|
yml, err := yaml.Marshal(thisMap)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// needs to be a different type to prevent infinite recursion
|
|
c := _mappedGalleryScraperConfig{}
|
|
if err := yaml.Unmarshal(yml, &c); err != nil {
|
|
return err
|
|
}
|
|
|
|
*s = mappedGalleryScraperConfig(c)
|
|
|
|
yml, err = yaml.Marshal(parentMap)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type mappedPerformerScraperConfig struct {
|
|
mappedConfig
|
|
|
|
Tags mappedConfig `yaml:"Tags"`
|
|
}
|
|
type _mappedPerformerScraperConfig mappedPerformerScraperConfig
|
|
|
|
const (
|
|
mappedScraperConfigPerformerTags = "Tags"
|
|
)
|
|
|
|
func (s *mappedPerformerScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
|
// HACK - unmarshal to map first, then remove known scene sub-fields, then
|
|
// remarshal to yaml and pass that down to the base map
|
|
parentMap := make(map[string]interface{})
|
|
if err := unmarshal(parentMap); err != nil {
|
|
return err
|
|
}
|
|
|
|
// move the known sub-fields to a separate map
|
|
thisMap := make(map[string]interface{})
|
|
|
|
thisMap[mappedScraperConfigPerformerTags] = parentMap[mappedScraperConfigPerformerTags]
|
|
|
|
delete(parentMap, mappedScraperConfigPerformerTags)
|
|
|
|
// re-unmarshal the sub-fields
|
|
yml, err := yaml.Marshal(thisMap)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// needs to be a different type to prevent infinite recursion
|
|
c := _mappedPerformerScraperConfig{}
|
|
if err := yaml.Unmarshal(yml, &c); err != nil {
|
|
return err
|
|
}
|
|
|
|
*s = mappedPerformerScraperConfig(c)
|
|
|
|
yml, err = yaml.Marshal(parentMap)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type mappedMovieScraperConfig struct {
|
|
mappedConfig
|
|
|
|
Studio mappedConfig `yaml:"Studio"`
|
|
}
|
|
type _mappedMovieScraperConfig mappedMovieScraperConfig
|
|
|
|
const (
|
|
mappedScraperConfigMovieStudio = "Studio"
|
|
)
|
|
|
|
func (s *mappedMovieScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
|
// HACK - unmarshal to map first, then remove known movie sub-fields, then
|
|
// remarshal to yaml and pass that down to the base map
|
|
parentMap := make(map[string]interface{})
|
|
if err := unmarshal(parentMap); err != nil {
|
|
return err
|
|
}
|
|
|
|
// move the known sub-fields to a separate map
|
|
thisMap := make(map[string]interface{})
|
|
|
|
thisMap[mappedScraperConfigMovieStudio] = parentMap[mappedScraperConfigMovieStudio]
|
|
|
|
delete(parentMap, mappedScraperConfigMovieStudio)
|
|
|
|
// re-unmarshal the sub-fields
|
|
yml, err := yaml.Marshal(thisMap)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// needs to be a different type to prevent infinite recursion
|
|
c := _mappedMovieScraperConfig{}
|
|
if err := yaml.Unmarshal(yml, &c); err != nil {
|
|
return err
|
|
}
|
|
|
|
*s = mappedMovieScraperConfig(c)
|
|
|
|
yml, err = yaml.Marshal(parentMap)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type mappedRegexConfig struct {
|
|
Regex string `yaml:"regex"`
|
|
With string `yaml:"with"`
|
|
}
|
|
|
|
type mappedRegexConfigs []mappedRegexConfig
|
|
|
|
func (c mappedRegexConfig) apply(value string) string {
|
|
if c.Regex != "" {
|
|
re, err := regexp.Compile(c.Regex)
|
|
if err != nil {
|
|
logger.Warnf("Error compiling regex '%s': %s", c.Regex, err.Error())
|
|
return value
|
|
}
|
|
|
|
ret := re.ReplaceAllString(value, c.With)
|
|
|
|
// trim leading and trailing whitespace
|
|
// this is done to maintain backwards compatibility with existing
|
|
// scrapers
|
|
ret = strings.TrimSpace(ret)
|
|
|
|
logger.Debugf(`Replace: '%s' with '%s'`, c.Regex, c.With)
|
|
logger.Debugf("Before: %s", value)
|
|
logger.Debugf("After: %s", ret)
|
|
return ret
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
func (c mappedRegexConfigs) apply(value string) string {
|
|
// apply regex in order
|
|
for _, config := range c {
|
|
value = config.apply(value)
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
type postProcessAction interface {
|
|
Apply(value string, q mappedQuery) string
|
|
}
|
|
|
|
type postProcessParseDate string
|
|
|
|
func (p *postProcessParseDate) Apply(value string, q mappedQuery) string {
|
|
parseDate := string(*p)
|
|
|
|
const internalDateFormat = "2006-01-02"
|
|
|
|
valueLower := strings.ToLower(value)
|
|
if valueLower == "today" || valueLower == "yesterday" { // handle today, yesterday
|
|
dt := time.Now()
|
|
if valueLower == "yesterday" { // subtract 1 day from now
|
|
dt = dt.AddDate(0, 0, -1)
|
|
}
|
|
return dt.Format(internalDateFormat)
|
|
}
|
|
|
|
if parseDate == "" {
|
|
return value
|
|
}
|
|
|
|
// try to parse the date using the pattern
|
|
// if it fails, then just fall back to the original value
|
|
parsedValue, err := time.Parse(parseDate, value)
|
|
if err != nil {
|
|
logger.Warnf("Error parsing date string '%s' using format '%s': %s", value, parseDate, err.Error())
|
|
return value
|
|
}
|
|
|
|
// convert it into our date format
|
|
return parsedValue.Format(internalDateFormat)
|
|
}
|
|
|
|
type postProcessSubtractDays bool
|
|
|
|
func (p *postProcessSubtractDays) Apply(value string, q mappedQuery) string {
|
|
const internalDateFormat = "2006-01-02"
|
|
|
|
i, err := strconv.Atoi(value)
|
|
if err != nil {
|
|
logger.Warnf("Error parsing day string %s: %s", value, err)
|
|
return value
|
|
}
|
|
|
|
dt := time.Now()
|
|
dt = dt.AddDate(0, 0, -i)
|
|
return dt.Format(internalDateFormat)
|
|
}
|
|
|
|
type postProcessReplace mappedRegexConfigs
|
|
|
|
func (c *postProcessReplace) Apply(value string, q mappedQuery) string {
|
|
replace := mappedRegexConfigs(*c)
|
|
return replace.apply(value)
|
|
}
|
|
|
|
type postProcessSubScraper mappedScraperAttrConfig
|
|
|
|
func (p *postProcessSubScraper) Apply(value string, q mappedQuery) string {
|
|
subScrapeConfig := mappedScraperAttrConfig(*p)
|
|
|
|
logger.Debugf("Sub-scraping for: %s", value)
|
|
ss := q.subScrape(value)
|
|
|
|
if ss != nil {
|
|
found, err := ss.runQuery(subScrapeConfig.Selector)
|
|
if err != nil {
|
|
logger.Warnf("subscrape for '%v': %v", value, err)
|
|
}
|
|
|
|
if len(found) > 0 {
|
|
// check if we're concatenating the results into a single result
|
|
var result string
|
|
if subScrapeConfig.hasConcat() {
|
|
result = subScrapeConfig.concatenateResults(found)
|
|
} else {
|
|
result = found[0]
|
|
}
|
|
|
|
result = subScrapeConfig.postProcess(result, ss)
|
|
return result
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
type postProcessMap map[string]string
|
|
|
|
func (p *postProcessMap) Apply(value string, q mappedQuery) string {
|
|
// return the mapped value if present
|
|
m := *p
|
|
mapped, ok := m[value]
|
|
|
|
if ok {
|
|
return mapped
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
type postProcessFeetToCm bool
|
|
|
|
func (p *postProcessFeetToCm) Apply(value string, q mappedQuery) string {
|
|
const foot_in_cm = 30.48
|
|
const inch_in_cm = 2.54
|
|
|
|
reg := regexp.MustCompile("[0-9]+")
|
|
filtered := reg.FindAllString(value, -1)
|
|
|
|
var feet float64
|
|
var inches float64
|
|
if len(filtered) > 0 {
|
|
feet, _ = strconv.ParseFloat(filtered[0], 64)
|
|
}
|
|
if len(filtered) > 1 {
|
|
inches, _ = strconv.ParseFloat(filtered[1], 64)
|
|
}
|
|
|
|
var centimeters = feet*foot_in_cm + inches*inch_in_cm
|
|
|
|
// Return rounded integer string
|
|
return strconv.Itoa(int(math.Round(centimeters)))
|
|
}
|
|
|
|
type postProcessLbToKg bool
|
|
|
|
func (p *postProcessLbToKg) Apply(value string, q mappedQuery) string {
|
|
const lb_in_kg = 0.45359237
|
|
w, err := strconv.ParseFloat(value, 64)
|
|
if err == nil {
|
|
w *= lb_in_kg
|
|
value = strconv.Itoa(int(math.Round(w)))
|
|
}
|
|
return value
|
|
}
|
|
|
|
type mappedPostProcessAction struct {
|
|
ParseDate string `yaml:"parseDate"`
|
|
SubtractDays bool `yaml:"subtractDays"`
|
|
Replace mappedRegexConfigs `yaml:"replace"`
|
|
SubScraper *mappedScraperAttrConfig `yaml:"subScraper"`
|
|
Map map[string]string `yaml:"map"`
|
|
FeetToCm bool `yaml:"feetToCm"`
|
|
LbToKg bool `yaml:"lbToKg"`
|
|
}
|
|
|
|
func (a mappedPostProcessAction) ToPostProcessAction() (postProcessAction, error) {
|
|
var found string
|
|
var ret postProcessAction
|
|
|
|
if a.ParseDate != "" {
|
|
found = "parseDate"
|
|
action := postProcessParseDate(a.ParseDate)
|
|
ret = &action
|
|
}
|
|
if len(a.Replace) > 0 {
|
|
if found != "" {
|
|
return nil, fmt.Errorf("post-process actions must have a single field, found %s and %s", found, "replace")
|
|
}
|
|
found = "replace"
|
|
action := postProcessReplace(a.Replace)
|
|
ret = &action
|
|
}
|
|
if a.SubScraper != nil {
|
|
if found != "" {
|
|
return nil, fmt.Errorf("post-process actions must have a single field, found %s and %s", found, "subScraper")
|
|
}
|
|
found = "subScraper"
|
|
action := postProcessSubScraper(*a.SubScraper)
|
|
ret = &action
|
|
}
|
|
if a.Map != nil {
|
|
if found != "" {
|
|
return nil, fmt.Errorf("post-process actions must have a single field, found %s and %s", found, "map")
|
|
}
|
|
found = "map"
|
|
action := postProcessMap(a.Map)
|
|
ret = &action
|
|
}
|
|
if a.FeetToCm {
|
|
if found != "" {
|
|
return nil, fmt.Errorf("post-process actions must have a single field, found %s and %s", found, "feetToCm")
|
|
}
|
|
found = "feetToCm"
|
|
action := postProcessFeetToCm(a.FeetToCm)
|
|
ret = &action
|
|
}
|
|
if a.LbToKg {
|
|
if found != "" {
|
|
return nil, fmt.Errorf("post-process actions must have a single field, found %s and %s", found, "lbToKg")
|
|
}
|
|
found = "lbToKg"
|
|
action := postProcessLbToKg(a.LbToKg)
|
|
ret = &action
|
|
}
|
|
if a.SubtractDays {
|
|
if found != "" {
|
|
return nil, fmt.Errorf("post-process actions must have a single field, found %s and %s", found, "subtractDays")
|
|
}
|
|
// found = "subtractDays"
|
|
action := postProcessSubtractDays(a.SubtractDays)
|
|
ret = &action
|
|
}
|
|
|
|
if ret == nil {
|
|
return nil, errors.New("invalid post-process action")
|
|
}
|
|
|
|
return ret, nil
|
|
}
|
|
|
|
type mappedScraperAttrConfig struct {
|
|
Selector string `yaml:"selector"`
|
|
Fixed string `yaml:"fixed"`
|
|
PostProcess []mappedPostProcessAction `yaml:"postProcess"`
|
|
Concat string `yaml:"concat"`
|
|
Split string `yaml:"split"`
|
|
|
|
postProcessActions []postProcessAction
|
|
|
|
// Deprecated: use PostProcess instead
|
|
ParseDate string `yaml:"parseDate"`
|
|
Replace mappedRegexConfigs `yaml:"replace"`
|
|
SubScraper *mappedScraperAttrConfig `yaml:"subScraper"`
|
|
}
|
|
|
|
type _mappedScraperAttrConfig mappedScraperAttrConfig
|
|
|
|
func (c *mappedScraperAttrConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
|
// try unmarshalling into a string first
|
|
if err := unmarshal(&c.Selector); err != nil {
|
|
// if it's a type error then we try to unmarshall to the full object
|
|
var typeErr *yaml.TypeError
|
|
if !errors.As(err, &typeErr) {
|
|
return err
|
|
}
|
|
|
|
// unmarshall to full object
|
|
// need it as a separate object
|
|
t := _mappedScraperAttrConfig{}
|
|
if err = unmarshal(&t); err != nil {
|
|
return err
|
|
}
|
|
|
|
*c = mappedScraperAttrConfig(t)
|
|
}
|
|
|
|
return c.convertPostProcessActions()
|
|
}
|
|
|
|
func (c *mappedScraperAttrConfig) convertPostProcessActions() error {
|
|
// ensure we don't have the old deprecated fields and the new post process field
|
|
if len(c.PostProcess) > 0 {
|
|
if c.ParseDate != "" || len(c.Replace) > 0 || c.SubScraper != nil {
|
|
return errors.New("cannot include postProcess and (parseDate, replace, subScraper) deprecated fields")
|
|
}
|
|
|
|
// convert xpathPostProcessAction actions to postProcessActions
|
|
for _, a := range c.PostProcess {
|
|
action, err := a.ToPostProcessAction()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.postProcessActions = append(c.postProcessActions, action)
|
|
}
|
|
|
|
c.PostProcess = nil
|
|
} else {
|
|
// convert old deprecated fields if present
|
|
// in same order as they used to be executed
|
|
if len(c.Replace) > 0 {
|
|
action := postProcessReplace(c.Replace)
|
|
c.postProcessActions = append(c.postProcessActions, &action)
|
|
c.Replace = nil
|
|
}
|
|
|
|
if c.SubScraper != nil {
|
|
action := postProcessSubScraper(*c.SubScraper)
|
|
c.postProcessActions = append(c.postProcessActions, &action)
|
|
c.SubScraper = nil
|
|
}
|
|
|
|
if c.ParseDate != "" {
|
|
action := postProcessParseDate(c.ParseDate)
|
|
c.postProcessActions = append(c.postProcessActions, &action)
|
|
c.ParseDate = ""
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c mappedScraperAttrConfig) hasConcat() bool {
|
|
return c.Concat != ""
|
|
}
|
|
|
|
func (c mappedScraperAttrConfig) hasSplit() bool {
|
|
return c.Split != ""
|
|
}
|
|
|
|
func (c mappedScraperAttrConfig) concatenateResults(nodes []string) string {
|
|
separator := c.Concat
|
|
return strings.Join(nodes, separator)
|
|
}
|
|
|
|
func (c mappedScraperAttrConfig) cleanResults(nodes []string) []string {
|
|
cleaned := utils.StrUnique(nodes) // remove duplicate values
|
|
cleaned = utils.StrDelete(cleaned, "") // remove empty values
|
|
return cleaned
|
|
}
|
|
|
|
func (c mappedScraperAttrConfig) splitString(value string) []string {
|
|
separator := c.Split
|
|
var res []string
|
|
|
|
if separator == "" {
|
|
return []string{value}
|
|
}
|
|
|
|
for _, str := range strings.Split(value, separator) {
|
|
if str != "" {
|
|
res = append(res, str)
|
|
}
|
|
}
|
|
|
|
return res
|
|
}
|
|
|
|
func (c mappedScraperAttrConfig) postProcess(value string, q mappedQuery) string {
|
|
for _, action := range c.postProcessActions {
|
|
value = action.Apply(value, q)
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
type mappedScrapers map[string]*mappedScraper
|
|
|
|
type mappedScraper struct {
|
|
Common commonMappedConfig `yaml:"common"`
|
|
Scene *mappedSceneScraperConfig `yaml:"scene"`
|
|
Gallery *mappedGalleryScraperConfig `yaml:"gallery"`
|
|
Performer *mappedPerformerScraperConfig `yaml:"performer"`
|
|
Movie *mappedMovieScraperConfig `yaml:"movie"`
|
|
}
|
|
|
|
type mappedResult map[string]string
|
|
type mappedResults []mappedResult
|
|
|
|
func (r mappedResult) apply(dest interface{}) {
|
|
destVal := reflect.ValueOf(dest)
|
|
|
|
// dest should be a pointer
|
|
destVal = destVal.Elem()
|
|
|
|
for key, value := range r {
|
|
field := destVal.FieldByName(key)
|
|
|
|
if field.IsValid() {
|
|
var reflectValue reflect.Value
|
|
if field.Kind() == reflect.Ptr {
|
|
// need to copy the value, otherwise everything is set to the
|
|
// same pointer
|
|
localValue := value
|
|
reflectValue = reflect.ValueOf(&localValue)
|
|
} else {
|
|
reflectValue = reflect.ValueOf(value)
|
|
}
|
|
|
|
field.Set(reflectValue)
|
|
} else {
|
|
logger.Errorf("Field %s does not exist in %T", key, dest)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r mappedResults) setKey(index int, key string, value string) mappedResults {
|
|
if index >= len(r) {
|
|
r = append(r, make(mappedResult))
|
|
}
|
|
|
|
logger.Debugf(`[%d][%s] = %s`, index, key, value)
|
|
r[index][key] = value
|
|
return r
|
|
}
|
|
|
|
func (s mappedScraper) scrapePerformer(q mappedQuery) (*models.ScrapedPerformer, error) {
|
|
var ret models.ScrapedPerformer
|
|
|
|
performerMap := s.Performer
|
|
if performerMap == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
performerTagsMap := performerMap.Tags
|
|
|
|
results := performerMap.process(q, s.Common)
|
|
if len(results) > 0 {
|
|
results[0].apply(&ret)
|
|
|
|
// now apply the tags
|
|
if performerTagsMap != nil {
|
|
logger.Debug(`Processing performer tags:`)
|
|
tagResults := performerTagsMap.process(q, s.Common)
|
|
|
|
for _, p := range tagResults {
|
|
tag := &models.ScrapedTag{}
|
|
p.apply(tag)
|
|
ret.Tags = append(ret.Tags, tag)
|
|
}
|
|
}
|
|
}
|
|
|
|
return &ret, nil
|
|
}
|
|
|
|
func (s mappedScraper) scrapePerformers(q mappedQuery) ([]*models.ScrapedPerformer, error) {
|
|
var ret []*models.ScrapedPerformer
|
|
|
|
performerMap := s.Performer
|
|
if performerMap == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
results := performerMap.process(q, s.Common)
|
|
for _, r := range results {
|
|
var p models.ScrapedPerformer
|
|
r.apply(&p)
|
|
ret = append(ret, &p)
|
|
}
|
|
|
|
return ret, nil
|
|
}
|
|
|
|
func (s mappedScraper) processScene(q mappedQuery, r mappedResult) *models.ScrapedScene {
|
|
var ret models.ScrapedScene
|
|
|
|
sceneScraperConfig := s.Scene
|
|
|
|
scenePerformersMap := sceneScraperConfig.Performers
|
|
sceneTagsMap := sceneScraperConfig.Tags
|
|
sceneStudioMap := sceneScraperConfig.Studio
|
|
sceneMoviesMap := sceneScraperConfig.Movies
|
|
|
|
scenePerformerTagsMap := scenePerformersMap.Tags
|
|
|
|
r.apply(&ret)
|
|
|
|
// process performer tags once
|
|
var performerTagResults mappedResults
|
|
if scenePerformerTagsMap != nil {
|
|
performerTagResults = scenePerformerTagsMap.process(q, s.Common)
|
|
}
|
|
|
|
// now apply the performers and tags
|
|
if scenePerformersMap.mappedConfig != nil {
|
|
logger.Debug(`Processing scene performers:`)
|
|
performerResults := scenePerformersMap.process(q, s.Common)
|
|
|
|
for _, p := range performerResults {
|
|
performer := &models.ScrapedPerformer{}
|
|
p.apply(performer)
|
|
|
|
for _, p := range performerTagResults {
|
|
tag := &models.ScrapedTag{}
|
|
p.apply(tag)
|
|
ret.Tags = append(ret.Tags, tag)
|
|
}
|
|
|
|
ret.Performers = append(ret.Performers, performer)
|
|
}
|
|
}
|
|
|
|
if sceneTagsMap != nil {
|
|
logger.Debug(`Processing scene tags:`)
|
|
tagResults := sceneTagsMap.process(q, s.Common)
|
|
|
|
for _, p := range tagResults {
|
|
tag := &models.ScrapedTag{}
|
|
p.apply(tag)
|
|
ret.Tags = append(ret.Tags, tag)
|
|
}
|
|
}
|
|
|
|
if sceneStudioMap != nil {
|
|
logger.Debug(`Processing scene studio:`)
|
|
studioResults := sceneStudioMap.process(q, s.Common)
|
|
|
|
if len(studioResults) > 0 {
|
|
studio := &models.ScrapedStudio{}
|
|
studioResults[0].apply(studio)
|
|
ret.Studio = studio
|
|
}
|
|
}
|
|
|
|
if sceneMoviesMap != nil {
|
|
logger.Debug(`Processing scene movies:`)
|
|
movieResults := sceneMoviesMap.process(q, s.Common)
|
|
|
|
for _, p := range movieResults {
|
|
movie := &models.ScrapedMovie{}
|
|
p.apply(movie)
|
|
ret.Movies = append(ret.Movies, movie)
|
|
}
|
|
}
|
|
|
|
return &ret
|
|
}
|
|
|
|
func (s mappedScraper) scrapeScenes(q mappedQuery) ([]*models.ScrapedScene, error) {
|
|
var ret []*models.ScrapedScene
|
|
|
|
sceneScraperConfig := s.Scene
|
|
sceneMap := sceneScraperConfig.mappedConfig
|
|
if sceneMap == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
logger.Debug(`Processing scenes:`)
|
|
results := sceneMap.process(q, s.Common)
|
|
for _, r := range results {
|
|
logger.Debug(`Processing scene:`)
|
|
ret = append(ret, s.processScene(q, r))
|
|
}
|
|
|
|
return ret, nil
|
|
}
|
|
|
|
func (s mappedScraper) scrapeScene(q mappedQuery) (*models.ScrapedScene, error) {
|
|
var ret models.ScrapedScene
|
|
|
|
sceneScraperConfig := s.Scene
|
|
sceneMap := sceneScraperConfig.mappedConfig
|
|
if sceneMap == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
logger.Debug(`Processing scene:`)
|
|
results := sceneMap.process(q, s.Common)
|
|
if len(results) > 0 {
|
|
ss := s.processScene(q, results[0])
|
|
ret = *ss
|
|
}
|
|
|
|
return &ret, nil
|
|
}
|
|
|
|
func (s mappedScraper) scrapeGallery(q mappedQuery) (*models.ScrapedGallery, error) {
|
|
var ret models.ScrapedGallery
|
|
|
|
galleryScraperConfig := s.Gallery
|
|
galleryMap := galleryScraperConfig.mappedConfig
|
|
if galleryMap == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
galleryPerformersMap := galleryScraperConfig.Performers
|
|
galleryTagsMap := galleryScraperConfig.Tags
|
|
galleryStudioMap := galleryScraperConfig.Studio
|
|
|
|
logger.Debug(`Processing gallery:`)
|
|
results := galleryMap.process(q, s.Common)
|
|
if len(results) > 0 {
|
|
results[0].apply(&ret)
|
|
|
|
// now apply the performers and tags
|
|
if galleryPerformersMap != nil {
|
|
logger.Debug(`Processing gallery performers:`)
|
|
performerResults := galleryPerformersMap.process(q, s.Common)
|
|
|
|
for _, p := range performerResults {
|
|
performer := &models.ScrapedPerformer{}
|
|
p.apply(performer)
|
|
ret.Performers = append(ret.Performers, performer)
|
|
}
|
|
}
|
|
|
|
if galleryTagsMap != nil {
|
|
logger.Debug(`Processing gallery tags:`)
|
|
tagResults := galleryTagsMap.process(q, s.Common)
|
|
|
|
for _, p := range tagResults {
|
|
tag := &models.ScrapedTag{}
|
|
p.apply(tag)
|
|
ret.Tags = append(ret.Tags, tag)
|
|
}
|
|
}
|
|
|
|
if galleryStudioMap != nil {
|
|
logger.Debug(`Processing gallery studio:`)
|
|
studioResults := galleryStudioMap.process(q, s.Common)
|
|
|
|
if len(studioResults) > 0 {
|
|
studio := &models.ScrapedStudio{}
|
|
studioResults[0].apply(studio)
|
|
ret.Studio = studio
|
|
}
|
|
}
|
|
}
|
|
|
|
return &ret, nil
|
|
}
|
|
|
|
func (s mappedScraper) scrapeMovie(q mappedQuery) (*models.ScrapedMovie, error) {
|
|
var ret models.ScrapedMovie
|
|
|
|
movieScraperConfig := s.Movie
|
|
movieMap := movieScraperConfig.mappedConfig
|
|
if movieMap == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
movieStudioMap := movieScraperConfig.Studio
|
|
|
|
results := movieMap.process(q, s.Common)
|
|
if len(results) > 0 {
|
|
results[0].apply(&ret)
|
|
|
|
if movieStudioMap != nil {
|
|
logger.Debug(`Processing movie studio:`)
|
|
studioResults := movieStudioMap.process(q, s.Common)
|
|
|
|
if len(studioResults) > 0 {
|
|
studio := &models.ScrapedStudio{}
|
|
studioResults[0].apply(studio)
|
|
ret.Studio = studio
|
|
}
|
|
}
|
|
}
|
|
|
|
return &ret, nil
|
|
}
|