Merge branch 'feature/220510_joongna_api' into 'main'
Feature/220510 joongna api # 중고나라 키워드 검색 api 1. `api/v2/joongna/{keyword}` GET 요청하려 keyword 전달 2. 네이버 카페 검색 api를 통해 유사도 높은 순서로 검색 3. 검색 결과의 카페 링크를 통해 세부 항목 크롤링 (selenium) 4. 세부 항목 담아서 response See merge request !6
Showing
9 changed files
with
231 additions
and
25 deletions
joongna/Dockerfile
0 → 100644
1 | +FROM golang:1.17.3 | ||
2 | + | ||
3 | +RUN apt-get -y update | ||
4 | +RUN apt-get install -y wget xvfb gnupg | ||
5 | + | ||
6 | +RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - | ||
7 | +RUN sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list' | ||
8 | +RUN apt-get -y update | ||
9 | +RUN apt-get install -y google-chrome-stable | ||
10 | + | ||
11 | +RUN apt-get install -yqq unzip | ||
12 | +RUN wget -O /tmp/chromedriver.zip http://chromedriver.storage.googleapis.com/`curl -sS chromedriver.storage.googleapis.com/LATEST_RELEASE`/chromedriver_linux64.zip | ||
13 | +RUN unzip /tmp/chromedriver.zip chromedriver -d /usr/local/bin/ | ||
14 | + | ||
15 | +ENV Xvfb :99 | ||
16 | +ENV DISPLAY=:99 |
joongna/controller/controller.go
0 → 100644
1 | +package controller | ||
2 | + | ||
3 | +import ( | ||
4 | + "joongna/service" | ||
5 | + "net/http" | ||
6 | + | ||
7 | + "github.com/labstack/echo/v4" | ||
8 | +) | ||
9 | + | ||
10 | +func Search(c echo.Context) error { | ||
11 | + keyword := c.Param("keyword") | ||
12 | + items, err := service.GetItemByKeyword(keyword) | ||
13 | + if err != nil { | ||
14 | + return err | ||
15 | + } | ||
16 | + return c.JSON(http.StatusOK, items) | ||
17 | +} |
... | @@ -3,6 +3,23 @@ module joongna | ... | @@ -3,6 +3,23 @@ module joongna |
3 | go 1.17 | 3 | go 1.17 |
4 | 4 | ||
5 | require ( | 5 | require ( |
6 | + github.com/PuerkitoBio/goquery v1.8.0 // indirect | ||
7 | + github.com/andybalholm/cascadia v1.3.1 // indirect | ||
8 | + github.com/blang/semver v3.5.1+incompatible // indirect | ||
9 | + github.com/bunsenapp/go-selenium v0.1.0 // indirect | ||
6 | github.com/caarlos0/env/v6 v6.9.1 // indirect | 10 | github.com/caarlos0/env/v6 v6.9.1 // indirect |
11 | + github.com/fedesog/webdriver v0.0.0-20180606182539-99f36c92eaef // indirect | ||
7 | github.com/joho/godotenv v1.4.0 // indirect | 12 | github.com/joho/godotenv v1.4.0 // indirect |
13 | + github.com/labstack/echo/v4 v4.7.2 // indirect | ||
14 | + github.com/labstack/gommon v0.3.1 // indirect | ||
15 | + github.com/mattn/go-colorable v0.1.11 // indirect | ||
16 | + github.com/mattn/go-isatty v0.0.14 // indirect | ||
17 | + github.com/tebeka/selenium v0.9.9 // indirect | ||
18 | + github.com/valyala/bytebufferpool v1.0.0 // indirect | ||
19 | + github.com/valyala/fasttemplate v1.2.1 // indirect | ||
20 | + golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect | ||
21 | + golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f // indirect | ||
22 | + golang.org/x/sys v0.0.0-20211103235746-7861aae1554b // indirect | ||
23 | + golang.org/x/text v0.3.7 // indirect | ||
24 | + sourcegraph.com/sourcegraph/go-selenium v0.0.0-20170113155244-3da7d00aac9c // indirect | ||
8 | ) | 25 | ) | ... | ... |
This diff is collapsed. Click to expand it.
1 | package main | 1 | package main |
2 | 2 | ||
3 | import ( | 3 | import ( |
4 | - "fmt" | 4 | + "joongna/router" |
5 | - "io/ioutil" | 5 | + |
6 | - "joongna/config" | 6 | + "github.com/labstack/echo/v4" |
7 | - "log" | ||
8 | - "net/http" | ||
9 | - url2 "net/url" | ||
10 | ) | 7 | ) |
11 | 8 | ||
12 | func main() { | 9 | func main() { |
13 | - keyword := "m1 pro 맥북 프로 16인치" | 10 | + e := echo.New() |
14 | - encText := url2.QueryEscape("중고나라" + keyword) | ||
15 | - url := "https://openapi.naver.com/v1/search/cafearticle.json?query=" + encText + "&sort=sim" | ||
16 | - | ||
17 | - req, err := http.NewRequest("GET", url, nil) | ||
18 | - if err != nil { | ||
19 | - log.Fatal(err) | ||
20 | - } | ||
21 | - req.Header.Add("X-Naver-Client-Id", config.Cfg.Secret.CLIENTID) | ||
22 | - req.Header.Add("X-Naver-Client-Secret", config.Cfg.Secret.CLIENTSECRET) | ||
23 | 11 | ||
24 | - client := &http.Client{} | 12 | + router.Init(e) |
25 | - resp, err := client.Do(req) | ||
26 | - if err != nil { | ||
27 | - log.Fatal(err) | ||
28 | - } | ||
29 | - defer resp.Body.Close() | ||
30 | 13 | ||
31 | - bytes, _ := ioutil.ReadAll(resp.Body) | 14 | + e.Logger.Fatal(e.Start(":8080")) |
32 | - str := string(bytes) | ||
33 | - fmt.Println(str) | ||
34 | } | 15 | } | ... | ... |
joongna/model/api_response.go
0 → 100644
1 | +package model | ||
2 | + | ||
3 | +type ApiResponse struct { | ||
4 | + LastBuildDate string `json:"lastBuildDate"` | ||
5 | + Total uint `json:"total"` | ||
6 | + Start uint `json:"start"` | ||
7 | + Display uint `json:"display"` | ||
8 | + Items []ApiResponseItem `json:"items"` | ||
9 | +} | ||
10 | + | ||
11 | +type ApiResponseItem struct { | ||
12 | + Title string `json:"title"` | ||
13 | + Link string `json:"link"` | ||
14 | + Description string `json:"description"` | ||
15 | + CafeName string `json:"cafename"` | ||
16 | +} |
joongna/model/item.go
0 → 100644
joongna/router/router.go
0 → 100644
1 | +package router | ||
2 | + | ||
3 | +import ( | ||
4 | + "joongna/controller" | ||
5 | + | ||
6 | + "github.com/labstack/echo/v4" | ||
7 | +) | ||
8 | + | ||
9 | +const ( | ||
10 | + API = "/api/v2" | ||
11 | + APIJoongNa = API + "/JoongNa" | ||
12 | + APIKeyword = APIJoongNa + "/:keyword" | ||
13 | +) | ||
14 | + | ||
15 | +func Init(e *echo.Echo) { | ||
16 | + e.GET(APIKeyword, controller.Search) | ||
17 | +} |
joongna/service/item.go
0 → 100644
1 | +package service | ||
2 | + | ||
3 | +import ( | ||
4 | + "bytes" | ||
5 | + "encoding/json" | ||
6 | + "io" | ||
7 | + "io/ioutil" | ||
8 | + "joongna/config" | ||
9 | + "joongna/model" | ||
10 | + "log" | ||
11 | + "net/http" | ||
12 | + "net/url" | ||
13 | + "strconv" | ||
14 | + "strings" | ||
15 | + "time" | ||
16 | + | ||
17 | + "github.com/PuerkitoBio/goquery" | ||
18 | + "github.com/fedesog/webdriver" | ||
19 | +) | ||
20 | + | ||
21 | +func GetItemByKeyword(keyword string) ([]model.Item, error) { | ||
22 | + var items []model.Item | ||
23 | + | ||
24 | + itemsInfo := getItemsInfoByKeyword(keyword) | ||
25 | + for _, itemInfo := range itemsInfo { | ||
26 | + if itemInfo.CafeName != "중고나라" { | ||
27 | + continue | ||
28 | + } | ||
29 | + itemUrl := itemInfo.Link | ||
30 | + sold, price, thumbnailUrl, extraInfo := crawlingNaverCafe(itemUrl) | ||
31 | + | ||
32 | + if sold == "판매 완료" { | ||
33 | + continue | ||
34 | + } | ||
35 | + | ||
36 | + item := model.Item{ | ||
37 | + Platform: "중고나라", | ||
38 | + Name: itemInfo.Title, | ||
39 | + Price: price, | ||
40 | + ThumbnailUrl: thumbnailUrl, | ||
41 | + ItemUrl: itemUrl, | ||
42 | + ExtraInfo: extraInfo, | ||
43 | + } | ||
44 | + items = append(items, item) | ||
45 | + } | ||
46 | + return items, nil | ||
47 | +} | ||
48 | + | ||
49 | +func getItemsInfoByKeyword(keyword string) []model.ApiResponseItem { | ||
50 | + encText := url.QueryEscape("중고나라 " + keyword + " 판매중") | ||
51 | + apiUrl := "https://openapi.naver.com/v1/search/cafearticle.json?query=" + encText + "&sort=sim" | ||
52 | + | ||
53 | + req, err := http.NewRequest("GET", apiUrl, nil) | ||
54 | + if err != nil { | ||
55 | + log.Fatal(err) | ||
56 | + } | ||
57 | + req.Header.Add("X-Naver-Client-Id", config.Cfg.Secret.CLIENTID) | ||
58 | + req.Header.Add("X-Naver-Client-Secret", config.Cfg.Secret.CLIENTSECRET) | ||
59 | + | ||
60 | + client := &http.Client{} | ||
61 | + resp, err := client.Do(req) | ||
62 | + if err != nil { | ||
63 | + log.Fatal(err) | ||
64 | + } | ||
65 | + defer func(Body io.ReadCloser) { | ||
66 | + err := Body.Close() | ||
67 | + if err != nil { | ||
68 | + log.Fatal(err) | ||
69 | + } | ||
70 | + }(resp.Body) | ||
71 | + | ||
72 | + response, _ := ioutil.ReadAll(resp.Body) | ||
73 | + var apiResponse model.ApiResponse | ||
74 | + err = json.Unmarshal(response, &apiResponse) | ||
75 | + if err != nil { | ||
76 | + log.Fatal(err) | ||
77 | + } | ||
78 | + return apiResponse.Items | ||
79 | +} | ||
80 | + | ||
81 | +func crawlingNaverCafe(cafeUrl string) (string, int, string, string) { | ||
82 | + driver := webdriver.NewChromeDriver("./chromedriver") | ||
83 | + err := driver.Start() | ||
84 | + if err != nil { | ||
85 | + log.Println(err) | ||
86 | + } | ||
87 | + desired := webdriver.Capabilities{"Platform": "Linux"} | ||
88 | + required := webdriver.Capabilities{} | ||
89 | + session, err := driver.NewSession(desired, required) | ||
90 | + if err != nil { | ||
91 | + log.Println(err) | ||
92 | + } | ||
93 | + err = session.Url(cafeUrl) | ||
94 | + if err != nil { | ||
95 | + log.Println(err) | ||
96 | + } | ||
97 | + time.Sleep(time.Second * 1) | ||
98 | + err = session.FocusOnFrame("cafe_main") | ||
99 | + if err != nil { | ||
100 | + log.Fatal(err) | ||
101 | + } | ||
102 | + resp, err := session.Source() | ||
103 | + | ||
104 | + html, err := goquery.NewDocumentFromReader(bytes.NewReader([]byte(resp))) | ||
105 | + if err != nil { | ||
106 | + log.Fatal(err) | ||
107 | + } | ||
108 | + | ||
109 | + sold := html.Find("div.sold_area").Text() | ||
110 | + price := priceStringToInt(html.Find(".ProductPrice").Text()) | ||
111 | + thumbnailUrl, _ := html.Find("div.product_thumb img").Attr("src") | ||
112 | + extraInfo := html.Find(".se-module-text").Text() | ||
113 | + | ||
114 | + sold = strings.TrimSpace(sold) | ||
115 | + thumbnailUrl = strings.TrimSpace(thumbnailUrl) | ||
116 | + extraInfo = strings.TrimSpace(extraInfo) | ||
117 | + | ||
118 | + return sold, price, thumbnailUrl, extraInfo | ||
119 | +} | ||
120 | + | ||
121 | +func priceStringToInt(priceString string) int { | ||
122 | + strings.TrimSpace(priceString) | ||
123 | + | ||
124 | + priceString = strings.ReplaceAll(priceString, "원", "") | ||
125 | + priceString = strings.ReplaceAll(priceString, ",", "") | ||
126 | + | ||
127 | + price, err := strconv.Atoi(priceString) | ||
128 | + if err != nil { | ||
129 | + log.Fatal(err) | ||
130 | + } | ||
131 | + return price | ||
132 | +} |
-
Please register or login to post a comment