{ "cells": [ { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "## 이 문서를 수정할 당신에게...\n", "#### 현재 상황은 아래와 같습니다.\n", "1. 우리의 앱에서 중요한 요소 중 하나는 와인을 추천하고, 이를 실제로 구매할 수 있는 링크에 연결하는 것입니다. 또, 실제 와인바를 추천해주기도 합니다. 이를 위해서 실제 와인을 파는 사이트와 와인바를 데이터베이스로 만들어두어야하기 때문에 이를 위해서는 크롤링이 필요합니다.\n", "2. 현재 와인나라와 와인앤모어에 대한 크롤링을 완료한 상태입니다. 하지만 이를 통합된 하나의 데이터베이스로 만드는 과정이 필요합니다.\n", "- 와인나라: [winenara.json](./winenara_scrapy/winenara.json)\n", "- 와인앤모어: [wine_and_more.json](./wine_and_more.json)\n", "3. 와인나라에 대한 데이터를 크롤링했으나 바디, 산도 등 없는 데이터가 많았습니다. 이 때문에 X wine dataset을 이용해 부족한 부분을 추가해주어야합니다.\n", "\n", "#### 당신의 목표는 아래와 같습니다.\n", "1. 아래 사이트에 대한 크롤링을 진행합니다. 크롤링이 필요한 요소는 와인이나 와인바 추천에 필요하다고 생각하는 모든 것을 하면 됩니다. 또한 추가로 더 좋은 사이트의 크롤링도 좋습니다.\n", "\n", " 와인\n", " - [와인나라](https://www.winenara.com/shop/main)\n", " - [와인앤모어](https://www.wineandmore.co.kr/)\n", " - [CU 편의점](https://pocketcu.co.kr/)\n", " - [SSG 와인장터](https://emart.ssg.com/specialStore/wineliquor/main.ssg)\n", " - [롯데온](https://www.lotteon.com/search/search/search.ecn?render=search&platform=pc&q=%EC%99%80%EC%9D%B8&mallId=1)\n", "\n", "\n", "
\n", "\n", "2. 이후 와인에 대한 데이터베이스를 통합합니다. 현재 아래의 schema를 생각하고 있으나 다른 것을 추가하는 것도 좋습니다.\n", "- url(required): 해당 페이지의 url\n", "- site_name(required): 해당 페이지의 출처(와인나라, 와인앤모어 등)\n", "- price(required): 가격\n", "- name: 한글 이름\n", "- en_name: 영어 이름\n", "- img_url(required): 이미지 url 리스트\n", "- body: 바디감(1~5점)\n", "- acidity: 산도(1~5점)\n", "- tannin: 타닌(1~5점)\n", "- sweetness: 당도(1~5점)\n", "- alcohol: %\n", "- wine_type(required): 와인타입(레드, 화이트 등)\n", "- country: 원산지(미국, 독일 등)\n", "- grape: 포도품종\n", "- rating: vivino 점수(1~5점)\n", "- pickup_location: 픽업장소\n", "- vivino_link: vivino 사이트의 와인 상세페이지 url\n", "\n", "#### 참고 사항\n", "현재 작성된 크롤링은 아래의 프레임워크로 작업했습니다.\n", "- [Scrapy](https://docs.scrapy.org/en/latest/intro/tutorial.html)\n", "- [Selenium](https://selenium-python.readthedocs.io/)" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "## 와인나라 크롤링" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "와인나라에 있는 와인에 대한 정보를 가져오는 것을 목적으로 한다.\n", "\n", "크롤링에는 [Scrapy](https://docs.scrapy.org/en/latest/intro/tutorial.html)를 사용한다.\n", "\n", "scrapy를 사용하기 위해서 [새로운 프로젝트](./winenara_scrapy)에서 작업을 한다. 여기서 [winenara_spiders](./winenara_scrapy/tutorial/spiders/winenara_spider.py) 파일을 수정한다." ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "### 상품목록페이지에서 각 상품 상세페이지 링크 추출\n", "" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "위에서 각 상세페이지의 주소를 가져오고, 모든 상세페이지에서 정보를 가져왔다면 다음 페이지 링크를 이용해 다음 페이지로 넘어간다." ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "### 상세페이지에서 와인 정보 추출\n", "" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "위의 이미지는 와인나라의 상세페이지이다. 상세페이지로부터 아래의 값들을 가져온다.\n", "- url: 해당 페이지의 url\n", "- price: 가격\n", "- name: 한글 이름\n", "- en_name: 영어 이름\n", "- img_url: 이미지 url 리스트\n", "- features: 와인에 대한 정보 딕셔너리(바디, 산도, 타닌, 알코올 등)\n", "- tag: 와인에 대한 정보 리스트(와인의 종류, 원산지, 포도품종)\n", "- rating: vivino 점수(1~5점)\n", "- vivino_link: vivino url (vivino는 전세계의 와인 데이터베이스 웹페이지이다.)" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "예를 들어 위의 이미지에 대해서는 아래와 같이 만들어진다.\n", "```json\n", "{\"url\": \"https://www.winenara.com/shop/product/product_view?product_cd=29E033\",\n", " \"price\": \"50,000원\", \n", " \"name\": \"SET)페데럴리스트 카베르네 소비뇽 원통 패키지\", \n", " \"en_name\": \"SET)THE FEDERALIST CABERNET SAUVIGNON\", \n", " \"img_url\": [\"https://www.winenara.com/uploads/product/550/e0705cecad48a9e01710b6fbf7b2f8c5.png\"], \n", " \"features\": {\"바디\": \"중간\"}, \n", " \"tag\": [\"레드\", \"미국\", \"카베르네 소비뇽\"], \n", " \"rating\": \"3.8\", \n", " \"vivino_link\": \"https://www.vivino.com/federalist-cabernet-sauvignon/w/3048981\", \"images\": [{\"url\": \"https://www.winenara.com/uploads/product/550/e0705cecad48a9e01710b6fbf7b2f8c5.png\", \"path\": \"product\\\\550\\\\e0705cecad48a9e01710b6fbf7b2f8c5.png\", \"checksum\": \"e04ed429fd54db2478136c3e03ab592f\", \"status\": \"downloaded\"}]}\n", "```" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "----------------- 코드 실행 ------------------" ] }, { "cell_type": "code", "execution_count": 1, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "c:\\Users\\chois\\Desktop\\Audrey\\chatwine\\winenara_scrapy\n" ] } ], "source": [ "%cd winenara_scrapy" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "크롤링 과정에서 img_url의 이미지가 로컬에 저장된다. 이를 위해 [settings.py](./winenara_scrapy/tutorial/settings.py)에 이미지가 저장될 경로를 설정한다. settings.py에 경로를 변경해서 아래 코드를 추가해준다.\n", "```python\n", "ITEM_PIPELINES = {'tutorial.pipelines.CustomImagesPipeline': 1}\n", "IMAGES_STORE = 'C:/Users/chois/Desktop/Audrey/chatwine/winenara_scrapy/assets/img'\n", "```" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "또한 이미지 저장에 있어서 이미지의 이름을 변경해주기 위해 [pipelines.py](./winenara_scrapy/tutorial/pipelines.py)에 CustomImagePipeline 클래스를 추가해준다. 코드는 아래와 같다.\n", "```python\n", "import os\n", "from urllib.parse import urlparse\n", "\n", "import scrapy\n", "from scrapy.pipelines.images import ImagesPipeline\n", "\n", "class CustomImagesPipeline(ImagesPipeline):\n", " def file_path(self, request, response=None, info=None, *, item=None):\n", " path = urlparse(request.url).path\n", " path_parts = path.split('/')\n", " filename = os.path.join(*path_parts[-3:])\n", " return filename\n", "\n", " def get_media_requests(self, item, info):\n", " if 'img_url' in item:\n", " for image_url in item['img_url']:\n", " yield scrapy.Request(image_url)\n", "```" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "아래 코드를 실행하여 얻은 결과는 [winenara.json](./winenara_scrapy/winenara.json)에서 확인할 수 있다." ] }, { "cell_type": "code", "execution_count": 2, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "'c:\\\\Users\\\\chois\\\\Desktop\\\\Audrey\\\\chatwine\\\\winenara_scrapy'" ] }, "execution_count": 2, "metadata": {}, "output_type": "execute_result" } ], "source": [ "!scrapy crawl winenara -o winenara.json # winenara spider를 실행하여 결과를 winenara.json에 저장" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "### 와인나라 와인의 vivino를 통한 특징 추출" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "와인나라에서는 판매하는 와인에 대한 feature(바디감, 당도 등...)를 제공하고 있다. 하지만 작성이 안되어있는 와인들도 많다. 따라서 이 정보들을 가져오기 위해서 아래 두 가지의 외부 데이터를 사용할 것이다. \n", "1. [vivino api](https://github.com/aptash/vivino-api)\n", "2. [X-wine dataset](https://github.com/rogerioxavier/X-Wines)" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "이를 위해 먼저 vivino api github을 클론한다." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "%cd .. # pwd가 chatwine이 되도록 함\n", "!git clone \"https://github.com/aptash/vivino-api.git\"" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "이후 [vivino.js](./vivino-api/vivino.js)의 244 line을 일부 수정한다. 이는 각 와인 검색의 json파일이 저장될 경로를 지정하기 위함이다.\n", "```javascript\n", "const outFile = fs.createWriteStream('./output/'+name+'.json');\n", "```" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "vivino api는 아래와 같이 사용할 수 있다.\n", "```shell\n", "cd vivino-api\n", "node vivino.js --name=ad-vivum-cabernet-sauvignon --minPrice=10 --maxPrice=25\n", "```\n", "\n", "이것에 대한 결과는 [./output/ad-vivum-cabernet-sauvignon.json](vivino-api/output/ad-vivum-cabernet-sauvignon.json)과 같이 json파일로 얻을 수 있다.\n", "```json\n", "{\n", " \"vinos\": [\n", " {\n", " \"name\": \"AD VIVUM Cabernet Sauvignon\",\n", " \"link\": \"https://www.vivino.com/US-CA/en/wines/2493776\",\n", " \"thumb\": \"https://images.vivino.com/thumbs/O_7LrDsLQSOxl2CqVfRHVA_pb_300x300.png\",\n", " \"country\": \"United States\",\n", " \"region\": \"Napa Valley\",\n", " \"average_rating\": 4.4,\n", " \"ratings\": 261,\n", " \"price\": 186.16\n", " }\n", " ],\n", " \"status\": \"FULL_DATA\"\n", "}\n", "```" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "이제 [winenara.json](./winenara_scrapy/winenara.json)의 와인들에 대해서 vivino api를 통해 검색하는 코드를 보자.\n", "\n", "검색을 위해서는 winenara.json의 vivino_link를 활용한다. url의 쿼리에서 와인 이름을 추출하여 이를 이용하여 검색한다. 예시는 아래와 같다.\n", "\n", "> vivino_link: https://www.vivino.com/monopole-la-rioja-blanco-seco-clasico/w/1925448
\n", "> -> 추출한 와인이름: monopole-la-rioja-blanco-seco-clasico" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import json\n", "from tqdm import tqdm\n", "\n", "# Read the winenara json file\n", "with open('./winenara_scrapy/winenara.json', 'r') as f:\n", " data = json.load(f)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "%cd vivino-api" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "아래 코드처럼 멀티프로세싱을 활용하여 와인 데이터를 검색한다. 그 결과는 \"./vivino-api/output\"에 저장된다." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import concurrent.futures\n", "from tqdm import tqdm\n", "\n", "import os\n", "file_list = os.listdir('./output')\n", "json_list = [file.replace('.json', '') for file in file_list]\n", "\n", "data_list = []\n", "for wine_data in data:\n", " if wine_data['vivino_link'].split('/')[-3] not in json_list:\n", " data_list.append(wine_data)\n", "\n", "def fetch_wine_data(wine_data):\n", " wine_name = wine_data['vivino_link'].split('/')[-3]\n", " !node vivino.js \"--name={wine_name}\"\n", "\n", "# Use a ThreadPoolExecutor to run fetch_wine_data in parallel\n", "with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor:\n", " list(tqdm(executor.map(fetch_wine_data, data_list), total=len(data_list)))" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "### 와인나라 와인의 X-wine 데이터셋을 통한 특징 추출" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "### 와인나라 와인의 데이터 통합" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "아래 과정은 naive하게 크롤링한 와인나라 와인의 통합된 형태로 변경하고, json을 저장하는 과정입니다." ] }, { "cell_type": "code", "execution_count": 1, "metadata": {}, "outputs": [], "source": [ "import json\n", "import pandas as pd" ] }, { "cell_type": "code", "execution_count": 23, "metadata": {}, "outputs": [], "source": [ "with open('./data/winenara.json', 'r', encoding='utf-8') as f:\n", " wine_list = json.load(f)\n", "df = pd.DataFrame(wine_list, columns=['price', 'name', 'en_name','rating', 'url', 'tag', 'features', 'img_url', 'vivino_link'])" ] }, { "cell_type": "code", "execution_count": 24, "metadata": {}, "outputs": [], "source": [ "wine_type_list = ['레드', '로제', '스파클링', '화이트', '디저트', '주정강화']\n", "country_list = ['기타 신대륙', '기타구대륙', '뉴질랜드', '독일', '미국', '스페인', '아르헨티나', '이탈리아', '칠레', '포루투칼', '프랑스', '호주']" ] }, { "cell_type": "code", "execution_count": 25, "metadata": {}, "outputs": [], "source": [ "drop_idx = []\n", "for idx in range(len(df)):\n", " if (df['tag'][idx][0] not in wine_type_list) or (df['tag'][idx][1] not in country_list):\n", " drop_idx.append(idx)\n", "df = df.drop(drop_idx)\n", "df = df.reset_index(drop=True)\n" ] }, { "cell_type": "code", "execution_count": 26, "metadata": {}, "outputs": [], "source": [ "df['wine_type'] = ''\n", "df['country'] = ''\n", "df['sweetness'] = ''\n", "df['body'] = ''\n", "df['alcohol'] = ''\n", "for idx in range(len(df)):\n", " df['wine_type'][idx] = df['tag'][idx][0]\n", " df['country'][idx] = df['tag'][idx][1]\n", " for key, value in df['features'][idx].items():\n", " if key == '당도':\n", " df['sweetness'][idx] = value\n", " elif key == '알코올':\n", " df['alcohol'][idx] = value\n", " elif key == '바디':\n", " df['body'][idx] = value\n", " else:\n", " raise ValueError('Wrong feature key')" ] }, { "cell_type": "code", "execution_count": 27, "metadata": {}, "outputs": [], "source": [ "df = df.drop(columns=['tag', 'features'])" ] }, { "cell_type": "code", "execution_count": 28, "metadata": {}, "outputs": [], "source": [ "df['price'] = df['price'].replace('원', '', regex=True)\n", "df['price'] = df['price'].replace(',', '', regex=True)" ] }, { "cell_type": "code", "execution_count": 29, "metadata": {}, "outputs": [], "source": [ "sweetness_dict = {'':-1, '드라이':1, '미디움드라이':2, '미디엄':3, '미디움스윗':4, '스윗':5}\n", "body_dict = {'':-1, '가벼움':1, '약간가벼움':2, '중간':3, '약간무거움':4, '무거움':5}\n", "alcohol_dict = {'':-1, '중간(12~13%)':3,'높음(14%+)':5}\n" ] }, { "cell_type": "code", "execution_count": 30, "metadata": {}, "outputs": [], "source": [ "for idx in range(len(df)):\n", " df['sweetness'][idx] = sweetness_dict[df['sweetness'][idx]]\n", " df['body'][idx] = body_dict[df['body'][idx]]\n", " df['alcohol'][idx] = alcohol_dict[df['alcohol'][idx]]" ] }, { "cell_type": "code", "execution_count": 31, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "Index(['price', 'name', 'en_name', 'rating', 'url', 'img_url', 'vivino_link',\n", " 'wine_type', 'country', 'sweetness', 'body', 'alcohol'],\n", " dtype='object')" ] }, "execution_count": 31, "metadata": {}, "output_type": "execute_result" } ], "source": [ "df.columns" ] }, { "cell_type": "code", "execution_count": 32, "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
pricenameen_nameratingurlimg_urlvivino_linkwine_typecountrysweetnessbodyalcohol
049000모노폴 클라시코Monopole Classico3.8https://www.winenara.com/shop/product/product_...[https://www.winenara.com/uploads/product/550/...https://www.vivino.com/monopole-la-rioja-blanc...화이트스페인-13-1
132000슐럼베르거 로제 스페셜 브뤼Schlumberger Rose Special Brut3.8https://www.winenara.com/shop/product/product_...[https://www.winenara.com/uploads/product/550/...https://www.vivino.com/schlumberger-spring-edi...스파클링독일-13-1
250000SET)페데럴리스트 샤르도네 원통 패키지SET)THE FEDERALIST CHARDONNAY3.7https://www.winenara.com/shop/product/product_...[https://www.winenara.com/uploads/product/550/...https://www.vivino.com/federalist-chardonnay-m...화이트미국-13-1
355000베니카 트레 비니스VENICA TRE VIGNIS3.9https://www.winenara.com/shop/product/product_...[https://www.winenara.com/uploads/product/550/...https://www.vivino.com/US-CA/en/venica-venica-...화이트이탈리아-14-1
424900SET)빌라엠비앙코 + 글라스2개 윈터패키지SET)VILLA M Bianco + GLASS WINTER PACKAGE3.9https://www.winenara.com/shop/product/product_...[https://www.winenara.com/uploads/product/550/...https://www.vivino.com/villa-m-bianco/w/1774733디저트이탈리아4-1-1
\n", "
" ], "text/plain": [ " price name en_name \\\n", "0 49000 모노폴 클라시코 Monopole Classico \n", "1 32000 슐럼베르거 로제 스페셜 브뤼 Schlumberger Rose Special Brut \n", "2 50000 SET)페데럴리스트 샤르도네 원통 패키지 SET)THE FEDERALIST CHARDONNAY \n", "3 55000 베니카 트레 비니스 VENICA TRE VIGNIS \n", "4 24900 SET)빌라엠비앙코 + 글라스2개 윈터패키지 SET)VILLA M Bianco + GLASS WINTER PACKAGE \n", "\n", " rating url \\\n", "0 3.8 https://www.winenara.com/shop/product/product_... \n", "1 3.8 https://www.winenara.com/shop/product/product_... \n", "2 3.7 https://www.winenara.com/shop/product/product_... \n", "3 3.9 https://www.winenara.com/shop/product/product_... \n", "4 3.9 https://www.winenara.com/shop/product/product_... \n", "\n", " img_url \\\n", "0 [https://www.winenara.com/uploads/product/550/... \n", "1 [https://www.winenara.com/uploads/product/550/... \n", "2 [https://www.winenara.com/uploads/product/550/... \n", "3 [https://www.winenara.com/uploads/product/550/... \n", "4 [https://www.winenara.com/uploads/product/550/... \n", "\n", " vivino_link wine_type country \\\n", "0 https://www.vivino.com/monopole-la-rioja-blanc... 화이트 스페인 \n", "1 https://www.vivino.com/schlumberger-spring-edi... 스파클링 독일 \n", "2 https://www.vivino.com/federalist-chardonnay-m... 화이트 미국 \n", "3 https://www.vivino.com/US-CA/en/venica-venica-... 화이트 이탈리아 \n", "4 https://www.vivino.com/villa-m-bianco/w/1774733 디저트 이탈리아 \n", "\n", " sweetness body alcohol \n", "0 -1 3 -1 \n", "1 -1 3 -1 \n", "2 -1 3 -1 \n", "3 -1 4 -1 \n", "4 4 -1 -1 " ] }, "execution_count": 32, "metadata": {}, "output_type": "execute_result" } ], "source": [ "df.head()" ] }, { "cell_type": "code", "execution_count": 42, "metadata": {}, "outputs": [], "source": [ "winenara_attribute = ['price', 'name', 'en_name', 'rating', 'url', 'wine_type', 'country', 'sweetness', 'body', 'alcohol', 'img_url', 'vivino_link']\n", "unified_attribute = ['url', 'site_name', 'price', 'name', 'en_name', 'img_url', 'body', 'acidity', 'tannin', \"sweetness\", \"alcohol\", \"wine_type\", 'country', 'grape', 'rating', 'pickup_location', 'vivino_link']\n", "unified_dict = {'url':'url', 'site_name':'', 'price':'price', 'name':'name', 'en_name':'en_name', 'img_url':'img_url', 'body':'body', 'acidity':'', 'tannin':'', \"sweetness\":'sweetness', \"alcohol\":'alcohol', \"wine_type\":'wine_type', 'country':'country', 'grape':'', 'rating':'rating', 'pickup_location':'', 'vivino_link':'vivino_link'}\n", "data_list = []\n", "for idx in range(len(df)):\n", " single_data = {}\n", " for unified_attr, winenara_attr in unified_dict.items():\n", " if winenara_attr != '':\n", " if type(df[winenara_attr][idx]) == list:\n", " single_data[unified_attr] = ', '.join(df[winenara_attr][idx])\n", " else:\n", " single_data[unified_attr] = df[winenara_attr][idx] \n", " elif unified_attr == 'site_name':\n", " single_data[unified_attr] = 'winenara'\n", " else:\n", " single_data[unified_attr] = ''\n", " data_list.append(single_data)" ] }, { "cell_type": "code", "execution_count": 43, "metadata": {}, "outputs": [], "source": [ "with open('./data/unified_wine_data.json', 'w', encoding='utf-8') as json_file:\n", " for data in data_list:\n", " json_file.write(json.dumps(data, ensure_ascii=False) + '\\n')" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "## 와인앤모어 크롤링" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "와인앤모어 크롤링을 위해서는 [Selenium](https://selenium-python.readthedocs.io/)을 사용한다.\n", "\n", "Scrapy를 사용해서도 할 수 있다.(아마도?) 근데 나는 scrapy로 로그인과 성인인증을 뚫지 못해서 사람이 브라우저와 interaction하는 것과 비슷한 Selenium을 사용하였다." ] }, { "cell_type": "code", "execution_count": 4, "metadata": {}, "outputs": [], "source": [ "import os\n", "from time import sleep\n", "import configparser\n", "\n", "from tqdm import tqdm\n", "from selenium import webdriver\n", "from selenium.webdriver.common.keys import Keys\n", "from webdriver_manager.chrome import ChromeDriverManager\n", "from selenium.webdriver.common.by import By\n", "from selenium.webdriver.support.ui import WebDriverWait\n", "from selenium.webdriver.support import expected_conditions as EC" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "아래 코드를 실행하면 크롬이 열리고, 와인앤모어 로그인 페이지로 접속하게 된다." ] }, { "cell_type": "code", "execution_count": 14, "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "[WDM] - Downloading: 100%|██████████| 6.30M/6.30M [00:00<00:00, 21.5MB/s]\n", "C:\\Users\\chois\\AppData\\Local\\Temp\\ipykernel_17584\\3443994688.py:2: DeprecationWarning: executable_path has been deprecated, please pass in a Service object\n", " driver = webdriver.Chrome(ChromeDriverManager().install())\n" ] } ], "source": [ "# Setup the driver. This one uses chrome with some options and a path to the chromedriver\n", "driver = webdriver.Chrome(ChromeDriverManager().install())\n", "\n", "# implicitly_wait tells the driver to wait before throwing an exception\n", "driver.implicitly_wait(30)\n", "\n", "# go to the login page\n", "driver.get('https://www.wineandmore.co.kr/member/login.php') # 와인앤모어 로그인페이지 접속" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "로컬파일로부터 와인앤모어 아이디, 비밀번호 가져오기" ] }, { "cell_type": "code", "execution_count": 10, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "['./secrets.ini']" ] }, "execution_count": 10, "metadata": {}, "output_type": "execute_result" } ], "source": [ "config = configparser.ConfigParser()\n", "config.read('./secrets.ini')" ] }, { "cell_type": "code", "execution_count": 11, "metadata": {}, "outputs": [], "source": [ "wineandmore_id = config['WINE_AND_MORE']['ID']\n", "wineandmore_password = config['WINE_AND_MORE']['PASSWORD']" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "아래 코드를 실행하면 아이디, 패스워드를 입력하는 부분이 채워진다." ] }, { "cell_type": "code", "execution_count": 15, "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "C:\\Users\\chois\\AppData\\Local\\Temp\\ipykernel_17584\\600041264.py:2: DeprecationWarning: find_element_by_* commands are deprecated. Please use find_element() instead\n", " username = driver.find_element_by_id(\"loginId\")\n", "C:\\Users\\chois\\AppData\\Local\\Temp\\ipykernel_17584\\600041264.py:3: DeprecationWarning: find_element_by_* commands are deprecated. Please use find_element() instead\n", " password = driver.find_element_by_id(\"loginPwd\")\n", "C:\\Users\\chois\\AppData\\Local\\Temp\\ipykernel_17584\\600041264.py:10: DeprecationWarning: find_element_by_class_name is deprecated. Please use find_element(by=By.CLASS_NAME, value=name) instead\n", " driver.find_element_by_class_name('member_login_order_btn').click()\n" ] } ], "source": [ "# the driver.get method will navigate to a page given by the URL\n", "username = driver.find_element_by_id(\"loginId\")\n", "password = driver.find_element_by_id(\"loginPwd\")\n", "\n", "# Target the form elements and send_keys to simulate key strokes\n", "username.send_keys(wineandmore_id)\n", "password.send_keys(wineandmore_password)\n", "\n", "# Submit form\n", "driver.find_element_by_class_name('member_login_order_btn').click()\n", "\n", "# Allow time for the website to load\n", "sleep(5)" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "### 상품목록페이지에서 각 상품 상세페이지 링크 추출\n", "\n", "상품리스트 페이지는 다음과 같은 url구조를 갖고있다. idx에 페이지 번호가 들어가게된다.\n", "\n", "https://www.wineandmore.co.kr/goods/goods_list.php?page={idx}&cateCd=001\n", "\n", "페이지는 1~28까지 있으므로 순회하며 크롤링한다.(정말 나중에는 바꿔줘야하는 안좋은 코드다..)\n", "\n", "" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "### 상품 상세페이지에서 와인 데이터 가져오기\n", "" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "위의 이미지는 와인앤모어의 상세페이지이다. 상세페이지로부터 아래의 값들을 가져온다.\n", "- url: 해당 페이지의 url\n", "- name: 한글 이름\n", "- price: 가격\n", "- feature_img_url: 와인에 대한 상세 정보가 담겨있는 이미지 url\n", "- img_url: 이미지 url 리스트\n", "- pickup_info: 해당 와인의 픽업이나 구매가 가능한 와인앤모어 오프라인 매장" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "예를 들어 위의 이미지에 대해서는 아래와 같이 만들어진다.\n", "```json\n", "\"url\": \"https://www.wineandmore.co.kr/goods/goods_view.php?goodsNo=1000002401\", \n", "\"name\": \"장 미셸 기불로 사비니 레 본 블랑\", \n", "\"price\": \"53,000\", \n", "\"feature_img_url\": \"https://shinsegaelnb.cdn-nhncommerce.com/data/editor/goods/230426/f2dcc9b3248c7d163cbb29c4d452cb4b_173014.jpg\",\n", "\"img_url\": \"https://shinsegaelnb.cdn-nhncommerce.com/data/goods/23/02/08/1000002401/1000002401_detail_048.jpg\", \n", "\"pickup_info\": [\"와인앤모어 교대역점\", \"와인앤모어 다산점\", \"와인앤모어 서울대입구역점\", \"와인앤모어 스타필드 위례점\", \"와인앤모어 여의도점\"]\n", "```" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "여기서 feature_img_url이라는 것이 있는데, 이는 아래 사진과 같다. 나중에 OCR을 통해 데이터를 뽑아와야한다.\n", "\n", "" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "아래 함수는 상세페이지에서 데이터를 가져오는 코드이다." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def get_detail_info(detail_url):\n", " driver.get(detail_url)\n", " # Allow time for the website to load\n", " sleep(10)\n", " data_dict = {}\n", " data_dict['url'] = driver.current_url\n", "\n", " name = driver.find_element(By.CSS_SELECTOR, \".item_detail_tit h3\")\n", " data_dict['name'] = name.text if name else \"\"\n", "\n", " price = driver.find_element(By.CSS_SELECTOR, \"dl.item_price strong strong\")\n", " data_dict['price'] = price.text if price else \"\"\n", "\n", " data_dict['feature_img_url'] = driver.find_elements(By.CSS_SELECTOR, '.js-smart-img')[3].get_attribute('src')\n", "\n", " img_url = driver.find_element(By.CSS_SELECTOR, \".zoom_layer_open img.middle\")\n", " data_dict['img_url'] = img_url.get_attribute('src')\n", "\n", " pickup_info_list = driver.find_elements(By.CSS_SELECTOR, \".form_element select option\")\n", " data_dict['pickup_info'] = [pickup_info.text for pickup_info in pickup_info_list if pickup_info.text != \"매장선택\"]\n", "\n", " return data_dict" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "아래 코드를 실행하면 페이지를 순회하면 크롤링을 진행하고, 이를 data_list에 저장한다." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Navigate to product list page\n", "data_list = []\n", "for idx in range(1, 29):\n", " driver.get(f'https://www.wineandmore.co.kr/goods/goods_list.php?page={idx}&cateCd=001')\n", " sleep(10)\n", " product_links = driver.find_elements_by_css_selector(\"div.item_photo_box a\")\n", " links = set()\n", " for link in product_links:\n", " links.add(link.get_attribute('href'))\n", " for detail_url in tqdm(links):\n", " data_list.append(get_detail_info(detail_url))" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "아래 코드를 통해 수집한 데이터를 [json 파일](./wine_and_more.json)로 저장한다." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import json\n", "\n", "with open('wine_data.json', 'w', encoding='utf-8') as json_file:\n", " for data in data_list:\n", " json_file.write(json.dumps(data, ensure_ascii=False) + '\\n')" ] }, { "cell_type": "code", "execution_count": 16, "metadata": {}, "outputs": [], "source": [ "driver.close()" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "## CU 와인 크롤링" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "PocketCU라는 CU편의점 재고를 확인하는 앱이있다. 다행히 이 앱을 웹에서도 확인할 수 있다. 크게 아래의 3가지 url로 확인가능하다.\n", "- 지도에서 아이템 재고 확인: [https://www.pocketcu.co.kr/search/stock?isRecommend=Y&item_cd={아이템id}](https://www.pocketcu.co.kr/search/stock?isRecommend=Y&item_cd=5011007015534)\n", "\n", "\n", "\n", "- 아이템이름으로 검색: [https://www.pocketcu.co.kr/search/total/product/cubar?searchWord={아이템이름}](https://www.pocketcu.co.kr/search/total/product/cubar?searchWord=%EC%9C%84%EC%8A%A4%ED%82%A4)\n", "\n", "\n", "\n", "- 특정 매장에 어떤 아이템의 재고 확인: [https://www.pocketcu.co.kr/search/stock/storeItems/{점포id}?searchWord={아이템이름}](https://www.pocketcu.co.kr/search/stock/storeItems/04456?searchWord=%EC%A0%9C%EC%9E%84%EC%8A%A8)\n", "\n", "" ] } ], "metadata": { "kernelspec": { "display_name": "nemo", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.10.11" }, "orig_nbformat": 4 }, "nbformat": 4, "nbformat_minor": 2 }