{
"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", " | price | \n", "name | \n", "en_name | \n", "rating | \n", "url | \n", "img_url | \n", "vivino_link | \n", "wine_type | \n", "country | \n", "sweetness | \n", "body | \n", "alcohol | \n", "
---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | \n", "49000 | \n", "모노폴 클라시코 | \n", "Monopole Classico | \n", "3.8 | \n", "https://www.winenara.com/shop/product/product_... | \n", "[https://www.winenara.com/uploads/product/550/... | \n", "https://www.vivino.com/monopole-la-rioja-blanc... | \n", "화이트 | \n", "스페인 | \n", "-1 | \n", "3 | \n", "-1 | \n", "
1 | \n", "32000 | \n", "슐럼베르거 로제 스페셜 브뤼 | \n", "Schlumberger Rose Special Brut | \n", "3.8 | \n", "https://www.winenara.com/shop/product/product_... | \n", "[https://www.winenara.com/uploads/product/550/... | \n", "https://www.vivino.com/schlumberger-spring-edi... | \n", "스파클링 | \n", "독일 | \n", "-1 | \n", "3 | \n", "-1 | \n", "
2 | \n", "50000 | \n", "SET)페데럴리스트 샤르도네 원통 패키지 | \n", "SET)THE FEDERALIST CHARDONNAY | \n", "3.7 | \n", "https://www.winenara.com/shop/product/product_... | \n", "[https://www.winenara.com/uploads/product/550/... | \n", "https://www.vivino.com/federalist-chardonnay-m... | \n", "화이트 | \n", "미국 | \n", "-1 | \n", "3 | \n", "-1 | \n", "
3 | \n", "55000 | \n", "베니카 트레 비니스 | \n", "VENICA TRE VIGNIS | \n", "3.9 | \n", "https://www.winenara.com/shop/product/product_... | \n", "[https://www.winenara.com/uploads/product/550/... | \n", "https://www.vivino.com/US-CA/en/venica-venica-... | \n", "화이트 | \n", "이탈리아 | \n", "-1 | \n", "4 | \n", "-1 | \n", "
4 | \n", "24900 | \n", "SET)빌라엠비앙코 + 글라스2개 윈터패키지 | \n", "SET)VILLA M Bianco + GLASS WINTER PACKAGE | \n", "3.9 | \n", "https://www.winenara.com/shop/product/product_... | \n", "[https://www.winenara.com/uploads/product/550/... | \n", "https://www.vivino.com/villa-m-bianco/w/1774733 | \n", "디저트 | \n", "이탈리아 | \n", "4 | \n", "-1 | \n", "-1 | \n", "