├── cache └── .gitkeep ├── .gitignore ├── Makefile ├── .github └── workflows │ └── test.yml ├── go.mod ├── LICENSE.txt ├── cmd └── getwishlist │ └── main.go ├── README.md ├── pkg └── amazon │ ├── item.go │ ├── wishlist.go │ └── wishlist_test.go └── go.sum /cache/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | cache/* 2 | !cache/.gitkeep 3 | wishlist-*.html 4 | pkg/amazon/cache 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: vet test 2 | test: 3 | go test -race -p 1 -cover -timeout 30s ./... 4 | vet: 5 | go vet ./... 6 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Test 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go-version: [1.12.x, 1.13.x] 8 | platform: [ubuntu-latest, macos-latest, windows-latest] 9 | runs-on: ${{ matrix.platform }} 10 | steps: 11 | - name: Install Go 12 | uses: actions/setup-go@v1 13 | with: 14 | go-version: ${{ matrix.go-version }} 15 | - name: Checkout code 16 | uses: actions/checkout@v1 17 | - name: Test 18 | run: make 19 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cheshire137/gogoamazonwish 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/PuerkitoBio/goquery v1.5.0 // indirect 7 | github.com/andybalholm/cascadia v1.1.0 // indirect 8 | github.com/antchfx/htmlquery v1.2.1 // indirect 9 | github.com/antchfx/xmlquery v1.2.2 // indirect 10 | github.com/antchfx/xpath v1.1.2 // indirect 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/gobwas/glob v0.2.3 // indirect 13 | github.com/gocolly/colly v1.2.0 14 | github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9 // indirect 15 | github.com/golang/protobuf v1.3.2 // indirect 16 | github.com/kennygrant/sanitize v1.2.4 // indirect 17 | github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca // indirect 18 | github.com/stretchr/testify v1.4.0 19 | github.com/temoto/robotstxt v1.1.1 // indirect 20 | golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553 // indirect 21 | google.golang.org/appengine v1.6.5 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Sarah Vessels 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cmd/getwishlist/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | "github.com/cheshire137/gogoamazonwish/pkg/amazon" 9 | ) 10 | 11 | func main() { 12 | if len(os.Args) < 2 { 13 | fmt.Println("Usage: Amazon_wishlist_URL [proxy URL]...") 14 | fmt.Println("\tspecify optional proxy URLs at the end") 15 | os.Exit(1) 16 | } 17 | url := os.Args[1] 18 | fmt.Printf("Got URL: %s\n", url) 19 | 20 | proxyURLs := []string{} 21 | if len(os.Args) > 3 { 22 | for i := 3; i < len(os.Args); i++ { 23 | proxyURLs = append(proxyURLs, os.Args[i]) 24 | } 25 | } 26 | 27 | wishlist, err := amazon.NewWishlist(url) 28 | if err != nil { 29 | log.Fatalln(err) 30 | } 31 | 32 | wishlist.DebugMode = true 33 | wishlist.SetProxyURLs(proxyURLs...) 34 | 35 | name, err := wishlist.Name() 36 | if err != nil { 37 | log.Fatalln(err) 38 | } 39 | fmt.Println(name) 40 | 41 | printURL, err := wishlist.PrintURL() 42 | if err != nil { 43 | log.Fatalln(err) 44 | } 45 | fmt.Printf("Printable URL: <%s>\n", printURL) 46 | 47 | items, err := wishlist.Items() 48 | if err != nil { 49 | log.Fatalln(err) 50 | } 51 | fmt.Printf("Found %d item(s):\n\n", len(items)) 52 | number := 1 53 | for _, item := range items { 54 | fmt.Printf("%d) %s\n\n", number, item) 55 | number++ 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GoGo Amazon Wish 2 | 3 | ![](https://github.com/cheshire137/gogoamazonwish/workflows/.github/workflows/test.yml/badge.svg) 4 | 5 | A Go library to get items from an Amazon wishlist. Unofficial as Amazon 6 | shut down their wishlist API. This uses web scraping to get the items 7 | off a specified wishlist. 8 | 9 | ## How to use 10 | 11 | See [the docs](https://godoc.org/github.com/cheshire137/gogoamazonwish/pkg/amazon). 12 | 13 | ```sh 14 | go get -u github.com/cheshire137/gogoamazonwish/pkg/amazon 15 | ``` 16 | 17 | ```go 18 | import ( 19 | "fmt" 20 | "log" 21 | 22 | "github.com/cheshire137/gogoamazonwish/pkg/amazon" 23 | ) 24 | 25 | func main() { 26 | url := "https://www.amazon.com/hz/wishlist/ls/3I6EQPZ8OB1DT" 27 | wishlist, err := amazon.NewWishlist(url) 28 | if err != nil { 29 | log.Fatalln(err) 30 | } 31 | 32 | items, err := wishlist.Items() 33 | if err != nil { 34 | log.Fatalln(err) 35 | } 36 | 37 | fmt.Printf("Found %d item(s):\n\n", len(items)) 38 | number := 1 39 | for _itemID, item := range items { 40 | fmt.Printf("%d) %s\n\n", number, item) 41 | number++ 42 | } 43 | } 44 | ``` 45 | 46 | ## How to develop 47 | 48 | I built this with Go version 1.13.4. There's a command-line tool to test 49 | loading an Amazon wishlist that you can run via: 50 | 51 | `go run cmd/getwishlist/main.go` _URL to Amazon wishlist_ _[proxy URL]..._ 52 | 53 | You can specify optional proxy URLs to hit Amazon with. Might be useful if you're 54 | hitting errors about Amazon thinking the tool is a bot. 55 | 56 | Sample use: 57 | 58 | ```sh 59 | go run cmd/getwishlist/main.go "https://www.amazon.com/hz/wishlist/ls/3I6EQPZ8OB1DT" 60 | ``` 61 | 62 | To run tests: `make` 63 | 64 | ## Thanks 65 | 66 | - [Colly web scraper](http://go-colly.org) 67 | - [Increase your scraping speed with Go and Colly! — Advanced Part](https://medium.com/swlh/increase-your-scraping-speed-with-go-and-colly-advanced-part-a38648111ab2) 68 | -------------------------------------------------------------------------------- /pkg/amazon/item.go: -------------------------------------------------------------------------------- 1 | package amazon 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | // Item represents a product on an Amazon wishlist. 11 | type Item struct { 12 | // IsPrime indicates whether the product is eligible for Amazon Prime free 13 | // shipping. 14 | IsPrime bool 15 | 16 | // DirectURL is the URL to view this product on Amazon. 17 | DirectURL string 18 | 19 | // AddToCartURL is the URL to add this product to your shopping cart on Amazon, 20 | // tied to the particular wishlist it came from. 21 | AddToCartURL string 22 | 23 | // ImageURL is the URL of an image that represents this product. 24 | ImageURL string 25 | 26 | // ReviewsURL is the URL to view customer reviews of this product. 27 | ReviewsURL string 28 | 29 | // ReviewCount is how many reviews customers have left for this product on Amazon. 30 | ReviewCount int 31 | 32 | // RequestedCount is how many of the product the wishlist recipient would like 33 | // to receive. 34 | RequestedCount int 35 | 36 | // OwnedCount is how many of the product the wishlist recipient already owns. 37 | OwnedCount int 38 | 39 | // Name is the name of this product. 40 | Name string 41 | 42 | // Price is a string representation of the cost of this product on Amazon. 43 | Price string 44 | 45 | // ID is a unique identifier for this product on Amazon. 46 | ID string 47 | 48 | // DateAdded is a string representation of when this item was added to the 49 | // wishlist. Example: "October 20, 2019" 50 | RawDateAdded string 51 | 52 | // Rating is a string description of how Amazon customers have rated this 53 | // product. 54 | Rating string 55 | } 56 | 57 | // NewItem constructs an Item with the given product identifier, name, and 58 | // URL to its Amazon page. 59 | func NewItem(id string, name string, directURL string) *Item { 60 | return &Item{ 61 | DirectURL: directURL, 62 | Name: name, 63 | ID: id, 64 | IsPrime: false, 65 | ReviewCount: 0, 66 | RequestedCount: -1, 67 | OwnedCount: -1, 68 | } 69 | } 70 | 71 | // DateAdded returns the date this item was added to the wishlist. 72 | func (i *Item) DateAdded() (*time.Time, error) { 73 | if i.RawDateAdded == "" { 74 | return nil, fmt.Errorf("No date added found for item %s", i.ID) 75 | } 76 | 77 | date, err := time.Parse("January 2, 2006", i.RawDateAdded) 78 | if err != nil { 79 | return nil, err 80 | } 81 | 82 | return &date, nil 83 | } 84 | 85 | // URL returns a string URL to this product on Amazon. Prefers the link that 86 | // ties this product to the wishlist it came from, if known. 87 | func (i *Item) URL() string { 88 | if i.AddToCartURL != "" { 89 | return i.AddToCartURL 90 | } 91 | return i.DirectURL 92 | } 93 | 94 | // String returns a description of this product. 95 | func (i *Item) String() string { 96 | var sb strings.Builder 97 | url := i.URL() 98 | 99 | if i.Name != "" { 100 | sb.WriteString(i.Name) 101 | sb.WriteString("\n") 102 | } 103 | 104 | line := strings.TrimSpace(strings.Join([]string{ 105 | i.Price, 106 | i.Rating, 107 | }, " ")) 108 | if line != "" { 109 | sb.WriteString("\t") 110 | sb.WriteString(line) 111 | sb.WriteString("\n") 112 | } 113 | 114 | if i.RawDateAdded != "" { 115 | sb.WriteString("\tAdded ") 116 | sb.WriteString(i.RawDateAdded) 117 | sb.WriteString("\n") 118 | } 119 | 120 | if i.IsPrime { 121 | sb.WriteString("\tPrime\n") 122 | } 123 | 124 | if i.ReviewCount > 0 || i.ReviewsURL != "" { 125 | sb.WriteString("\t") 126 | if i.ReviewCount > 0 { 127 | units := "review" 128 | if i.ReviewCount != 1 { 129 | units = units + "s" 130 | } 131 | sb.WriteString(strconv.Itoa(i.ReviewCount)) 132 | sb.WriteString(" ") 133 | sb.WriteString(units) 134 | } 135 | if i.ReviewsURL != "" { 136 | sb.WriteString(" <") 137 | sb.WriteString(i.ReviewsURL) 138 | sb.WriteString(">\n") 139 | } 140 | } 141 | 142 | if url != "" { 143 | sb.WriteString("\t<") 144 | sb.WriteString(url) 145 | sb.WriteString(">\n") 146 | } 147 | 148 | if i.ImageURL != "" { 149 | sb.WriteString("\tImage: <") 150 | sb.WriteString(i.ImageURL) 151 | sb.WriteString(">\n") 152 | } 153 | 154 | if i.RequestedCount > -1 || i.OwnedCount > -1 { 155 | if i.RequestedCount > -1 { 156 | sb.WriteString("\tQuantity: ") 157 | sb.WriteString(strconv.Itoa(i.RequestedCount)) 158 | } 159 | if i.OwnedCount > -1 { 160 | if i.RequestedCount > -1 { 161 | sb.WriteString(" / ") 162 | } else { 163 | sb.WriteString("\t") 164 | } 165 | sb.WriteString("Has: ") 166 | sb.WriteString(strconv.Itoa(i.OwnedCount)) 167 | } 168 | } 169 | 170 | return strings.TrimSuffix(sb.String(), "\n") 171 | } 172 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/PuerkitoBio/goquery v1.5.0 h1:uGvmFXOA73IKluu/F84Xd1tt/z07GYm8X49XKHP7EJk= 2 | github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg= 3 | github.com/andybalholm/cascadia v1.0.0 h1:hOCXnnZ5A+3eVDX8pvgl4kofXv2ELss0bKcqRySc45o= 4 | github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= 5 | github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo= 6 | github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= 7 | github.com/antchfx/htmlquery v1.2.1 h1:bSH+uvb5fh6gLAi2UXVwD4qGJVNJi9P+46gvPhZ+D/s= 8 | github.com/antchfx/htmlquery v1.2.1/go.mod h1:MS9yksVSQXls00iXkiMqXr0J+umL/AmxXKuP28SUJM8= 9 | github.com/antchfx/xmlquery v1.2.2 h1:5FHCVxIjULz8pYI8n+MwbdblnLDmK6LQJicRy/aCtTI= 10 | github.com/antchfx/xmlquery v1.2.2/go.mod h1:/+CnyD/DzHRnv2eRxrVbieRU/FIF6N0C+7oTtyUtCKk= 11 | github.com/antchfx/xpath v1.1.2 h1:YziPrtM0gEJBnhdUGxYcIVYXZ8FXbtbovxOi+UW/yWQ= 12 | github.com/antchfx/xpath v1.1.2/go.mod h1:Yee4kTMuNiPYJ7nSNorELQMr1J33uOpXDMByNYhvtNk= 13 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 15 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= 17 | github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= 18 | github.com/gocolly/colly v1.2.0 h1:qRz9YAn8FIH0qzgNUw+HT9UN7wm1oF9OBAilwEWpyrI= 19 | github.com/gocolly/colly v1.2.0/go.mod h1:Hof5T3ZswNVsOHYmba1u03W65HDWgpV5HifSuueE0EA= 20 | github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9 h1:uHTyIjqVhYRhLbJ8nIiOJHkEZZ+5YoOsAbD3sk82NiE= 21 | github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 22 | github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= 23 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 24 | github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= 25 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 26 | github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o= 27 | github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak= 28 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 29 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 30 | github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca h1:NugYot0LIVPxTvN8n+Kvkn6TrbMyxQiuvKdEwFdR9vI= 31 | github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= 32 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 33 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 34 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 35 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 36 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 37 | github.com/temoto/robotstxt v1.1.1 h1:Gh8RCs8ouX3hRSxxK7B1mO5RFByQ4CmJZDwgom++JaA= 38 | github.com/temoto/robotstxt v1.1.1/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo= 39 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 40 | golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 41 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 42 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 43 | golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553 h1:efeOvDhwQ29Dj3SdAV/MJf8oukgn+8D8WgaCaRMchF8= 44 | golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 45 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 46 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 47 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 48 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 49 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 50 | google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM= 51 | google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 52 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 53 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 54 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 55 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 56 | -------------------------------------------------------------------------------- /pkg/amazon/wishlist.go: -------------------------------------------------------------------------------- 1 | package amazon 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/url" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/gocolly/colly" 12 | "github.com/gocolly/colly/extensions" 13 | "github.com/gocolly/colly/proxy" 14 | ) 15 | 16 | const ( 17 | // DefaultAmazonDomain is the domain where an Amazon wishlist will 18 | // be assumed to be located if not otherwise specified. 19 | DefaultAmazonDomain = "https://www.amazon.com" 20 | DefaultCurrency = "USD" 21 | 22 | robotMessage = "we just need to make sure you're not a robot" 23 | cachePath = "./cache" 24 | proxyPrefix = "socks5://" 25 | addToCartText = "add to cart" 26 | reviewCountIDPrefix = "review_count_" 27 | requestCountIDPrefix = "itemRequested_" 28 | ownedCountIDPrefix = "itemPurchased_" 29 | dateAddedIDPrefix = "itemAddedDate_" 30 | dateAddedPrefix = "Added " 31 | ) 32 | 33 | var ( 34 | tldCurrencies map[string]string 35 | ) 36 | 37 | func init() { 38 | tldCurrencies = make(map[string]string) 39 | tldCurrencies["ca"] = "CAD" 40 | tldCurrencies["co.jp"] = "JPY" 41 | tldCurrencies["co.uk"] = "GBP" 42 | tldCurrencies["com"] = "USD" 43 | tldCurrencies["com.br"] = "BRL" 44 | tldCurrencies["de"] = "EUR" 45 | tldCurrencies["es"] = "EUR" 46 | tldCurrencies["fr"] = "EUR" 47 | tldCurrencies["in"] = "INR" 48 | tldCurrencies["it"] = "EUR" 49 | } 50 | 51 | // Wishlist represents an Amazon wishlist of products. 52 | type Wishlist struct { 53 | // DebugMode specifies whether messages should be logged to STDOUT about 54 | // what's going on, as well as if the HTML source of the wishlist should 55 | // be saved to files. 56 | DebugMode bool 57 | 58 | // CacheResults determines whether responses from Amazon should be cached. 59 | CacheResults bool 60 | 61 | errors []error 62 | proxyURLs []string 63 | urls []string 64 | id string 65 | items map[string]*Item 66 | name string 67 | printURL string 68 | } 69 | 70 | // NewWishlist constructs an Amazon wishlist for the given URL. 71 | func NewWishlist(urlStr string) (*Wishlist, error) { 72 | if len(urlStr) < 1 { 73 | return nil, errors.New("No Amazon wishlist URL provided") 74 | } 75 | 76 | uri, err := url.Parse(urlStr) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | if !uri.IsAbs() { 82 | return nil, fmt.Errorf("URL '%s' is not an absolute URL to an Amazon wishlist", 83 | urlStr) 84 | } 85 | 86 | pathParts := strings.Split(uri.EscapedPath(), "/") 87 | id := pathParts[len(pathParts)-1] 88 | domain := fmt.Sprintf("https://%s", uri.Hostname()) 89 | 90 | return NewWishlistFromIDAtDomain(id, domain) 91 | } 92 | 93 | // NewWishlistFromID constructs an Amazon wishlist for the given wishlist ID. 94 | func NewWishlistFromID(id string) (*Wishlist, error) { 95 | return NewWishlistFromIDAtDomain(id, DefaultAmazonDomain) 96 | } 97 | 98 | // NewWishlistFromIDAtDomain constructs an Amazon wishlist for the given 99 | // wishlist ID at the given Amazon domain, e.g., "https://amazon.com". 100 | func NewWishlistFromIDAtDomain(id string, amazonDomain string) (*Wishlist, error) { 101 | if len(id) < 1 { 102 | return nil, errors.New("No Amazon wishlist ID given") 103 | } 104 | if len(amazonDomain) < 1 { 105 | return nil, errors.New("No Amazon domain specified") 106 | } 107 | 108 | wishlistURL, err := getWishlistURL(amazonDomain, id) 109 | if err != nil { 110 | return nil, err 111 | } 112 | 113 | return &Wishlist{ 114 | DebugMode: false, 115 | CacheResults: true, 116 | urls: []string{wishlistURL}, 117 | id: id, 118 | items: map[string]*Item{}, 119 | proxyURLs: []string{}, 120 | errors: []error{}, 121 | name: "", 122 | printURL: "", 123 | }, nil 124 | } 125 | 126 | // ID returns the identifier for this wishlist on Amazon. 127 | func (w *Wishlist) ID() string { 128 | return w.id 129 | } 130 | 131 | // Name returns the name of this wishlist on Amazon. 132 | func (w *Wishlist) Name() (string, error) { 133 | c := w.collector() 134 | 135 | c.OnHTML("#profile-list-name", w.onName) 136 | 137 | if err := w.loadWishlist(c); err != nil { 138 | return "", err 139 | } 140 | 141 | return w.name, nil 142 | } 143 | 144 | // PrintURL returns the URL to the printer-friendly view of this wishlist on Amazon. 145 | func (w *Wishlist) PrintURL() (string, error) { 146 | c := w.collector() 147 | 148 | c.OnHTML("#wl-print-link", w.onPrintLink) 149 | 150 | if err := w.loadWishlist(c); err != nil { 151 | return "", err 152 | } 153 | 154 | return w.printURL, nil 155 | } 156 | 157 | // URLs returns the URLs used to access all the items in the wishlist. Will be 158 | // extended as necessary when Items is called. 159 | func (w *Wishlist) URLs() []string { 160 | return w.urls 161 | } 162 | 163 | // Errors returns any errors that occurred when trying to load the wishlist. 164 | func (w *Wishlist) Errors() []error { 165 | return w.errors 166 | } 167 | 168 | // SetProxyURLs specifies URLs of proxies to use when accessing Amazon. May 169 | // be useful if you're getting an error about Amazon thinking you're a bot. 170 | func (w *Wishlist) SetProxyURLs(urls ...string) { 171 | w.proxyURLs = make([]string, len(urls)) 172 | for i, url := range urls { 173 | if strings.HasPrefix(url, proxyPrefix) { 174 | w.proxyURLs[i] = url 175 | } else { 176 | w.proxyURLs[i] = fmt.Sprintf("%s%s", proxyPrefix, url) 177 | } 178 | } 179 | } 180 | 181 | // Items returns a map of the products on the wishlist, where keys are 182 | // the product IDs and the values are the products. 183 | func (w *Wishlist) Items() (map[string]*Item, error) { 184 | c := w.collector() 185 | 186 | c.OnHTML("ul li", w.onListItem) 187 | c.OnHTML("a.wl-see-more", func(link *colly.HTMLElement) { 188 | w.onLoadMoreLink(c, link) 189 | }) 190 | 191 | if err := w.loadWishlist(c); err != nil { 192 | return nil, err 193 | } 194 | 195 | return w.items, nil 196 | } 197 | 198 | func (w *Wishlist) String() string { 199 | return strings.Join(w.urls, ", ") 200 | } 201 | 202 | func (w *Wishlist) loadWishlist(c *colly.Collector) error { 203 | if w.DebugMode { 204 | fmt.Println("Using URL", w.urls[0]) 205 | } 206 | 207 | if err := c.Visit(w.urls[0]); err != nil { 208 | return err 209 | } 210 | 211 | c.Wait() 212 | 213 | if len(w.errors) > 0 { 214 | return w.errors[0] 215 | } 216 | 217 | return nil 218 | } 219 | 220 | func (w *Wishlist) collector() *colly.Collector { 221 | options := []func(*colly.Collector){colly.Async(true)} 222 | if w.CacheResults { 223 | if w.DebugMode { 224 | fmt.Println("Caching Amazon responses in", cachePath) 225 | } 226 | options = append(options, colly.CacheDir(cachePath)) 227 | } 228 | c := colly.NewCollector(options...) 229 | 230 | extensions.RandomUserAgent(c) 231 | c.Limit(&colly.LimitRule{ 232 | RandomDelay: 2 * time.Second, 233 | Parallelism: 4, 234 | }) 235 | 236 | if len(w.proxyURLs) > 0 { 237 | w.applyProxies(c) 238 | } 239 | 240 | c.OnRequest(w.onRequest) 241 | c.OnResponse(w.onResponse) 242 | c.OnError(func(r *colly.Response, e error) { 243 | w.errors = append(w.errors, e) 244 | }) 245 | 246 | return c 247 | } 248 | 249 | func (w *Wishlist) onRequest(r *colly.Request) { 250 | if w.DebugMode { 251 | fmt.Println("Using User-Agent", r.Headers.Get("User-Agent")) 252 | } 253 | r.Headers.Set("cookie", getPrefsHeader(r.URL)) 254 | } 255 | 256 | func (w *Wishlist) onResponse(r *colly.Response) { 257 | if w.DebugMode { 258 | fmt.Printf("Status %d\n", r.StatusCode) 259 | } 260 | 261 | if strings.Contains(string(r.Body), robotMessage) { 262 | w.errors = append(w.errors, errors.New("Amazon is not showing the wishlist because it thinks I'm a robot :(")) 263 | } 264 | 265 | if w.DebugMode { 266 | filename := fmt.Sprintf("wishlist-%s-%s.html", w.id, r.FileName()) 267 | fmt.Printf("Saving wishlist HTML source to %s...\n", filename) 268 | if err := r.Save(filename); err != nil { 269 | w.errors = append(w.errors, err) 270 | } 271 | } 272 | } 273 | 274 | func (w *Wishlist) onName(el *colly.HTMLElement) { 275 | w.name = strings.TrimSpace(el.Text) 276 | } 277 | 278 | func (w *Wishlist) onPrintLink(link *colly.HTMLElement) { 279 | relativeURL := link.Attr("href") 280 | if len(relativeURL) < 1 { 281 | return 282 | } 283 | 284 | w.printURL = link.Request.AbsoluteURL(relativeURL) 285 | } 286 | 287 | func (w *Wishlist) onLoadMoreLink(c *colly.Collector, link *colly.HTMLElement) { 288 | relativeURL := link.Attr("href") 289 | if len(relativeURL) < 1 { 290 | return 291 | } 292 | 293 | nextPageURL := link.Request.AbsoluteURL(relativeURL) 294 | w.urls = append(w.urls, nextPageURL) 295 | 296 | if w.DebugMode { 297 | fmt.Println("Found URL to next page", nextPageURL) 298 | } 299 | 300 | c.Visit(nextPageURL) 301 | } 302 | 303 | func (w *Wishlist) onListItem(listItem *colly.HTMLElement) { 304 | id := listItem.Attr("data-itemid") 305 | if len(id) < 1 { 306 | return 307 | } 308 | 309 | listItem.ForEach("a", func(index int, link *colly.HTMLElement) { 310 | w.onLink(id, link) 311 | }) 312 | listItem.ForEach(".a-price", func(index int, priceEl *colly.HTMLElement) { 313 | w.onPrice(id, priceEl) 314 | }) 315 | listItem.ForEach(".itemUsedAndNewPrice", func(index int, priceEl *colly.HTMLElement) { 316 | w.onBackupPrice(id, priceEl) 317 | }) 318 | listItem.ForEach(".dateAddedText", func(index int, container *colly.HTMLElement) { 319 | w.onDateAddedContainer(id, container) 320 | }) 321 | listItem.ForEach("[data-action='add-to-cart']", func(index int, container *colly.HTMLElement) { 322 | w.onAddToCartContainer(id, container) 323 | }) 324 | listItem.ForEach(".g-itemImage", func(index int, container *colly.HTMLElement) { 325 | w.onImageContainer(id, container) 326 | }) 327 | listItem.ForEach(".reviewStarsPopoverLink", func(index int, container *colly.HTMLElement) { 328 | w.onRatingContainer(id, container) 329 | }) 330 | listItem.ForEach(".a-icon-prime", func(index int, primeIndicator *colly.HTMLElement) { 331 | w.onPrime(id, primeIndicator) 332 | }) 333 | listItem.ForEach("span", func(index int, span *colly.HTMLElement) { 334 | w.onSpan(id, span) 335 | }) 336 | } 337 | 338 | func (w *Wishlist) onSpan(id string, span *colly.HTMLElement) { 339 | spanID := span.Attr("id") 340 | if len(spanID) < 1 { 341 | return 342 | } 343 | 344 | if strings.HasPrefix(spanID, requestCountIDPrefix) { 345 | w.onRequestedCountSpan(id, span) 346 | } else if strings.HasPrefix(spanID, ownedCountIDPrefix) { 347 | w.onOwnedCountSpan(id, span) 348 | } 349 | } 350 | 351 | func (w *Wishlist) onRequestedCountSpan(id string, span *colly.HTMLElement) { 352 | item := w.items[id] 353 | if item == nil { 354 | return 355 | } 356 | 357 | requestedCountStr := strings.TrimSpace(span.Text) 358 | if len(requestedCountStr) < 1 { 359 | return 360 | } 361 | 362 | requestedCount, err := strconv.ParseInt(requestedCountStr, 10, 64) 363 | if err != nil { 364 | w.errors = append(w.errors, err) 365 | return 366 | } 367 | 368 | item.RequestedCount = int(requestedCount) 369 | } 370 | 371 | func (w *Wishlist) onOwnedCountSpan(id string, span *colly.HTMLElement) { 372 | item := w.items[id] 373 | if item == nil { 374 | return 375 | } 376 | 377 | ownedCountStr := strings.TrimSpace(span.Text) 378 | if len(ownedCountStr) < 1 { 379 | return 380 | } 381 | 382 | ownedCount, err := strconv.ParseInt(ownedCountStr, 10, 64) 383 | if err != nil { 384 | w.errors = append(w.errors, err) 385 | return 386 | } 387 | 388 | item.OwnedCount = int(ownedCount) 389 | } 390 | 391 | func (w *Wishlist) onPrime(id string, primeIndicator *colly.HTMLElement) { 392 | item := w.items[id] 393 | if item == nil { 394 | return 395 | } 396 | 397 | item.IsPrime = true 398 | } 399 | 400 | func (w *Wishlist) onRatingContainer(id string, container *colly.HTMLElement) { 401 | container.ForEach(".a-icon-alt", func(index int, ratingEl *colly.HTMLElement) { 402 | w.onRating(id, ratingEl) 403 | }) 404 | } 405 | 406 | func (w *Wishlist) onRating(id string, ratingEl *colly.HTMLElement) { 407 | item := w.items[id] 408 | if item == nil { 409 | return 410 | } 411 | 412 | item.Rating = strings.TrimSpace(ratingEl.Text) 413 | } 414 | 415 | func (w *Wishlist) onImageContainer(id string, container *colly.HTMLElement) { 416 | container.ForEach("img", func(index int, image *colly.HTMLElement) { 417 | w.onImage(id, image) 418 | }) 419 | } 420 | 421 | func (w *Wishlist) onImage(id string, image *colly.HTMLElement) { 422 | item := w.items[id] 423 | if item == nil { 424 | return 425 | } 426 | 427 | relativeURL := image.Attr("src") 428 | if len(relativeURL) < 1 { 429 | return 430 | } 431 | 432 | item.ImageURL = image.Request.AbsoluteURL(relativeURL) 433 | } 434 | 435 | func (w *Wishlist) onAddToCartContainer(id string, container *colly.HTMLElement) { 436 | container.ForEach("a", func(index int, link *colly.HTMLElement) { 437 | w.onAddToCartLink(id, link) 438 | }) 439 | } 440 | 441 | func (w *Wishlist) onAddToCartLink(id string, link *colly.HTMLElement) { 442 | linkText := strings.ToLower(link.Text) 443 | if !strings.Contains(linkText, addToCartText) { 444 | return 445 | } 446 | 447 | item := w.items[id] 448 | if item == nil { 449 | return 450 | } 451 | 452 | relativeURL := link.Attr("href") 453 | if len(relativeURL) < 1 { 454 | return 455 | } 456 | 457 | item.AddToCartURL = link.Request.AbsoluteURL(relativeURL) 458 | } 459 | 460 | func (w *Wishlist) onReviewCountLink(id string, link *colly.HTMLElement) { 461 | item := w.items[id] 462 | if item == nil { 463 | return 464 | } 465 | 466 | reviewCountStr := strings.TrimSpace(link.Text) 467 | if reviewCountStr != "" { 468 | reviewCountStr = strings.Replace(reviewCountStr, ",", "", -1) 469 | reviewCountStr = strings.Replace(reviewCountStr, ".", "", -1) 470 | reviewCount, err := strconv.ParseInt(reviewCountStr, 10, 64) 471 | if err != nil { 472 | w.errors = append(w.errors, err) 473 | return 474 | } 475 | 476 | item.ReviewCount = int(reviewCount) 477 | } 478 | 479 | relativeURL := link.Attr("href") 480 | if relativeURL != "" { 481 | item.ReviewsURL = link.Request.AbsoluteURL(relativeURL) 482 | } 483 | } 484 | 485 | func (w *Wishlist) onLink(id string, link *colly.HTMLElement) { 486 | linkID := link.Attr("id") 487 | if len(linkID) > 0 && strings.HasPrefix(linkID, reviewCountIDPrefix) { 488 | w.onReviewCountLink(id, link) 489 | return 490 | } 491 | 492 | title := link.Attr("title") 493 | if len(title) < 1 { 494 | return 495 | } 496 | 497 | relativeURL := link.Attr("href") 498 | if len(relativeURL) < 1 { 499 | return 500 | } 501 | 502 | w.items[id] = NewItem(id, title, link.Request.AbsoluteURL(relativeURL)) 503 | } 504 | 505 | func (w *Wishlist) onPrice(id string, priceEl *colly.HTMLElement) { 506 | item := w.items[id] 507 | if item == nil { 508 | return 509 | } 510 | 511 | item.Price = priceEl.ChildText(".a-offscreen") 512 | } 513 | 514 | func (w *Wishlist) onBackupPrice(id string, priceEl *colly.HTMLElement) { 515 | item := w.items[id] 516 | if item == nil { 517 | return 518 | } 519 | 520 | if item.Price != "" { 521 | return 522 | } 523 | 524 | item.Price = priceEl.Text 525 | } 526 | 527 | func (w *Wishlist) onDateAddedContainer(id string, container *colly.HTMLElement) { 528 | container.ForEach("span", func(index int, span *colly.HTMLElement) { 529 | spanID := span.Attr("id") 530 | if len(spanID) < 1 { 531 | return 532 | } 533 | if !strings.HasPrefix(spanID, dateAddedIDPrefix) { 534 | return 535 | } 536 | w.onDateAdded(id, span) 537 | }) 538 | } 539 | 540 | func (w *Wishlist) onDateAdded(id string, dateEl *colly.HTMLElement) { 541 | item := w.items[id] 542 | if item == nil { 543 | return 544 | } 545 | 546 | item.RawDateAdded = strings.TrimPrefix(dateEl.Text, dateAddedPrefix) 547 | } 548 | 549 | func (w *Wishlist) applyProxies(c *colly.Collector) error { 550 | if w.DebugMode { 551 | fmt.Printf("Using proxies: %v\n", w.proxyURLs) 552 | } 553 | 554 | proxySwitcher, err := proxy.RoundRobinProxySwitcher(w.proxyURLs...) 555 | if err != nil { 556 | return err 557 | } 558 | 559 | c.SetProxyFunc(proxySwitcher) 560 | 561 | return nil 562 | } 563 | 564 | func getWishlistURL(amazonDomain string, id string) (string, error) { 565 | amazonURL, err := url.Parse(amazonDomain) 566 | if err != nil { 567 | return "", err 568 | } 569 | 570 | port := amazonURL.Port() 571 | if port != "" { 572 | port = ":" + port 573 | } 574 | 575 | url := fmt.Sprintf("%s://%s%s/hz/wishlist/ls/%s?reveal=unpurchased&sort=date&layout=standard&viewType=list&filter=DEFAULT&type=wishlist", 576 | amazonURL.Scheme, amazonURL.Hostname(), port, id) 577 | return url, nil 578 | } 579 | 580 | func getPrefsHeader(url *url.URL) string { 581 | strTmpl := "i18n-prefs=%s" 582 | 583 | for tld, currency := range tldCurrencies { 584 | if strings.HasSuffix(url.Host, tld) { 585 | return fmt.Sprintf(strTmpl, currency) 586 | } 587 | } 588 | 589 | return fmt.Sprintf(strTmpl, DefaultCurrency) 590 | } 591 | -------------------------------------------------------------------------------- /pkg/amazon/wishlist_test.go: -------------------------------------------------------------------------------- 1 | package amazon 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestNewWishlist(t *testing.T) { 13 | id := "123abc" 14 | ts := newTestServer(t, id) 15 | defer ts.Close() 16 | 17 | wishlist, err := NewWishlist(ts.URL + "/hz/wishlist/ls/123abc") 18 | 19 | require.NoError(t, err) 20 | require.Equal(t, "123abc", wishlist.ID()) 21 | } 22 | 23 | func TestNewWishlistFromID(t *testing.T) { 24 | id := "123abc" 25 | wishlist, err := NewWishlistFromID(id) 26 | require.NoError(t, err) 27 | 28 | urls := wishlist.URLs() 29 | require.NotEmpty(t, urls) 30 | 31 | for _, url := range urls { 32 | require.Contains(t, url, DefaultAmazonDomain) 33 | require.Contains(t, url, id) 34 | require.Contains(t, url, "wishlist") 35 | } 36 | } 37 | 38 | func TestNewWishlistFromIDAtDomain(t *testing.T) { 39 | id := "123abc" 40 | ts := newTestServer(t, id) 41 | defer ts.Close() 42 | 43 | wishlist, err := NewWishlistFromIDAtDomain(id, ts.URL) 44 | require.NoError(t, err) 45 | 46 | urls := wishlist.URLs() 47 | require.NotEmpty(t, urls) 48 | 49 | for _, url := range urls { 50 | require.Contains(t, url, ts.URL) 51 | require.Contains(t, url, id) 52 | require.Contains(t, url, "wishlist") 53 | } 54 | } 55 | 56 | func TestName(t *testing.T) { 57 | id := "123abc" 58 | ts := newTestServer(t, id) 59 | defer ts.Close() 60 | 61 | wishlist, err := NewWishlistFromIDAtDomain(id, ts.URL) 62 | require.NoError(t, err) 63 | 64 | name, err := wishlist.Name() 65 | require.NoError(t, err) 66 | require.Equal(t, "NHA Wish List", name) 67 | } 68 | 69 | func TestPrintURL(t *testing.T) { 70 | id := "123abc" 71 | ts := newTestServer(t, id) 72 | defer ts.Close() 73 | 74 | wishlist, err := NewWishlistFromIDAtDomain(id, ts.URL) 75 | require.NoError(t, err) 76 | 77 | printURL, err := wishlist.PrintURL() 78 | require.NoError(t, err) 79 | require.Equal(t, ts.URL+"/hz/wishlist/printview/3I6EQPZ8OB1DT", printURL) 80 | } 81 | 82 | func TestItems(t *testing.T) { 83 | id := "123abc" 84 | ts := newTestServer(t, id) 85 | defer ts.Close() 86 | 87 | wishlist, err := NewWishlistFromIDAtDomain(id, ts.URL) 88 | require.NoError(t, err) 89 | wishlist.CacheResults = false 90 | 91 | items, err := wishlist.Items() 92 | require.NoError(t, err) 93 | require.Len(t, items, 1) 94 | 95 | itemID := "I2G6UJO0FYWV8J" 96 | item, ok := items[itemID] 97 | require.True(t, ok) 98 | require.Equal(t, itemID, item.ID) 99 | require.Equal(t, "Purina Tidy Cats Non-Clumping Cat Litter", item.Name) 100 | require.Equal(t, "$15.96", item.Price) 101 | require.Equal(t, "July 10, 2019", item.RawDateAdded) 102 | dateAdded, err := item.DateAdded() 103 | require.NoError(t, err) 104 | expectedDateAdded := time.Date(2019, 7, 10, 0, 0, 0, 0, time.UTC) 105 | require.Equal(t, &expectedDateAdded, dateAdded) 106 | require.Equal(t, "https://images-na.ssl-images-amazon.com/images/I/81YphWp9eIL._SS135_.jpg", item.ImageURL) 107 | require.Equal(t, 50, item.RequestedCount) 108 | require.Equal(t, 11, item.OwnedCount) 109 | require.Equal(t, "4.0 out of 5 stars", item.Rating) 110 | require.Equal(t, 930, item.ReviewCount) 111 | require.Equal(t, ts.URL+"/product-reviews/B0018CLTKE/?colid=3I6EQPZ8OB1DT&coliid=I2G6UJO0FYWV8J&showViewpoints=1&ref_=lv_vv_lig_pr_rc", item.ReviewsURL) 112 | require.True(t, item.IsPrime, "should be marked as a Prime item") 113 | require.NotEqual(t, "", item.AddToCartURL) 114 | require.Contains(t, item.AddToCartURL, ts.URL) 115 | require.Contains(t, item.AddToCartURL, itemID) 116 | require.Equal(t, ts.URL+"/dp/B0018CLTKE/?coliid=I2G6UJO0FYWV8J&colid=3I6EQPZ8OB1DT&psc=1&ref_=lv_vv_lig_dp_it", item.DirectURL) 117 | } 118 | 119 | const wishlistHTML = ` 120 | 121 | 122 | Print List 123 | NHA Wish List 124 | 221 | 222 | ` 223 | 224 | func newTestServer(t *testing.T, wishlistID string) *httptest.Server { 225 | mux := http.NewServeMux() 226 | 227 | mux.HandleFunc("/hz/wishlist/ls/"+wishlistID, func(w http.ResponseWriter, r *http.Request) { 228 | w.Header().Set("Content-Type", "text/html") 229 | w.Write([]byte(wishlistHTML)) 230 | }) 231 | 232 | return httptest.NewServer(mux) 233 | } 234 | --------------------------------------------------------------------------------