이 포스팅은 공부 목적으로 작성된 포스팅입니다. 왜곡된 내용이 포함되어 있을 수 있습니다
방학 중에 진행중인 프로젝트에서 식당 관련된 요구사항이 있었다.
- 식당정보, 식당 메뉴 영업시간, 식당 사진과 같은 식당 세부 정보를 조회할 수 있다.
- 식당의 위치를 조회할 수 있다.
사전 조사
식당 정보를 제공하는 OPEN API, 공공 데이터가 있다고 생각하고 조사를 시작했지만 조사 하면서 제공되는 API가 없다는 것을 알게 되었다. 먼저 위치 정보를 알아야 하기 때문에 지도 API를 조사하였다, 대중적인 지도 API는 네이버 지도, 카카오 맵, 구글 맵가 있었다.
각각 API를 조사했을때, 식당 세부정보를 위해 사용할만한 API는 없었다. 그나마 구글맵에서 유사하게 가게 상세정보를 제공하는 API가 있었는데 인자값으로 위도와 경로 값을 필요로 한다는점에서 동일 위치에 있는 식당에 대한 처리가 불가능한 것과 같이 한계점이 있어 보였다, 네이버 지도 API는 검색 API가 있었지만 반환값으로 위치, 식당 URL(인스타그램, 자체 사이트) 정도만 제공하고 있었다.
따라서 API로 식당 정보를 조회할 수 없다고 생각하고 직접 크롤링하여 데이터를 구축하기로 결정하였다.
공공 데이터로는 서울시 인허가 정보가 있었는데 50만개로 사용할 만 했다.(다만 페업한 식당도 포함되어 있었다)
https://data.seoul.go.kr/dataList/OA-16094/S/1/datasetView.do
크롤링
크롤링이란 웹에 존재하는 데이터를 수집하는 행위이다. GET 요청이랑 별다른 차이점이 없어보일 수 있으나, GET 요청이외에도 동적으로 웹페이지를 탐색하여 데이터를 수집하는데, 정적 데이터를 받는 GET 요청과는 차이점이 있다.(물론 GET 요청도 크롤링 방법중 하나가 될 수 있다) 특정 방법에 대한 단어라기 보단 웹상에 있는 데이터를 가공하는 테스트라는 넓은 의미로 크롤링 사용하고 있다.
사이드 프로젝트를 하는 입장에서 크롤링은 유용한 기술이 될 수 있는데, 초기 데이터가 없는 상황에서 크롤링을 통해 데이터를 구축할 수 있고, 나와 같이 학생 입장에서 할 수 없는 정제된 데이터를 손쉽게 가져올 수 있다.(프로젝트에서 할 수 있는 것들이 많아진다)
그러면 웹상에서 크롤링을 사용하는 것은 크게 문제가 없을까?
크롤링 주의사항
서비스를 운영하고 있는 입장에서는 크롤링을 좋게 보긴 여려운데, 서비스의 불필요한 트레픽의 원인이 될 수 있다. 대체로 크롤링은 수동으로 수행되는 것이 아닌 자동으로 수백만개의 페이지를 빠른 속도로 읽는 형태로 수행되기 때문에 크롤링이 진행되는 동안에는 해당 웹사이트에 수백만개의 GET 요청이 보내지게 된다. 이외에도 해당 서비스가 관리하고 있는 데이터를 허락없이 가져오는 것은 문제가 된다(이건 서비스를 이용하는 사용자와 같은 것이라고 볼 수 없다)
실제로 기업에서 타 기업 서비스를 크롤링하여 사용하여 법적 처벌받은 사례가 있다. 단순히 크롤링하면 처벌 받는다 보단, 1. 어떤 데이터를 2. 어느정도로 가져와서 3. 어디에 4. 어떤 의도로 사용했는지에 따라 처벌 정도가 달라지는데 복잡한 문제이다.
만약 실제로 배포할 서비스에 대해서 크롤링을 수행한다면 한번 고려해봐야한다.(소규모 프로젝트의 경우 소송까지는 하지 않는 것 같다.)
그렇다면 모든 사이트에 대해서 크롤링을 사용할때, 주의를 해야할까?
robots.txt 라는 웹사이트 접근 규악 파일에서 해당 도메인의 크롤링 규제를 명세할 수 있다. 실제로 네이버에서 제공하고 있는 robots.txt를 확인해보자
User-agent: *
Disallow: /
Allow : /$
Allow : /.well-known/privacy-sandbox-attestations.json
naver.com/robots.txt 와 같이 도메인 + robots.txt으로 해당 도메인의 robots.txt를 확인할 수 있다.
naver.com의 경우 메인 페이지를 제외한 모든 페이지 크롤링을 제한하고 있었다.
다만 robots.txt는 실제 금지가 아닌 권고의 의미라고 한다. 따라서 절대 금지는 아니지만 도메인 입장에서는 어느정도 주시하고 있다고 생각하면 되겠다.
네이버 지도를 크롤링해야되는 상황에서 robots.txt에도 지도 크롤링을 제한하고 있었지만, 배포되지 않는 서비스라는 점과 크롤링 데이터를 어느 정도 축소해서 사용한다는 점에서 지도 크롤링을 사용해도 문제 없다고 생각했다.
번외로 조사하면서 허가되지 않은 도메인 상대로 크롤링하는 서비스가 몇개 떠올랐는데, 규모가 작은 프로젝트이거나, 수익이 나지 않는 프로젝트 정도는 어느정도 용인해주고 있는 것 같다.
크롤링 방법
크롤링은 크게 두가지 방법으로 나뉜다.
- 정적 크롤링
- 동적 크롤링
정적 크롤링은 정적 데이터 (GET 요청)에 대한 크롤링으로, 일반적으로 BeautifulSoup를 사용한다.
동적 크롤링은 정적 데이터로 처리되지 않는 영역, redirect 되거나 클릭 이벤트와 같은 상황에서 사용하는 크롤링으로 selenium와 webdriver를 사용한다.
내가 크롤링하는 네이버 지도를 보면서 크롤링 방법을 결정해보자
map.naver.com/p/search/{검색어} 를 통해 검색을 할수 있었다.
검색을 해보다가 redirect되는 상황이 있었는데 검색결과 가 하나인 경우에 해당 식당 상세 조회 페이지로 redirect 되고 있었다.
위와 같이 건대 정면을 검색하면 map.naver.com/p/search/건대%20정면/place/1765453403?c=15.00,0,0,0,dh&isCorrectAnswer=true 으로 redirect되었다.
그러면 map.naver.com/p/search/{검색어}/place 으로 검색하여 한번에 상세조회하면 안될까?
제대로 되지 않는다. place뒤에 /1765453403 을 붙여줘야 했다.(확실하지 않지만 위치정보 같다)
따라서 상세조회를 하기 위해서는 map.naver.com/p/search/{검색어}와 같이 검색후 하나의 식당을 클릭하여 상세조회 페이지로 넘어 가거나 map.naver.com/p/search/{검색어} 결과가 하나의 식당값으로 redirect 되길 기도하는 수 밖에 없었다.
나는 후자의 방법을 선택했는데 만약 전자의 방법을 선택하면 어떤 식당을 클릭할지도 처리해줘야하기 때문에 단순한 후자의 방법을 선택했다.
내가 크롤링할 정보는 가게 사진과, 가게 메뉴 정보(이름, 가격), 가게 영업 시간이였고, 이때 가게 영업 시간은 현재 영업 정보를 클릭해야 했기 때문에 정적 크롤링이 불가능했다. 따라서 동적 크롤링 방법을 선택하였다.
크롤링 수행하기
{
"lastmodts": "2021-06-09 16:33:50",
"dtlstatenm": "영업",
"totepnum": null,
"wmeipcnt": null,
"bplcnm": "백제추어탕군자역점",
"trdstategbn": "01",
"trdstatenm": "영업/정상",
"apvcancelymd": null,
"sitepostno": "143908",
"fctysiljobepcnt": null,
"opnsfteamcode": "3040000",
"sitetel": "02 60325200",
"fctypdtjobepcnt": null,
"sitewhladdr": "서울특별시 광진구 중곡동 341-8 1층 ",
"dtlstategbn": "01",
"rdnpostno": "04929",
"uptaenm": "한식",
"hoffepcnt": null,
"rdnwhladdr": "서울특별시 광진구 천호대로 569, 1층 (중곡동)",
"sntuptaenm": "한식",
"y": "450502.916083112 ",
"ropnymd": null,
"mgtno": "3040000-101-2021-00020",
"x": "207132.241304677 "
}
위 json 객체는 서울시 인허가 정보 데이터으로 가게 이름과 위치를 알 수 있다
https://data.seoul.go.kr/dataList/OA-16094/S/1/datasetView.do
def crawl_restaurant_info(json):
global no_places, no_photos, no_menus, no_times
data = {
"name": json['bplcnm'],
"address": json['sitewhladdr'],
"type": json['uptaenm'],
"images": [],
"menu": None,
"time":None
}
webDriver = webdriver.Chrome()
url = "https://map.naver.com/p/search/"+json['bplcnm']+" 광진구"
webDriver.get(url) # 해당 URL로 접속
wait = WebDriverWait(webDriver, 5) # 1
가게 이름(name) 주소(address) 가게 타입(type)을 데이터에 가져오고 추가적으로 크롤링할 데이터를 저장할 이미지(images), 메뉴(menu), 영업시간(time)을 추가적으로 필드로 생성한다. 이후 webDriver에게 "https://map.naver.com/p/search/"+json['bplcnm']+" 광진구" 으로 get 요청을 보내도록 한다 가게 이름뒤에 광진구를 붙인 이유는 현재 가지고 있는 json 데이터를 광진구 기준으로 정렬하였고, 검색 성능을 높이기 위해 넣었다.(최대한 하나의 검색결과로 redirect되야하기 때문에)
try:
iframe_element = wait.until(EC.visibility_of_element_located((By.ID, "entryIframe")))
# iframe 으로 변경
webDriver.switch_to.frame(iframe_element)
except Exception as e:
no_places+=1;
return data
데이터를 받아 왔음에도 불구 하고 데이터를 확인할 수 없는 에러가 있었는데 iframe이 안에 있는 겨우 해당 iframe으로 변경해야 내부 block에 접근할 수 있다고 한다.
# 1. 가게 메뉴를 조회한다.
try:
body_element1 = wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, ".place_section >.place_section_content>ul")))
data["menu"] = body_element1.text
print("가게 메뉴:", body_element1.text)
except Exception as e:
no_menus+=1
print("가게 메뉴 크롤링 실패:", e)
# 2. 가게 사진을 조회한다.
try:
body_element2 = wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, "#app-root > div > div > div > div.CB8aP > div")))
img_elements = body_element2.find_elements(By.TAG_NAME, "img")
data["images"] = [img.get_attribute("src") for img in img_elements if img.get_attribute("src")]
# 각 <img> 태그의 src 속성에서 URL 추출
for img in img_elements:
img_url = img.get_attribute("src")
print("가게 사진:", img_url)
except Exception as e:
no_photos+=1
print("가게 사진 크롤링 실패:", e)
# 3. 영업시간을 조회한다.
try:
button_element = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, ".gKP9i.RMgN0")))
button_element.click()
# 영업시간을 조회한다.
body_element3 = wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, "#app-root > div > div > div > div:nth-child(5) > div > div:nth-child(2) > div.place_section_content > div > div.O8qbU.pSavy > div > a")))
data["time"] = body_element3.text
print("가게 영업 시간:", body_element3.text)
except Exception as e:
no_times+=1
print("가게 영업 시간 크롤링 실패:", e)
가게 메뉴, 가게 사진, 영업 시간을 크롤링한다.
가게 사진은 리스트와 같은 형태로 받을 수 있었던 것에 반해 메뉴와 영업시간은 리스트로 받을 수 없었는데, 가게 마다 영업시간을 제공하는 형태가 달랐고(매일, 월화수목금토일 모두 다르게 표시, 주중 주말와 같이 표시) 메뉴의 경우 사진이 있는 경우 사진이 없는 경우가 아에 html selector가 달랐다.
또한 크롤링 수행이 실패되는 상황이 적지 않았기 때문에(데이터가 없는경우, 검색에 실패한경우, 웹 드라이버가 문제인 경우) try except을 모두 걸어 줘야 했다.
def process_json_files(input_dir, output_dir):
global no_places, no_photos, no_menus, no_times # 전역 변수 사용 선언
if not os.path.exists(output_dir):
os.makedirs(output_dir)
for file_name in os.listdir(input_dir):
if file_name.endswith('.json'):
file_path = os.path.join(input_dir, file_name)
with open(file_path, 'r', encoding='utf-8') as file:
restaurants = json.load(file)
file_results = []
for restaurant in restaurants:
info = crawl_restaurant_info(restaurant)
file_results.append(info)
# 파일별 결과 저장
output_file = os.path.join(output_dir, f'{os.path.splitext(file_name)[0]}_crawled_data.json')
with open(output_file, 'w', encoding='utf-8') as file:
json.dump(file_results, file, ensure_ascii=False, indent=4)
공공데이터를 여러 json으로 분리한 상황이 였기 때문에 모든 json에 대해서 크롤링 작업을 수행하여 새로운 json을 만들도록 하였다.
결과
많은 시간을 요구하는 태스크이기 때문에 자기전에 실행후 자러 갔다. 크롤링에 대해서 IP를 차단하는 경우가 있다고 하는데 IP 차단되지 않길 기도하면서 잤다. 시간은 8시간정도 소요되었고, 2000개 정도의 크롤링 작업을 수행하였다.
{
"name": "뒤안길",
"address": "서울특별시 광진구 자양동 51-32 1층 101호",
"type": "기타",
"images": [
"https://search.pstatic.net/common/?autoRotate=true&type=w560_sharpen&src=https%3A%2F%2Fldb-phinf.pstatic.net%2F20240504_76%2F1714757976028L9EgI_JPEG%2FIMG_5201.jpeg",
"https://search.pstatic.net/common/?autoRotate=true&type=w278_sharpen&src=https%3A%2F%2Fldb-phinf.pstatic.net%2F20240504_130%2F1714757974462g7A7q_JPEG%2FIMG_7935.jpeg",
"https://search.pstatic.net/common/?autoRotate=true&type=w278_sharpen&src=https%3A%2F%2Fldb-phinf.pstatic.net%2F20240504_23%2F1714757969401aE6Nt_JPEG%2FIMG_5220.jpeg",
"https://search.pstatic.net/common/?autoRotate=true&type=w278_sharpen&src=https%3A%2F%2Fldb-phinf.pstatic.net%2F20240504_245%2F17147579673896ihRo_JPEG%2FIMG_5222.jpeg",
"https://search.pstatic.net/common/?autoRotate=true&type=w278_sharpen&src=https%3A%2F%2Fldb-phinf.pstatic.net%2F20240504_48%2F1714757975851WdUn7_JPEG%2FIMG_5245.jpeg"
],
"menu": "사 히비\n사진\n대표\n6,900원\n고급음료\n사진\n대표\n6,900원",
"time": "영업 종료\n00:00에 영업 시작\n0시 0분에 영업 시작\n펼쳐보기"
}
'사이드 프로젝트' 카테고리의 다른 글
2024 KUIT 프로젝트 회고 (5) | 2024.08.30 |
---|---|
JPA 일대일 연관관계에서 지연로딩이 적용되지 않는 이유 (0) | 2024.01.22 |
Riot API 파헤치기 (0) | 2023.08.03 |