initial commit
This commit is contained in:
commit
d3cd46f5c8
258
cmd/apiserver/main.go
Normal file
258
cmd/apiserver/main.go
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cache "github.com/patrickmn/go-cache"
|
||||||
|
|
||||||
|
"go.dedaa.de/julixau/reichelt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
addr = flag.String("http", ":8080", "The Address to bind to ")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
*reichelt.Connection
|
||||||
|
|
||||||
|
cache *cache.Cache
|
||||||
|
}
|
||||||
|
|
||||||
|
func NotFound(resp http.ResponseWriter) {
|
||||||
|
resp.WriteHeader(http.StatusNotFound)
|
||||||
|
fmt.Fprint(resp, "404")
|
||||||
|
}
|
||||||
|
|
||||||
|
func InternalError(resp http.ResponseWriter) {
|
||||||
|
resp.WriteHeader(http.StatusInternalServerError)
|
||||||
|
fmt.Fprint(resp, "500")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) Search(resp http.ResponseWriter, path []string) {
|
||||||
|
if len(path) == 0 {
|
||||||
|
NotFound(resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Println("level 2 request:", path)
|
||||||
|
|
||||||
|
query, err := url.PathUnescape(path[0])
|
||||||
|
if err != nil {
|
||||||
|
log.Println("illegal query:", path[0], ":", err)
|
||||||
|
NotFound(resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parts, err := h.FindPart(query)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error retrieving part:", err)
|
||||||
|
InternalError(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// insert to cache
|
||||||
|
if h.cache != nil {
|
||||||
|
for _, p := range parts {
|
||||||
|
h.cache.Set(strconv.Itoa(p.Number), &p, cache.NoExpiration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
encoder := json.NewEncoder(resp)
|
||||||
|
encoder.Encode(parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) Picture(resp http.ResponseWriter, path []string) {
|
||||||
|
if len(path) == 0 {
|
||||||
|
NotFound(resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Println("level 2 request:", path)
|
||||||
|
serve := func(img image.Image) {
|
||||||
|
resp.Header().Set("Content-type", "image/png")
|
||||||
|
resp.WriteHeader(http.StatusOK)
|
||||||
|
if err := png.Encode(resp, img); err != nil {
|
||||||
|
log.Println("could not encode png:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
number, err := strconv.Atoi(path[0])
|
||||||
|
if err != nil {
|
||||||
|
log.Println("encountered decode error:", err)
|
||||||
|
NotFound(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.cache != nil {
|
||||||
|
if x, ok := h.cache.Get(path[0] + "-image"); ok {
|
||||||
|
serve(*(x.(*image.Image)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img, err := h.GetImage(reichelt.Part{Number: number}, 99999, 9999)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Println("error retrieving picture:", err)
|
||||||
|
InternalError(resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
decodedImg, err := jpeg.Decode(img)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("could no decode image:", err)
|
||||||
|
InternalError(resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.cache != nil {
|
||||||
|
h.cache.Set(path[0]+"-image", &decodedImg, cache.NoExpiration)
|
||||||
|
}
|
||||||
|
serve(decodedImg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) Price(resp http.ResponseWriter, path []string) {
|
||||||
|
if len(path) == 0 {
|
||||||
|
NotFound(resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("level 2 request:", path)
|
||||||
|
|
||||||
|
number, err := strconv.Atoi(path[0])
|
||||||
|
if err != nil {
|
||||||
|
log.Println("encountered decode error:", err)
|
||||||
|
NotFound(resp)
|
||||||
|
}
|
||||||
|
var price float32
|
||||||
|
|
||||||
|
if h.cache != nil {
|
||||||
|
if x, ok := h.cache.Get(path[0] + "-price"); ok {
|
||||||
|
price = x.(float32)
|
||||||
|
goto cached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
price = h.GetPrice(reichelt.Part{Number: number})
|
||||||
|
if h.cache != nil {
|
||||||
|
h.cache.Set(path[0]+"-price", price, time.Second*30)
|
||||||
|
}
|
||||||
|
|
||||||
|
cached:
|
||||||
|
encoder := json.NewEncoder(resp)
|
||||||
|
encoder.Encode(price)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) Meta(resp http.ResponseWriter, path []string) {
|
||||||
|
if len(path) == 0 {
|
||||||
|
NotFound(resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("level 2 request:", path)
|
||||||
|
|
||||||
|
number, err := strconv.Atoi(path[0])
|
||||||
|
if err != nil {
|
||||||
|
log.Println("encountered decode error:", err)
|
||||||
|
NotFound(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// implement caching to avoid many queries to reichelt server
|
||||||
|
var meta reichelt.Meta
|
||||||
|
if h.cache != nil {
|
||||||
|
if x, ok := h.cache.Get(path[0] + "-meta"); ok {
|
||||||
|
meta = x.(reichelt.Meta)
|
||||||
|
goto cached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err = h.GetMeta(reichelt.Part{Number: number})
|
||||||
|
if err != nil {
|
||||||
|
log.Println("encountered error:", err)
|
||||||
|
InternalError(resp)
|
||||||
|
}
|
||||||
|
if h.cache != nil {
|
||||||
|
h.cache.Set(path[0]+"-meta", meta, cache.NoExpiration)
|
||||||
|
}
|
||||||
|
|
||||||
|
cached:
|
||||||
|
encoder := json.NewEncoder(resp)
|
||||||
|
|
||||||
|
if len(path) > 1 {
|
||||||
|
if strings.ToLower(path[1]) == "overview" {
|
||||||
|
var headlines []string
|
||||||
|
for k, _ := range meta {
|
||||||
|
headlines = append(headlines, k)
|
||||||
|
}
|
||||||
|
encoder.Encode(headlines)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if query, err := url.QueryUnescape(path[1]); err != nil {
|
||||||
|
NotFound(resp)
|
||||||
|
log.Println("illegal query:", path[1])
|
||||||
|
} else {
|
||||||
|
// query is more concrete
|
||||||
|
subset, ok := meta[query]
|
||||||
|
if !ok {
|
||||||
|
NotFound(resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
encoder.Encode(subset)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
encoder.Encode(meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
|
||||||
|
// find out whether there was URL encoded data in the query
|
||||||
|
path := req.URL.RawPath
|
||||||
|
if path == "" {
|
||||||
|
path = req.URL.Path
|
||||||
|
}
|
||||||
|
p := strings.Split(path, "/")
|
||||||
|
if len(p) < 2 {
|
||||||
|
NotFound(resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p = p[1:]
|
||||||
|
log.Println("level 1 request:", p)
|
||||||
|
|
||||||
|
switch p[0] {
|
||||||
|
case "search":
|
||||||
|
h.Search(resp, p[1:])
|
||||||
|
case "image":
|
||||||
|
h.Picture(resp, p[1:])
|
||||||
|
case "price":
|
||||||
|
h.Price(resp, p[1:])
|
||||||
|
case "meta":
|
||||||
|
h.Meta(resp, p[1:])
|
||||||
|
default:
|
||||||
|
NotFound(resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a Simple request server
|
||||||
|
// exposing a simple api
|
||||||
|
// to search and retrieve
|
||||||
|
// - price
|
||||||
|
// - productimage
|
||||||
|
// for a product
|
||||||
|
func main() {
|
||||||
|
flag.Parse()
|
||||||
|
conn, err := reichelt.NewConnection()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("could not create connection to reichelt:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("start serving on:", *addr)
|
||||||
|
log.Fatal(http.ListenAndServe(*addr, Handler{conn, cache.New(cache.NoExpiration, 0)}))
|
||||||
|
}
|
40
connection.go
Normal file
40
connection.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package reichelt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Connection struct {
|
||||||
|
client http.Client
|
||||||
|
|
||||||
|
queryCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConnection() (c *Connection, err error) {
|
||||||
|
jar, err := cookiejar.New(nil)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
c = &Connection{
|
||||||
|
client: http.Client{
|
||||||
|
Jar: jar,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// get reichelt SID cookie set
|
||||||
|
resp, err := c.client.Get(apiurl)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("Wrong Status response: %d(%s)", resp.StatusCode, resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c, nil
|
||||||
|
}
|
74
connection_test.go
Normal file
74
connection_test.go
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
package reichelt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/jpeg"
|
||||||
|
"log"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var conn *Connection
|
||||||
|
|
||||||
|
func SetupConnection(t *testing.T) {
|
||||||
|
c, err := NewConnection()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("create Connection:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// look for cookie
|
||||||
|
url, _ := url.Parse(apiurl)
|
||||||
|
cookies := c.Jar.Cookies(url)
|
||||||
|
found := false
|
||||||
|
for _, k := range cookies {
|
||||||
|
t.Log("cookie set:", k)
|
||||||
|
if k.Name == "Reichelt_SID" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
log.Fatal("connection did not get cookie")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("connection created successfully")
|
||||||
|
conn = c
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPart(t *testing.T) {
|
||||||
|
t.Run("createConnection", SetupConnection)
|
||||||
|
|
||||||
|
// get part
|
||||||
|
parts, err := conn.FindPart("1N4001")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("find part:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) == 0 {
|
||||||
|
log.Fatal("not enough parts were retrieved")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log(parts)
|
||||||
|
|
||||||
|
// get prices
|
||||||
|
p := conn.GetPrice(parts[0])
|
||||||
|
if p == 0 {
|
||||||
|
t.Fatal("get Price")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log(parts[0], ":", p)
|
||||||
|
|
||||||
|
// get image for part
|
||||||
|
imgReader, err := conn.GetImage(parts[0], 1000, 1000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("get product image:", err)
|
||||||
|
}
|
||||||
|
defer imgReader.Close()
|
||||||
|
img, err := jpeg.Decode(imgReader)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("jpg decode:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("image size:", img.Bounds())
|
||||||
|
}
|
69
meta.go
Normal file
69
meta.go
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
package reichelt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/andybalholm/cascadia"
|
||||||
|
|
||||||
|
"golang.org/x/net/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Meta map[string]map[string]string
|
||||||
|
|
||||||
|
var (
|
||||||
|
metaSelector = cascadia.MustCompile(".av_propview")
|
||||||
|
metaItemNameSelector = cascadia.MustCompile(".av_propname")
|
||||||
|
metaItemValueSelector = cascadia.MustCompile(".av_propvalue")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get Metadata connected to specified part
|
||||||
|
func (c *Connection) GetMeta(p Part) (Meta, error) {
|
||||||
|
resp, err := c.client.Get("https://www.reichelt.de/index.html?ACTION=3&ARTICLE=" + strconv.Itoa(p.Number))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
doc, err := html.Parse(resp.Body)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes := metaSelector.MatchAll(doc)
|
||||||
|
if nodes == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(Meta)
|
||||||
|
|
||||||
|
for _, n := range nodes {
|
||||||
|
if n.FirstChild == nil || n.FirstChild.FirstChild == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
headline := n.FirstChild.FirstChild.Data
|
||||||
|
data := make(map[string]string)
|
||||||
|
|
||||||
|
names := metaItemNameSelector.MatchAll(n)
|
||||||
|
values := metaItemValueSelector.MatchAll(n)
|
||||||
|
|
||||||
|
if len(names) != len(values) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range names {
|
||||||
|
if names[i].FirstChild == nil || values[i].FirstChild == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data[names[i].FirstChild.Data] = strings.Trim(values[i].FirstChild.Data, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
result[headline] = data
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
110
part.go
Normal file
110
part.go
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
package reichelt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/andybalholm/cascadia"
|
||||||
|
|
||||||
|
"golang.org/x/net/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Part struct {
|
||||||
|
Number int `json:"article_artid"`
|
||||||
|
|
||||||
|
Description string `json:"article_lang_besch"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiurl = "https://www.reichelt.de/index.html"
|
||||||
|
|
||||||
|
type ResponseField struct {
|
||||||
|
NumFound int `json:"numFound"`
|
||||||
|
MaxScore float32 `json:"maxScore"`
|
||||||
|
|
||||||
|
Docs []Part `json:"docs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchResponse struct {
|
||||||
|
Response ResponseField `json:"response"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
priceSelector = cascadia.MustCompile("#av_price")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Search for a part like using the sites search engine
|
||||||
|
// can be used to resolv partnumbers to internal ones
|
||||||
|
func (c *Connection) FindPart(query string) ([]Part, error) {
|
||||||
|
resp, err := c.client.Get(apiurl + "?ACTION=514&id=8&term=" + url.PathEscape(query))
|
||||||
|
c.queryCount++
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("wrong response status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := json.NewDecoder(resp.Body)
|
||||||
|
response := SearchResponse{}
|
||||||
|
if err = reader.Decode(&response); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Response.Docs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the Price of the Part
|
||||||
|
// or 0 if there was an error
|
||||||
|
func (c *Connection) GetPrice(p Part) float32 {
|
||||||
|
resp, err := c.client.Get(apiurl + "?ACTION=3&ARTICLE=" + strconv.Itoa(p.Number))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// log.Println("price:", "get request:", err)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
// log.Println("price:", "wrong result:", resp.Status)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, err := html.Parse(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
// log.Println("price:", "parse html:", err)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
priceTag := priceSelector.MatchFirst(doc)
|
||||||
|
|
||||||
|
if priceTag == nil {
|
||||||
|
// log.Println("price:", "selector returned nothing")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// retrieve first child of node
|
||||||
|
// since inner Text is saved as child node
|
||||||
|
// NOTE: This might be the wrong node, but if it is,
|
||||||
|
// code below this one will fail anyway, so we dont check
|
||||||
|
// the node type here
|
||||||
|
price := priceTag.FirstChild.Data
|
||||||
|
|
||||||
|
// split before € sign
|
||||||
|
i := strings.Index(price, " €")
|
||||||
|
if i == len(price) || len(price) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// need to convert german decimals (using ,) to american decimals
|
||||||
|
// using .
|
||||||
|
str := strings.Replace(price[:i-1], ",", ".", 1)
|
||||||
|
ret, _ := strconv.ParseFloat(str, 32)
|
||||||
|
return float32(ret)
|
||||||
|
}
|
18
picture.go
Normal file
18
picture.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package reichelt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gets the product image of a reichelt article using the internal Part number
|
||||||
|
// the reader will return a image/jpg file
|
||||||
|
func (c *Connection) GetImage(p Part, w, h uint) (io.ReadCloser, error) {
|
||||||
|
resp, err := c.client.Get("https://www.reichelt.de/artimage/resize_" + strconv.Itoa(int(w)) + "x" + strconv.Itoa(int(h)) + "/" + strconv.Itoa(p.Number))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.Body, nil
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user