diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..c9039ac --- /dev/null +++ b/.travis.yml @@ -0,0 +1,9 @@ +language: go +go: + - 1.12.x + - 1.13.x + +script: + - export GO111MODULE=on + - go build + - GOARCH=arm GOARM=7 go build \ No newline at end of file diff --git a/README.md b/README.md index 9dd0049..6e4f026 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Price-Tracker +[![Build Status](https://travis-ci.org/xiahongze/pricetracker.svg?branch=master)](https://travis-ci.org/xiahongze/pricetracker) ## Introduction diff --git a/go.mod b/go.mod index 743db39..279ae2d 100644 --- a/go.mod +++ b/go.mod @@ -4,24 +4,24 @@ go 1.12 require ( cloud.google.com/go v0.30.0 + github.com/antchfx/htmlquery v1.2.0 + github.com/antchfx/xpath v1.1.2 // indirect github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4 github.com/chromedp/chromedp v0.5.1 + github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9 // indirect github.com/google/go-cmp v0.3.1 // indirect github.com/googleapis/gax-go v2.0.0+incompatible // indirect - github.com/kr/pretty v0.1.0 // indirect github.com/labstack/echo v0.0.0-20180911044237-1abaa3049251 github.com/labstack/gommon v0.2.7 // indirect github.com/mattn/go-isatty v0.0.4 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect go.opencensus.io v0.17.0 // indirect golang.org/x/crypto v0.0.0-20181012144002-a92615f3c490 // indirect - golang.org/x/net v0.0.0-20181011144130-49bb7cea24b1 + golang.org/x/net v0.0.0-20181011144130-49bb7cea24b1 // indirect golang.org/x/oauth2 v0.0.0-20181003184128-c57b0facaced // indirect golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e // indirect google.golang.org/api v0.0.0-20181012000736-72df7e5ac770 google.golang.org/appengine v1.2.0 // indirect google.golang.org/genproto v0.0.0-20181004005441-af9cb2a35e7f // indirect google.golang.org/grpc v1.15.0 // indirect - gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect - gopkg.in/xmlpath.v2 v2.0.0-20150820204837-860cbeca3ebc ) diff --git a/go.sum b/go.sum index 41f0df0..edd6878 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,10 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT cloud.google.com/go v0.30.0 h1:xKvyLgk56d0nksWq49J0UyGEeUIicTl4+UBiX1NPX9g= cloud.google.com/go v0.30.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= +github.com/antchfx/htmlquery v1.2.0 h1:oKShnsGlnOHX6t4uj5OHgLKkABcJoqnXpqnscoi9Lpw= +github.com/antchfx/htmlquery v1.2.0/go.mod h1:MS9yksVSQXls00iXkiMqXr0J+umL/AmxXKuP28SUJM8= +github.com/antchfx/xpath v1.1.2 h1:YziPrtM0gEJBnhdUGxYcIVYXZ8FXbtbovxOi+UW/yWQ= +github.com/antchfx/xpath v1.1.2/go.mod h1:Yee4kTMuNiPYJ7nSNorELQMr1J33uOpXDMByNYhvtNk= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/chromedp/cdproto v0.0.0-20191009033829-c22f49c9ff0a/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g= github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4 h1:QD3KxSJ59L2lxG6MXBjNHxiQO2RmxTQ3XcK+wO44WOg= @@ -21,6 +25,8 @@ github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9 h1:uHTyIjqVhYRhLbJ8nIiOJHkEZZ+5YoOsAbD3sk82NiE= +github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= @@ -32,11 +38,6 @@ github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08 h1:V0an7KRw92wmJysvFvtqtKMAPmvS5O0jtB0nYo6t+gs= github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08/go.mod h1:dFWs1zEqDjFtnBXsd1vPOZaLsESovai349994nHx3e0= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/labstack/echo v0.0.0-20180911044237-1abaa3049251 h1:4q++nZ4OEtmbHazhA/7i3T9B+CBWtnHpuMMcW55ZjRk= github.com/labstack/echo v0.0.0-20180911044237-1abaa3049251/go.mod h1:rWD2DNQgFb1IY9lVYZVLWn2Ko4dyHZ/LpHORyBLP3hI= github.com/labstack/gommon v0.0.0-20180312174116-6fe1405d73ec/go.mod h1:/tj9csK2iPSBvn+3NLM9e52usepMtrd5ilFYA+wQNJ4= @@ -102,8 +103,4 @@ google.golang.org/genproto v0.0.0-20181004005441-af9cb2a35e7f/go.mod h1:JiN7NxoA google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.15.0 h1:Az/KuahOM4NAidTEuJCv/RonAA7rYsTPkqXVjr+8OOw= google.golang.org/grpc v1.15.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/xmlpath.v2 v2.0.0-20150820204837-860cbeca3ebc h1:LMEBgNcZUqXaP7evD1PZcL6EcDVa2QOFuI+cqM3+AJM= -gopkg.in/xmlpath.v2 v2.0.0-20150820204837-860cbeca3ebc/go.mod h1:N8UOSI6/c2yOpa/XDz3KVUiegocTziPiqNkeNTMiG1k= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/gutils/converters.go b/gutils/converters.go index 8be97e4..538d27f 100644 --- a/gutils/converters.go +++ b/gutils/converters.go @@ -9,7 +9,7 @@ import ( // ConvReq2Ent converts CreateRequest to Entity in datastore func ConvReq2Ent(req *models.CreateRequest) models.Entity { return models.Entity{ - Options: *req.Options, + Options: req.Options, URL: req.URL, Name: req.Name, XPATH: req.XPATH, diff --git a/gutils/tasks.go b/gutils/tasks.go index 0bc7674..f488115 100644 --- a/gutils/tasks.go +++ b/gutils/tasks.go @@ -15,14 +15,21 @@ import ( var priceRegex, _ = regexp.Compile("\\d+\\.?\\d{0,}") -func processEntity(ent *models.Entity, pushClient *pushover.Client) { +func processEntity(ent *models.Entity, pushClient *pushover.Client) (err error) { // save the entity before returning defer func() { + if err != nil { + log.Printf("ERROR: %v", err) + key, _ := ent.K.MarshalJSON() + log.Printf("INFO: URL: %s\tXPATH: %s\tKey: %s", ent.URL, ent.XPATH, key) + // do not check again after 30 minutes + ent.NextCheck = ent.NextCheck.Add(time.Minute * 30) + } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(CancelWaitTime)) + defer cancel() if err := ent.Save(ctx, EntityType, DsClient, true); err != nil { log.Printf("ERROR: failed to save entity [%s] with %v", ent.Name, err) } - cancel() }() msg := pushover.Message{ @@ -31,23 +38,18 @@ func processEntity(ent *models.Entity, pushClient *pushover.Client) { } var tracker trackers.Tracker = trackers.SimpleTracker - if ent.Options.UseChrome != nil && *ent.Options.UseChrome { + if ent.Options.UseChrome { tracker = trackers.ChromeTracker } - content, ok := tracker(&ent.URL, &ent.XPATH) - if !ok { - log.Println("ERROR: failed to fetch price.", content) - key, _ := ent.K.MarshalJSON() - log.Printf("URL: %s\nXPATH: %s\nKey: %s", ent.URL, ent.XPATH, key) - msg.Title = fmt.Sprintf("[%s] Alert: failed to fetch price because`%s`!", ent.Name, content) + content, err := tracker(&ent.URL, &ent.XPATH) + if err != nil { + msg.Title = fmt.Sprintf("[%s] Alert: failed to fetch price `%v`!", ent.Name, err) pushClient.Send(&msg) - // do not check again after 30 minutes - ent.NextCheck.Add(time.Minute * 30) return } if ent.History == nil { - log.Println("WARN: zero price history.", ent) + log.Println("WARN: zero price history") ent.History = []models.DataPoint{{Price: content, Timestamp: time.Now()}} return } @@ -55,11 +57,8 @@ func processEntity(ent *models.Entity, pushClient *pushover.Client) { last := ent.History[len(ent.History)-1] thisP, err := strconv.ParseFloat(priceRegex.FindString(content), 32) if err != nil { - log.Println("ERROR: failed to convert price", err, "this price:", content) msg.Title = fmt.Sprintf("[%s] Alert: failed to convert price `%s`!", ent.Name, content) pushClient.Send(&msg) - // do not check again after 30 minutes - ent.NextCheck.Add(time.Minute * 30) return } @@ -70,6 +69,7 @@ func processEntity(ent *models.Entity, pushClient *pushover.Client) { if deltaRecordCnt > 0 { ent.History = ent.History[deltaRecordCnt:] } + msg.Msg = ent.String() // update message // send alert if ent.Options.AlertType == "onChange" && content != last.Price { msg.Title = fmt.Sprintf("[%s] Alert: price changes to %s!", ent.Name, content) @@ -79,6 +79,7 @@ func processEntity(ent *models.Entity, pushClient *pushover.Client) { msg.Title = fmt.Sprintf("[%s] Alert: price drops to %s!", ent.Name, content) pushClient.Send(&msg) } + return } // Refresh refreshes prices from datastore diff --git a/handlers/create.go b/handlers/create.go index 87c8774..11904d4 100644 --- a/handlers/create.go +++ b/handlers/create.go @@ -16,31 +16,28 @@ import ( // MakeCreate creates create handler request func MakeCreate(client *pushover.Client) echo.HandlerFunc { return func(c echo.Context) error { + var ( + content string + err error + ) + req := &models.CreateRequest{} - if err := c.Bind(req); err != nil { + if err = c.Bind(req); err != nil { return c.String(http.StatusBadRequest, err.Error()) } - if msg, ok := req.Validate(); !ok { - return c.String(http.StatusBadRequest, msg) + if err = req.Validate(); err != nil { + return c.String(http.StatusBadRequest, err.Error()) } - var ( - content string - ok bool - useChrome = true - ) - - if req.Options.UseChrome == nil || !*req.Options.UseChrome { - content, ok = trackers.SimpleTracker(&req.URL, &req.XPATH) + if !req.Options.UseChrome { + content, err = trackers.SimpleTracker(&req.URL, &req.XPATH) } - if !ok { - req.Options.UseChrome = &useChrome + if err != nil || req.Options.UseChrome { + req.Options.UseChrome = true log.Println("INFO: Resorting to Chrome") - } - if req.Options.UseChrome != nil && *req.Options.UseChrome { - if content, ok = trackers.ChromeTracker(&req.URL, &req.XPATH); !ok { - return c.String(http.StatusBadRequest, content) + if content, err = trackers.ChromeTracker(&req.URL, &req.XPATH); err != nil { + return c.String(http.StatusBadRequest, err.Error()) } } diff --git a/handlers/update.go b/handlers/update.go index d98a3e4..8d88088 100644 --- a/handlers/update.go +++ b/handlers/update.go @@ -39,8 +39,8 @@ func MakeUpdate(client *pushover.Client) echo.HandlerFunc { if req.Options.Threshold != 0 { entity.Options.Threshold = req.Options.Threshold } - if req.Options.UseChrome != nil { - entity.Options.UseChrome = req.Options.UseChrome + if req.UseChrome != nil { + entity.Options.UseChrome = *req.UseChrome } if req.Name != "" { diff --git a/models/requests.go b/models/requests.go index 31997f1..b9231e9 100644 --- a/models/requests.go +++ b/models/requests.go @@ -2,6 +2,8 @@ package models import "cloud.google.com/go/datastore" +import "fmt" + type ( // Options is the options for an entry Options struct { @@ -10,25 +12,26 @@ type ( AlertType string `json:"alertType"` Threshold float32 `json:"threshold"` MaxRecords int16 `json:"maxRecords"` - UseChrome *bool `json:"useChrome"` + UseChrome bool `json:"useChrome"` } // CreateRequest defines the contract to add an entry CreateRequest struct { - URL string `json:"url"` - XPATH string `json:"xpath"` - Name string `json:"name"` - ExpectedPrice string `json:"expectedPrice"` - Options *Options `json:"options"` + URL string `json:"url"` + XPATH string `json:"xpath"` + Name string `json:"name"` + ExpectedPrice string `json:"expectedPrice"` + Options Options `json:"options,omitempty"` } // UpdateRequest defines the contract to update an entry UpdateRequest struct { - URL string `json:"url"` - XPATH string `json:"xpath"` - Name string `json:"name"` - Key *datastore.Key `json:"key"` - Options *Options `json:"options"` + URL string `json:"url"` + XPATH string `json:"xpath"` + Name string `json:"name"` + Key *datastore.Key `json:"key"` + UseChrome *bool `json:"useChrome,omitempty"` + Options *Options `json:"options,omitempty"` } // ReadOrDelRequest defines the contract to read/delete an entry @@ -38,21 +41,21 @@ type ( ) // Validate validates -func (r *CreateRequest) Validate() (string, bool) { +func (r *CreateRequest) Validate() error { if r.URL == "" { - return "url is not set", false + return fmt.Errorf("url is not set") } if r.XPATH == "" { - return "xpath is not set", false + return fmt.Errorf("xpath is not set") } if r.Name == "" { - return "name is not set", false + return fmt.Errorf("name is not set") } if r.ExpectedPrice == "" { - return "expectedPrice is not set", false + return fmt.Errorf("ExpectedPrice is not set") } r.Options.setDefault() - return "", true + return nil } func (o *Options) setDefault() { @@ -71,20 +74,20 @@ func (o *Options) setDefault() { } // Validate validates -func (r *ReadOrDelRequest) Validate() (string, bool) { +func (r *ReadOrDelRequest) Validate() error { if r.Key == nil { - return "key is not given", false + return fmt.Errorf("key is not given") } - return "", true + return nil } // Validate validates -func (r *UpdateRequest) Validate() (string, bool) { +func (r *UpdateRequest) Validate() error { if r.Key == nil { - return "key is not given", false + return fmt.Errorf("key is not given") } if r.Options == nil { - return "options is not given", false + return fmt.Errorf("options is not given") } - return "", true + return nil } diff --git a/tests/chromedp_test.go b/tests/chromedp_test.go index bf28ff3..542df8b 100644 --- a/tests/chromedp_test.go +++ b/tests/chromedp_test.go @@ -7,12 +7,23 @@ import ( "github.com/xiahongze/pricetracker/trackers" ) -func TestChromedp(t *testing.T) { +func TestColes(t *testing.T) { url := "https://shop.coles.com.au/a/a-nsw-metro-westmead/product/goldn-canola-canola-oil" xpath := `//span/strong[@class="product-price"]` - price, ok := trackers.ChromeTracker(&url, &xpath) - if !ok { - t.Errorf("can't fetch price from %s with %s", url, xpath) + price, err := trackers.ChromeTracker(&url, &xpath) + if err != nil { + t.Errorf("can't fetch price from %s with %s error: %v", url, xpath, err) + return + } + log.Printf("price: %s", price) +} + +func TestChemist(t *testing.T) { + url := "https://www.chemistwarehouse.com.au/buy/1062/beconase-hayfever-nasal-spray-200-doses" + xpath := `//span[@class="product__price"] | //div[@class="product__price"]` + price, err := trackers.ChromeTracker(&url, &xpath) + if err != nil { + t.Errorf("can't fetch price from %s with %s error: %v", url, xpath, err) return } log.Printf("price: %s", price) diff --git a/tests/simpletracker_test.go b/tests/simpletracker_test.go new file mode 100644 index 0000000..fd1f8e8 --- /dev/null +++ b/tests/simpletracker_test.go @@ -0,0 +1,19 @@ +package main + +import ( + "log" + "testing" + + "github.com/xiahongze/pricetracker/trackers" +) + +func TestChemistSimple(t *testing.T) { + url := "https://www.chemistwarehouse.com.au/buy/1062/beconase-hayfever-nasal-spray-200-doses" + xpath := `//span[@class="product__price"] | //div[@class="product__price"]` + price, err := trackers.SimpleTracker(&url, &xpath) + if err != nil { + t.Errorf("can't fetch price from %s with %s error: %v", url, xpath, err) + return + } + log.Printf("price: %s", price) +} diff --git a/trackers/chrome_tracker.go b/trackers/chrome_tracker.go index fc1ce05..d6766ed 100644 --- a/trackers/chrome_tracker.go +++ b/trackers/chrome_tracker.go @@ -68,8 +68,8 @@ func init() { } // ChromeTracker uses headless chrome to fetch content from given url and xpath -// and returns content/error message, ok -func ChromeTracker(url, xpath *string) (string, bool) { +// and returns content, error +func ChromeTracker(url, xpath *string) (res string, err error) { ctx, cancel := context.WithTimeout(context.Background(), chromeTimeout) defer cancel() ctx, cancel = chromedp.NewExecAllocator(ctx, chromeOpts...) @@ -77,17 +77,15 @@ func ChromeTracker(url, xpath *string) (string, bool) { ctx, cancel = chromedp.NewContext(ctx) defer cancel() - var res string + log.Printf("INFO: loading %s", *url) - err := chromedp.Run(ctx, + err = chromedp.Run(ctx, hide, chromedp.Navigate(*url), chromedp.Text(*xpath, &res, chromedp.NodeVisible, chromedp.BySearch), ) + res = strings.TrimSpace(res) + res = strings.Replace(res, "\n", " ", -1) - if err != nil { - log.Printf("WARN: failed to fetch with chromedp with %v", err) - } - - return strings.TrimSpace(res), true + return } diff --git a/trackers/simple_tracker.go b/trackers/simple_tracker.go index ad22178..9791608 100644 --- a/trackers/simple_tracker.go +++ b/trackers/simple_tracker.go @@ -1,73 +1,35 @@ package trackers import ( - "bytes" - "io" - "io/ioutil" + "fmt" "log" - "net/http" "strings" - "golang.org/x/net/html" - "gopkg.in/xmlpath.v2" + "github.com/antchfx/htmlquery" ) // SimpleTracker accepts url and xpath to extract content -// and returns content/error message, ok -func SimpleTracker(url, xpath *string) (content string, ok bool) { +// and returns content, error message +func SimpleTracker(url, xpath *string) (content string, err error) { defer func() { - if !ok { - log.Println(content) + if err == nil { + log.Printf("INFO: Found innerText=%s", content) } - log.Println("INFO: Found", content, "from", *url) }() - xpExec, err := xmlpath.Compile(*xpath) + log.Printf("INFO: loading %s", *url) + doc, err := htmlquery.LoadURL(*url) if err != nil { - content = "ERROR: failed to compile xpath %s" + *xpath - ok = false return } - - resp, getErr := http.Get(*url) - if getErr != nil { - content = "ERROR: failed to fetch the website" - ok = false + elem := htmlquery.FindOne(doc, *xpath) + if elem == nil { + err = fmt.Errorf("WARN: failed to find element with `%s`", *xpath) return } - - body, _ := ioutil.ReadAll(resp.Body) - - // create closure - extractHelper := func(reader io.Reader) { - xmlRoot, xmlErr := xmlpath.ParseHTML(reader) - if xmlErr != nil { - content = "ERROR: parse xml error: " + xmlErr.Error() - ok = false - return - } - content, ok = xpExec.String(xmlRoot) - content = strings.TrimSpace(content) - if !ok { - content = "value not found" - return - } - } - - // step 1. read directly from body - extractHelper(bytes.NewReader(body)) - - // step 2. try clean up HTML and do it again - if !ok { - root, err := html.Parse(bytes.NewReader(body)) - if err != nil { - content = "ERROR: parse html" + err.Error() - return - } - var b bytes.Buffer - html.Render(&b, root) - extractHelper(bytes.NewReader(b.Bytes())) - } + content = htmlquery.InnerText(elem) + content = strings.TrimSpace(content) + content = strings.Replace(content, "\n", " ", -1) return } diff --git a/trackers/tracker.go b/trackers/tracker.go index df55615..faa7da3 100644 --- a/trackers/tracker.go +++ b/trackers/tracker.go @@ -1,4 +1,4 @@ package trackers // Tracker is the type future implementation should follow -type Tracker func(url, xpath *string) (string, bool) +type Tracker func(url, xpath *string) (string, error)