지난주 저는 TripAdvisor에서 유럽 3개 도시의 호텔 약 200곳에 대한 평점과 리뷰 수를 가져오려고 했습니다. 첫 번째 스크립트는 기본 requests.get()에 기본 헤더만 넣은, 정말 단순한 방식이었는데요. 결과는 매 요청마다 보기 좋게 403 Forbidden이 돌아오는 것이 전부였습니다. 쓸 만한 데이터는 단 한 바이트도 건지지 못했죠.
TripAdvisor는 여행 업계에서 가장 풍성한 공개 데이터 소스 중 하나입니다. , 800만 개가 넘는 비즈니스 등록 정보, 그리고 월간 고유 방문자 약 4억 6천만 명을 보유하고 있습니다. 연간 여행 지출에도 영향을 줍니다. 그런데 이 데이터를 프로그램으로 가져오려 하면 이야기가 완전히 달라집니다. TripAdvisor는 DataDome 봇 탐지, Cloudflare WAF, TLS 지문 분석, JavaScript 챌린지를 함께 써서 다층 방어를 구축해 두었기 때문에, 대부분의 단순한 스크래핑 시도는 시작도 하기 전에 막혀버립니다. 이 가이드는 제가 예전에 꼭 하나 있었으면 했던 자료입니다. 3가지 Python 스크래핑 방식과 코드 없는 대안까지 정면으로 비교하고, 각 방식의 전체 코드, 구조화된 안티봇 문제 해결 섹션, 그리고 호텔·레스토랑·관광지 전반에 재사용할 수 있는 패턴까지 담았습니다. Python 초보자든 숙련 개발자든, 이 글은 403 에러를 반복해서 보는 시간을 크게 줄여줄 겁니다.
코드 작성이 부담스럽다면? TripAdvisor를 더 쉽게 스크래핑하는 방법
먼저 분명히 말씀드리겠습니다. “Python으로 TripAdvisor 스크래핑”을 검색하는 분들 중 상당수는 사실 코드를 직접 짜는 것에 꼭 집착하는 건 아닙니다. 그저 호텔 이름, 평점, 리뷰 수, 가격 같은 데이터를 빨리 스프레드시트로 옮기고 싶을 뿐이죠. 그렇다면 훨씬 짧은 길이 있습니다.
은 저희가 만든 AI 기반 Chrome 확장 프로그램으로, TripAdvisor의 어떤 페이지든 읽어 들인 뒤 추출할 컬럼을 자동으로 제안해 줍니다. 작업 흐름은 정말 딱 두 단계입니다.
- TripAdvisor 목록 페이지를 엽니다 (예: “파리 호텔” 검색 결과).
- Thunderbit 사이드바에서 “AI Suggest Fields”를 클릭합니다. AI가 페이지를 스캔해 Hotel Name, Rating, Review Count, Price, Location 같은 컬럼을 제안합니다.
- “Scrape”를 클릭합니다. Thunderbit이 페이지 안의 모든 목록에서 데이터를 추출하고, 필요하면 페이지네이션도 자동으로 처리합니다.
- Excel, Google Sheets, Airtable, Notion으로 내보냅니다. 모든 요금제에서 내보내기는 무료입니다.
Thunderbit은 별도 설정 없이 호텔, 레스토랑, 관광지 전반에서 작동합니다. AI가 페이지 구조에 맞춰 알아서 적응하기 때문입니다. 여러 페이지로 이어진 결과라면 “Next” 버튼이나 무한 스크롤도 자동으로 감지합니다. 그리고 실제 Chrome 브라우저 안에서 실행되기 때문에 세션 쿠키와 브라우저 지문을 그대로 활용할 수 있어, 봇 탐지에 대해 자연스러운 이점도 있습니다.
에서 바로 써볼 수 있습니다. 무료 요금제는 월 6페이지까지 제공돼서 워크플로를 시험해 보기 충분합니다.
프로그래밍 제어가 필요하거나, 맞춤 파싱 로직이 필요하거나, 1만 페이지 이상을 스크래핑할 계획이라면 Python이 정답입니다. 계속 읽어보세요.
왜 TripAdvisor를 Python으로 스크래핑해야 할까요?
TripAdvisor 데이터는 비즈니스에 직접적이고 측정 가능한 영향을 줍니다. 에 따르면 호텔의 100점 만점 Global Review Index가 1점 오를 때 평균 일일 요금은 0.89% 상승하고, 객실당 매출(Revenue Per Available Room)은 1.42% 증가합니다. 또 다른 는 TripAdvisor 평점이 외부 요인으로 1점 상승하면 평균 호텔의 연간 매출이 5만 5천~7만 5천 달러 늘어난다고 밝혔습니다. 리뷰는 단순한 보여주기용 지표가 아니라 실제 수익을 만드는 요소입니다.
다음은 팀별로 TripAdvisor 데이터를 어떻게 활용하는지 정리한 표입니다.
| 활용 사례 | 도움이 되는 대상 | 필요한 데이터 |
|---|---|---|
| 호텔 경쟁사 분석 | 호텔 체인, 수익 관리자 | 평점, 가격, 리뷰 수, 편의시설 |
| 레스토랑 시장 조사 | 레스토랑 그룹, 식음료 브랜드 | 요리 유형, 가격대, 리뷰 감성 |
| 관광지 트렌드 추적 | 여행사, 관광청 | 인기 순위, 계절별 패턴 |
| 감성 분석 | 연구자, 데이터 분석가 | 전체 리뷰 텍스트, 별점, 날짜 |
| 리드 발굴 | 영업팀, 여행사 | 비즈니스명, 연락처, 위치 |
그렇다면 왜 하필 Python일까요? 이유는 세 가지입니다. 첫째, 생태계가 강력합니다. BeautifulSoup, Selenium, Playwright, Scrapy, httpx, pandas까지, Python만큼 성숙한 스크래핑과 데이터 분석 라이브러리를 갖춘 언어는 거의 없습니다. 둘째, 가 Python을 사용합니다. 즉, 커뮤니티 지원이 더 많고, StackOverflow 답변도 풍부하며, 최신 가이드도 훨씬 쉽게 찾을 수 있습니다. 셋째, 파이프라인 측면에서 유리합니다. BeautifulSoup으로 스크래핑하고, pandas로 정제하고, Hugging Face Transformers로 감성 분석을 하고, 대시보드까지 같은 언어로 처리할 수 있습니다. 문맥 전환이 필요 없죠.
Python으로 TripAdvisor를 스크래핑하는 3가지 방법 비교
대부분의 경쟁 가이드는 한 가지 방식만 골라 밀어붙입니다. 하지만 코드를 쓰기 전에 어떤 방법을 선택할지 판단해야 하는 입장에서는 별 도움이 되지 않죠. 제가 예전에 누군가에게서 받고 싶었던 비교표는 다음과 같습니다.
| 방식 | 속도 | JS 지원 | 안티봇 내성 | 난이도 | 적합한 경우 |
|---|---|---|---|---|---|
requests + BeautifulSoup | ⚡ 빠름(원시 기준 약 120~200페이지/분) | ❌ 없음 | ⚠️ 낮음 | 쉬움 | 정적 목록 페이지, 소규모 프로젝트 |
| Selenium / 헤드리스 브라우저 | 🐢 느림(약 8~20페이지/분) | ✅ 완전 지원 | ⚠️ 중간 | 보통 | 동적 콘텐츠, “Read more” 클릭, 쿠키 배너 |
| 숨겨진 JSON / GraphQL API | ⚡⚡ 가장 빠름(원시 기준 약 200~600페이지/분) | N/A | ✅ 높음 | 어려움 | 대규모 리뷰/호텔 추출 |
| 노코드(Thunderbit) | ⚡ 빠름 | ✅ 내장 | ✅ 내장 | 가장 쉬움 | 비개발자, 빠른 일회성 내보내기 |
몇 가지 중요한 참고사항이 있습니다. 위 속도는 이론값입니다. TripAdvisor의 속도 제한은 대략 IP당 분당 10~15요청 수준이라서, 실제 처리량은 방식과 상관없이 IP당 분당 약 10페이지 정도로 제한됩니다. 숨겨진 JSON 방식은 요청 1회당 가장 많은 데이터를 가져올 수 있어 총 요청 수를 줄이고, 그만큼 속도 제한에 걸릴 기회도 적습니다. Selenium은 실제 벤치마크에서 요청 기반 방식보다 약 5배 느리지만, 버튼 클릭이나 JavaScript 렌더링이 필요할 때는 사실상 유일한 선택지입니다.
이제부터는 3가지 Python 방법을 각각 완전한 코드와 함께 살펴보겠습니다. 상황에 맞는 방법을 골라 쓰셔도 되고, 조합해서 써도 됩니다. 저는 보통 목록 페이지는 requests+BS4로, 상세 페이지는 숨겨진 JSON으로 처리합니다.
Python 환경 설정하기
시작하기 전에 환경부터 준비해 봅시다. Python 3.10 이상이 필요합니다. 저는 3.12나 3.13을 권장합니다. 주요 패키지들이 모두 문제 없이 지원합니다.
한 번에 설치하려면:
1pip install requests beautifulsoup4 selenium httpx parsel pandas curl-cffi
패키지 안내:
requests(2.33.1) — HTTP 요청, Python 3.10+ 필요beautifulsoup4(4.14.3) — HTML 파싱selenium(4.43.0) — 브라우저 자동화, Python 3.10+ 필요httpx(0.28.1) — 비동기 HTTP 클라이언트parsel(1.11.0) — CSS/XPath 선택자, BS4보다 가벼움pandas(3.0.2) — 데이터 내보내기, Python 3.11+ 필요curl_cffi(0.15.0) — TLS 지문 위장(Cloudflare 우회에 중요)
ChromeDriver: Selenium을 쓴다면 좋은 소식이 있습니다. Selenium 4.6부터는 Selenium Manager가 적절한 ChromeDriver 바이너리를 자동으로 내려받고 캐시합니다. 수동 설치가 필요 없습니다. Chrome 버전과의 매칭도 동적으로 처리되므로 버전 불일치 걱정을 덜 수 있습니다.
가상 환경(권장):
1python -m venv tripadvisor-scraper
2source tripadvisor-scraper/bin/activate # macOS/Linux
3tripadvisor-scraper\Scripts\activate # Windows
방법 1: requests와 BeautifulSoup으로 TripAdvisor 스크래핑하기
가장 단순한 접근법입니다. 필요한 데이터가 정적 HTML 안에 들어 있는 목록 페이지(호텔 검색 결과, 레스토랑 목록 등)를 스크래핑할 때 특히 잘 맞습니다. 브라우저도, JavaScript 렌더링도 필요 없고, 리소스 사용량도 적습니다.
TripAdvisor URL 패턴 이해하기
TripAdvisor URL은 카테고리별로 예측 가능한 패턴을 따릅니다.
- 호텔:
https://www.tripadvisor.com/Hotels-g{locationId}-{Location_Name}-Hotels.html - 레스토랑:
https://www.tripadvisor.com/Restaurants-g{locationId}-{Location_Name}.html - 관광지:
https://www.tripadvisor.com/Attractions-g{locationId}-Activities-{Location_Name}.html
페이지네이션에는 oa(offset anchors) 파라미터가 URL에 들어갑니다. 각 페이지는 30개 결과를 보여줍니다.
- 1페이지: 기본 URL (
oa없음) - 2페이지:
Hotels-g187768-oa30-Italy-Hotels.html - 3페이지:
Hotels-g187768-oa60-Italy-Hotels.html
리뷰 페이지에서는 오프셋 파라미터가 or이며 10씩 증가합니다.
- 1페이지:
Reviews-or0-Hotel_Name.html - 2페이지:
Reviews-or10-Hotel_Name.html
모든 언어의 리뷰를 가져오려면 URL 뒤에 ?filterLang=ALL을 붙이면 됩니다.
현실적인 헤더로 요청 보내기
TripAdvisor는 헤더를 매우 엄격하게 검사합니다. 기본 Python 헤더로 요청하면 즉시 차단됩니다. 실제 Chrome 브라우저처럼 보이게 만들어야 합니다.
1import requests
2import time
3import random
4session = requests.Session()
5headers = {
6 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36",
7 "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
8 "Accept-Language": "en-US,en;q=0.9",
9 "Accept-Encoding": "gzip, deflate, br",
10 "Referer": "https://www.tripadvisor.com/",
11 "Sec-Fetch-Dest": "document",
12 "Sec-Fetch-Mode": "navigate",
13 "Sec-Fetch-Site": "none",
14 "Sec-CH-UA": '"Google Chrome";v="135", "Not-A.Brand";v="8", "Chromium";v="135"',
15 "Sec-CH-UA-Mobile": "?0",
16 "Sec-CH-UA-Platform": '"Windows"',
17}
18session.headers.update(headers)
19url = "https://www.tripadvisor.com/Hotels-g187147-Paris_Ile_de_France-Hotels.html"
20response = session.get(url)
21print(f"Status: {response.status_code}")
22print(f"Content length: {len(response.text)} characters")
핵심 포인트: TripAdvisor는 User-Agent와 Sec-CH-UA Client Hints 헤더가 서로 일치하는지 확인합니다. User-Agent에는 Chrome 135라고 적어놓고 Sec-CH-UA에는 Chrome 120이라고 되어 있으면 바로 의심받습니다. 헤더는 개별적으로가 아니라 전체 세트 단위로 함께 바꾸세요.
BeautifulSoup으로 목록 파싱하기
응답을 성공적으로 받았다면 BeautifulSoup으로 데이터를 추출합니다. TripAdvisor는 CSS 클래스명보다 안정적인 data-automation과 data-test-attribute 속성을 사용합니다. 클래스명은 자주 바뀌기 때문입니다.
1from bs4 import BeautifulSoup
2soup = BeautifulSoup(response.text, "html.parser")
3# 호텔 목록 카드 모두 찾기
4cards = soup.select('div[data-test-attribute="location-results-card"]')
5hotels = []
6for card in cards:
7 # 호텔 이름
8 title_el = card.select_one('div[data-automation="hotel-card-title"]')
9 name = title_el.get_text(strip=True) if title_el else None
10 # 상세 페이지 링크
11 link_el = card.select_one('div[data-automation="hotel-card-title"] a')
12 link = "https://www.tripadvisor.com" + link_el["href"] if link_el else None
13 # 평점
14 rating_el = card.select_one('[data-automation="bubbleRatingValue"]')
15 rating = rating_el.get_text(strip=True) if rating_el else None
16 # 리뷰 수
17 review_el = card.select_one('[data-automation="bubbleReviewCount"]')
18 review_count = review_el.get_text(strip=True).replace(",", "").split()[0] if review_el else None
19 hotels.append({
20 "name": name,
21 "rating": rating,
22 "review_count": review_count,
23 "url": link,
24 })
25print(f"이 페이지에서 {len(hotels)}개의 호텔을 찾았습니다")
26for h in hotels[:3]:
27 print(h)
선택자에 대한 참고: TripAdvisor는 FGwzt, yyzcQ 같은 난독화된 CSS 클래스명을 사용하며, 이는 사이트 업데이트마다 바뀝니다. 반면 data-automation과 data-test-target 속성은 훨씬 안정적입니다. 항상 클래스명보다 데이터 속성을 우선하세요.
페이지네이션 처리하기
여러 페이지를 스크래핑하려면 요청 사이에 적절한 지연을 두면서 offset 파라미터를 반복하면 됩니다.
1import pandas as pd
2all_hotels = []
3base_url = "https://www.tripadvisor.com/Hotels-g187147-oa{offset}-Paris_Ile_de_France-Hotels.html"
4for page in range(5): # 처음 5페이지
5 offset = page * 30
6 url = base_url.format(offset=offset) if page > 0 else "https://www.tripadvisor.com/Hotels-g187147-Paris_Ile_de_France-Hotels.html"
7 response = session.get(url)
8 if response.status_code != 200:
9 print(f"Page {page + 1}: 상태 코드 {response.status_code}를 받아 중단합니다.")
10 break
11 soup = BeautifulSoup(response.text, "html.parser")
12 cards = soup.select('div[data-test-attribute="location-results-card"]')
13 for card in cards:
14 title_el = card.select_one('div[data-automation="hotel-card-title"]')
15 name = title_el.get_text(strip=True) if title_el else None
16 rating_el = card.select_one('[data-automation="bubbleRatingValue"]')
17 rating = rating_el.get_text(strip=True) if rating_el else None
18 review_el = card.select_one('[data-automation="bubbleReviewCount"]')
19 review_count = review_el.get_text(strip=True).replace(",", "").split()[0] if review_el else None
20 all_hotels.append({"name": name, "rating": rating, "review_count": review_count})
21 print(f"Page {page + 1}: {len(cards)}개의 호텔을 찾음")
22 time.sleep(random.uniform(3, 7)) # 속도 제한을 피하기 위한 랜덤 지연
23df = pd.DataFrame(all_hotels)
24print(f"\n총 스크래핑한 호텔 수: {len(df)}")
time.sleep(random.uniform(3, 7))는 중요합니다. TripAdvisor의 속도 제한 임계값은 대략 IP당 분당 10~15요청 수준입니다. 이보다 빠르게 요청하면 CAPTCHA나 429 에러가 발생할 수 있습니다.
이 방식의 한계
이 접근법은 언제 무너질까요? 다음과 같은 경우입니다.
- TripAdvisor가 JavaScript 렌더링 콘텐츠를 제공할 때(일부 검색 결과 페이지는 JS가 필요함)
- 리뷰가 “Read more” 버튼 뒤에 잘려 있을 때
- 안티봇 장치가 JavaScript 챌린지나 CAPTCHA로 단계적으로 강화될 때
- 가격, 재고 여부처럼 클라이언트 측 렌더링 후에만 나타나는 데이터가 필요할 때
이런 경우에는 Selenium(방법 2) 또는 숨겨진 JSON 방식(방법 3)이 필요합니다.
방법 2: Selenium으로 TripAdvisor 스크래핑하기(헤드리스 브라우저)
Selenium은 실제 브라우저를 실행하므로 JavaScript를 렌더링하고, 버튼을 클릭하고, 쿠키 동의 배너를 처리하고, 동적 콘텐츠와 상호작용할 수 있습니다. 대신 속도는 약 , 브라우저 인스턴스당 RAM 300~500MB를 사용합니다.
안티탐지 설정과 함께 Selenium 구성하기
기본 설정의 Selenium은 아주 쉽게 탐지됩니다. TripAdvisor의 지문 분석은 이를 바로 잡아냅니다. 자동화 흔적을 꺼야 합니다.
1from selenium import webdriver
2from selenium.webdriver.chrome.options import Options
3from selenium.webdriver.common.by import By
4from selenium.webdriver.support.ui import WebDriverWait
5from selenium.webdriver.support import expected_conditions as EC
6options = Options()
7options.add_argument("--headless=new") # 새로운 헤드리스 모드 사용(Chrome 112+)
8options.add_argument("--disable-blink-features=AutomationControlled")
9options.add_argument("--window-size=1920,1080")
10options.add_argument("user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36")
11options.add_experimental_option("excludeSwitches", ["enable-automation"])
12options.add_experimental_option("useAutomationExtension", False)
13driver = webdriver.Chrome(options=options)
14# navigator에서 webdriver 속성 제거
15driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {
16 "source": "Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
17})
TripAdvisor에 이 정도면 충분할까요? 소규모 스크래핑(50페이지 이하)이라면, 이 설정에 리지덴셜 프록시를 더하면 대체로 동작합니다. 더 큰 규모라면 undetected-chromedriver나 nodriver가 필요할 수 있습니다. TripAdvisor의 DataDome 보호는 TLS 지문까지 포함해 요청당 1,000개가 넘는 신호를 분석하며, 일반 Selenium은 이를 완전히 속이기 어렵습니다.
Selenium으로 호텔 검색 결과 스크래핑하기
1import time
2import random
3url = "https://www.tripadvisor.com/Hotels-g187147-Paris_Ile_de_France-Hotels.html"
4driver.get(url)
5# 호텔 카드가 로드될 때까지 대기
6wait = WebDriverWait(driver, 15)
7wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, 'div[data-test-attribute="location-results-card"]')))
8# 쿠키 동의 팝업 처리(나타나는 경우)
9try:
10 cookie_btn = driver.find_element(By.ID, "onetrust-accept-btn-handler")
11 cookie_btn.click()
12 time.sleep(1)
13except:
14 pass # 쿠키 팝업 없음
15# 호텔 데이터 추출
16cards = driver.find_elements(By.CSS_SELECTOR, 'div[data-test-attribute="location-results-card"]')
17hotels = []
18for card in cards:
19 try:
20 name = card.find_element(By.CSS_SELECTOR, 'div[data-automation="hotel-card-title"]').text
21 except:
22 name = None
23 try:
24 rating = card.find_element(By.CSS_SELECTOR, '[data-automation="bubbleRatingValue"]').text
25 except:
26 rating = None
27 try:
28 reviews = card.find_element(By.CSS_SELECTOR, '[data-automation="bubbleReviewCount"]').text
29 except:
30 reviews = None
31 hotels.append({"name": name, "rating": rating, "review_count": reviews})
32print(f"{len(hotels)}개의 호텔을 스크래핑했습니다")
33for h in hotels[:3]:
34 print(h)
이 코드는 제 환경에서 한 페이지를 가져오는 데 약 8초가 걸렸습니다. requests+BS4로는 1초도 채 걸리지 않았죠. 수백 페이지를 스크래핑하면 이 8배 차이는 금방 더 크게 벌어집니다.
“Read more” 펼치기와 전체 리뷰 수집하기
리뷰 페이지는 긴 리뷰를 “Read more” 버튼 뒤에 잘라 놓습니다. Selenium은 이 버튼을 클릭할 수 있습니다.
1review_url = "https://www.tripadvisor.com/Hotel_Review-g187147-d188726-Reviews-Le_Marais_Hotel-Paris_Ile_de_France.html"
2driver.get(review_url)
3wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, 'div[data-reviewid]')))
4time.sleep(2)
5# 모든 “Read more” 버튼 클릭
6read_more_buttons = driver.find_elements(By.XPATH, '//button//*[contains(text(), "Read more")]/..')
7for btn in read_more_buttons:
8 try:
9 driver.execute_script("arguments[0].click();", btn)
10 time.sleep(0.3)
11 except:
12 pass
13# 리뷰 추출
14review_elements = driver.find_elements(By.CSS_SELECTOR, 'div[data-reviewid]')
15reviews = []
16for rev in review_elements:
17 try:
18 title = rev.find_element(By.CSS_SELECTOR, 'div[data-test-target="review-title"]').text
19 except:
20 title = None
21 try:
22 body = rev.find_element(By.CSS_SELECTOR, 'q.IRsGHoPm span').text
23 except:
24 try:
25 body = rev.find_element(By.CSS_SELECTOR, 'p.partial_entry').text
26 except:
27 body = None
28 try:
29 rating_class = rev.find_element(By.CSS_SELECTOR, 'div[data-test-target="review-rating"] span').get_attribute("class")
30 # 클래스에 "ui_bubble_rating bubble_50"처럼 평점이 인코딩됨 = 5.0
31 rating_num = [c for c in rating_class.split() if "bubble_" in c][0].replace("bubble_", "")
32 rating = int(rating_num) / 10
33 except:
34 rating = None
35 reviews.append({"title": title, "body": body, "rating": rating})
36print(f"{len(reviews)}개의 리뷰를 스크래핑했습니다")
Selenium에 프록시 회전 추가하기
지속적으로 스크래핑하려면 프록시 회전이 필요합니다. selenium-wire는 2024년 1월부터 deprecated 상태이므로, Chrome 내장 프록시 지원을 사용하세요.
1# 인증이 필요 없는 프록시 사용 시
2proxy = "http://your-proxy-address:port"
3options.add_argument(f"--proxy-server={proxy}")
4# 인증이 필요한 프록시는 Chrome 확장 프로그램 또는 Selenium 4의 BiDi 프로토콜 사용
프로그래밍적으로 프록시를 회전하려면 요청 배치마다 다른 프록시로 새 driver 인스턴스를 만들면 됩니다. 세련되진 않지만 안정적입니다.
방법 3: 숨겨진 JSON 방식(HTML 파싱을 아예 건너뛰기)
많은 가이드가 이 방법을 아예 건너뜁니다. 그런데 사실 이 방식이 세 가지 중 가장 빠르고 깔끔합니다. TripAdvisor는 HTML 페이지 안에 구조화된 데이터를 JSON 형태로 직접 포함합니다. <script> 태그 안의 pageManifest, urqlCache 같은 JavaScript 변수 속에 들어 있죠. 이 JSON을 뽑아내면 더 깨끗한 데이터(숫자로 된 평점, ISO 형식 날짜)를 얻을 수 있고, 요청 수도 줄며, JavaScript 렌더링도 필요 없습니다.
페이지 소스에서 포함된 JSON 찾기
핵심은 아주 단순합니다. requests.get()으로 페이지를 받아온 뒤, JavaScript를 렌더링하지 않고 원시 HTML에서 JSON을 추출하면 됩니다.
1import requests
2import re
3import json
4headers = {
5 "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36",
6 "Accept-Language": "en-US,en;q=0.9",
7 "Accept": "text/html,application/xhtml+xml,application/xml;q=0.8,*/*;q=0.8",
8 "Referer": "https://www.tripadvisor.com/",
9 "Sec-CH-UA": '"Google Chrome";v="135", "Not-A.Brand";v="8", "Chromium";v="135"',
10 "Sec-CH-UA-Mobile": "?0",
11 "Sec-CH-UA-Platform": '"macOS"',
12}
13url = "https://www.tripadvisor.com/Hotel_Review-g188590-d194317-Reviews-NH_City_Centre_Amsterdam.html"
14response = requests.get(url, headers=headers)
15# pageManifest JSON 블록 추출
16match = re.search(r"pageManifest:({.+?})};", response.text)
17if match:
18 page_data = json.loads(match.group(1))
19 print("pageManifest 데이터를 찾았습니다")
20 print(f"키 목록: {list(page_data.keys())[:10]}")
변수명을 직접 찾는 방법: Chrome에서 TripAdvisor 호텔 페이지를 연 뒤, 우클릭 → 페이지 소스 보기 → pageManifest, urqlCache, aggregateRating를 Ctrl+F로 검색해 보세요. 데이터가 그 안에 숨어 있습니다.
JSON 파싱과 구조화 데이터 추출하기
TripAdvisor는 application/ld+json 형식의 schema.org 데이터도 삽입하는데, 이건 훨씬 쉽게 추출할 수 있습니다.
1from parsel import Selector
2sel = Selector(text=response.text)
3# JSON-LD 구조화 데이터 추출
4json_ld_scripts = sel.xpath("//script[@type='application/ld+json']/text()").getall()
5for script in json_ld_scripts:
6 data = json.loads(script)
7 if isinstance(data, dict) and data.get("@type") in ["Hotel", "Restaurant", "TouristAttraction"]:
8 print(f"Name: {data.get('name')}")
9 print(f"Rating: {data.get('aggregateRating', {}).get('ratingValue')}")
10 print(f"Review Count: {data.get('aggregateRating', {}).get('reviewCount')}")
11 print(f"Price Range: {data.get('priceRange')}")
12 print(f"Address: {data.get('address', {}).get('streetAddress')}")
13 print(f"Coordinates: {data.get('geo', {}).get('latitude')}, {data.get('geo', {}).get('longitude')}")
14 break
JSON-LD 데이터는 정적 HTML에 포함되어 있고 JavaScript 렌더링이 필요 없습니다. 이름, 종합 평점, 리뷰 수, 주소, 좌표, 가격대, 사진 URL까지 한 번에 얻을 수 있어서 HTML 태그를 하나도 파싱하지 않아도 됩니다.
더 풍부한 데이터(개별 리뷰, 평점 분포, 편의시설 목록 등)가 필요하다면 urqlCache 객체를 사용해야 합니다.
1# 상세 리뷰 데이터용 urqlCache 추출
2cache_match = re.search(r'"urqlCache"\s*:\s*({.+?})\s*,\s*"redux"', response.text)
3if cache_match:
4 cache_data = json.loads(cache_match.group(1))
5 # 캐시를 탐색해 리뷰 데이터 찾기
6 for key, value in cache_data.items():
7 if "reviews" in str(value).lower()[:100]:
8 reviews_data = json.loads(value.get("data", "{}")) if isinstance(value, dict) else None
9 if reviews_data:
10 print(f"리뷰 캐시 항목을 찾았습니다: {key[:50]}...")
11 break
TripAdvisor가 프론트엔드를 업데이트하면 정확한 JSON 경로는 가끔 바뀌지만, 일반 구조인 JSON-LD는 요약 데이터용, urqlCache는 상세 데이터용이라는 점은 오래 유지되어 왔습니다.
TripAdvisor GraphQL API 역공학하기(고급)
대규모 추출이 목적이라면 TripAdvisor의 GraphQL 엔드포인트가 구조화된 데이터를 직접 반환합니다. 가장 빠른 방법이지만, 유지보수 난도도 가장 높습니다.
1import httpx
2import random
3import string
4def generate_request_id():
5 """X-Requested-By 헤더 값을 생성"""
6 random_chars = ''.join(random.choices(string.ascii_letters + string.digits, k=180))
7 return f"TNI1625!{random_chars}"
8# 파리의 호텔 검색
9search_payload = [{
10 "variables": {
11 "request": {
12 "query": "hotels in Paris",
13 "limit": 10,
14 "scope": "WORLDWIDE",
15 "locale": "en-US",
16 "scopeGeoId": 1,
17 "searchCenter": None,
18 "types": ["LOCATION", "QUERY_SUGGESTION", "RESCUE_RESULT"],
19 "locationTypes": ["GEO", "AIRPORT", "ACCOMMODATION", "ATTRACTION", "EATERY", "NEIGHBORHOOD"]
20 }
21 },
22 "extensions": {
23 "preRegisteredQueryId": "84b17ed122fbdbd4"
24 }
25}]
26graphql_headers = {
27 "Content-Type": "application/json",
28 "Accept": "*/*",
29 "Accept-Language": "en-US,en;q=0.9",
30 "Origin": "https://www.tripadvisor.com",
31 "Referer": "https://www.tripadvisor.com/Hotels",
32 "X-Requested-By": generate_request_id(),
33 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36",
34}
35with httpx.Client() as client:
36 response = client.post(
37 "https://www.tripadvisor.com/data/graphql/ids",
38 json=search_payload,
39 headers=graphql_headers
40 )
41 if response.status_code == 200:
42 results = response.json()
43 print(json.dumps(results, indent=2)[:1000])
44 else:
45 print(f"GraphQL 요청 실패: {response.status_code}")
GraphQL로 리뷰를 가져오려면:
1review_payload = [{
2 "variables": {
3 "locationId": 194317, # NH City Centre Amsterdam
4 "offset": 0,
5 "limit": 20,
6 "filters": {},
7 "sortType": None,
8 "sortBy": "date",
9 "language": "en",
10 "doMachineTranslation": False,
11 "photosPerReviewLimit": 3
12 },
13 "extensions": {
14 "preRegisteredQueryId": "ef1a9f94012220d3"
15 }
16}]
17with httpx.Client() as client:
18 response = client.post(
19 "https://www.tripadvisor.com/data/graphql/ids",
20 json=review_payload,
21 headers=graphql_headers
22 )
23 if response.status_code == 200:
24 data = response.json()
25 reviews = data[0]["data"]["locations"][0]["reviewListPage"]["reviews"]
26 total = data[0]["data"]["locations"][0]["reviewListPage"]["totalCount"]
27 print(f"총 리뷰 수: {total}")
28 for r in reviews[:3]:
29 print(f" [{r['rating']}/5] {r['title']} - {r['createdDate']}")
중요한 주의사항: preRegisteredQueryId 값(검색용 84b17ed122fbdbd4, 리뷰용 ef1a9f94012220d3 같은 값)은 TripAdvisor가 재배포할 때 깨질 수 있습니다. 그러면 요청은 조용히 실패합니다. 이럴 땐 브라우저 DevTools에서 네트워크 요청을 모니터링하면서 쿼리 ID를 다시 찾아야 합니다.
이 방식이 프록시 필요성을 줄이는 이유
계산은 단순합니다. requests+BS4로 호텔 상세 페이지 100개를 스크래핑하려면 요청도 100번 필요합니다. 반면 숨겨진 JSON 방식은 페이지 한 번 로드할 때 필요한 데이터가 한꺼번에 나오므로, 리뷰 펼치기나 동적 콘텐츠를 위한 추가 요청이 줄어듭니다. GraphQL은 한 번의 API 호출로 리뷰 20개를 가져올 수도 있습니다. 요청 수가 적어질수록 속도 제한에 덜 노출되고, 프록시 회전도 덜 필요합니다. 1,000페이지 미만의 소규모~중간 규모 프로젝트라면, 적절한 지연만 둬도 프록시 없이 가능할 수 있습니다.
하나의 재사용 가능한 스크립트로 호텔·레스토랑·관광지 모두 스크래핑하기
경쟁 가이드 5개 중 4개는 호텔만 다룹니다. 하지만 TripAdvisor의 핵심 콘텐츠는 호텔, 레스토랑, 관광지의 세 가지이며, URL 패턴과 데이터 필드도 서로 다릅니다. 세 카테고리를 모두 처리하는 함수를 만드는 방법은 다음과 같습니다.
카테고리별로 사용할 수 있는 데이터 필드
| 필드 | 호텔 | 레스토랑 | 관광지 |
|---|---|---|---|
| 이름 | ✅ | ✅ | ✅ |
| 평점 | ✅ | ✅ | ✅ |
| 리뷰 수 | ✅ | ✅ | ✅ |
| 가격/가격대 | ✅ | ✅ | 경우에 따라 다름 |
| 주소 | ✅ | ✅ | ✅ |
| 요리 유형 | ❌ | ✅ | ❌ |
| 소요 시간/투어 유형 | ❌ | ❌ | ✅ |
| 편의시설 | ✅ | ❌ | ❌ |
| 좌표 | ✅ | ✅ | ✅ |
재사용 가능한 scrape_tripadvisor() 함수 만들기
1import requests
2from bs4 import BeautifulSoup
3import pandas as pd
4import time
5import random
6import re
7import json
8def scrape_tripadvisor(category, location_id, location_name, num_pages=3):
9 """
10 호텔, 레스토랑, 관광지 전반의 TripAdvisor 목록을 스크래핑합니다.
11 Args:
12 category: "hotels", "restaurants", 또는 "attractions"
13 location_id: TripAdvisor geo ID(예: 파리는 "187147")
14 location_name: URL에 쓰는 이름(예: "Paris_Ile_de_France")
15 num_pages: 스크래핑할 페이지 수
16 """
17 url_patterns = {
18 "hotels": "https://www.tripadvisor.com/Hotels-g{geo}-oa{offset}-{name}-Hotels.html",
19 "restaurants": "https://www.tripadvisor.com/Restaurants-g{geo}-oa{offset}-{name}.html",
20 "attractions": "https://www.tripadvisor.com/Attractions-g{geo}-oa{offset}-Activities-{name}.html",
21 }
22 first_page_patterns = {
23 "hotels": "https://www.tripadvisor.com/Hotels-g{geo}-{name}-Hotels.html",
24 "restaurants": "https://www.tripadvisor.com/Restaurants-g{geo}-{name}.html",
25 "attractions": "https://www.tripadvisor.com/Attractions-g{geo}-Activities-{name}.html",
26 }
27 headers = {
28 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36",
29 "Accept-Language": "en-US,en;q=0.9",
30 "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
31 "Referer": "https://www.tripadvisor.com/",
32 "Sec-CH-UA": '"Google Chrome";v="135", "Not-A.Brand";v="8", "Chromium";v="135"',
33 "Sec-CH-UA-Mobile": "?0",
34 "Sec-CH-UA-Platform": '"Windows"',
35 }
36 session = requests.Session()
37 session.headers.update(headers)
38 all_items = []
39 for page in range(num_pages):
40 offset = page * 30
41 if page == 0:
42 url = first_page_patterns[category].format(geo=location_id, name=location_name)
43 else:
44 url = url_patterns[category].format(geo=location_id, offset=offset, name=location_name)
45 response = session.get(url)
46 if response.status_code != 200:
47 print(f" Page {page + 1}: 상태 코드 {response.status_code}, 중단합니다.")
48 break
49 soup = BeautifulSoup(response.text, "html.parser")
50 cards = soup.select('div[data-test-attribute="location-results-card"]')
51 for card in cards:
52 item = {"category": category}
53 title_el = card.select_one('div[data-automation="hotel-card-title"]') or card.select_one('a[data-automation]')
54 item["name"] = title_el.get_text(strip=True) if title_el else None
55 rating_el = card.select_one('[data-automation="bubbleRatingValue"]')
56 item["rating"] = rating_el.get_text(strip=True) if rating_el else None
57 review_el = card.select_one('[data-automation="bubbleReviewCount"]')
58 item["review_count"] = review_el.get_text(strip=True) if review_el else None
59 all_items.append(item)
60 print(f" Page {page + 1}: {len(cards)}개의 항목 발견")
61 time.sleep(random.uniform(3, 7))
62 return pd.DataFrame(all_items)
63# 사용 예시
64print("=== 파리 호텔 ===")
65hotels_df = scrape_tripadvisor("hotels", "187147", "Paris_Ile_de_France", num_pages=2)
66print(hotels_df.head())
67print("\n=== 로마 레스토랑 ===")
68restaurants_df = scrape_tripadvisor("restaurants", "187791", "Rome_Lazio", num_pages=2)
69print(restaurants_df.head())
70print("\n=== 바르셀로나 관광지 ===")
71attractions_df = scrape_tripadvisor("attractions", "187497", "Barcelona_Catalonia", num_pages=2)
72print(attractions_df.head())
함수 하나로 세 카테고리를 모두 처리할 수 있으니 중복 코드가 필요 없습니다. TripAdvisor가 선택자를 바꾸더라도 한 곳만 수정하면 됩니다.
TripAdvisor가 막아설 때 해야 할 일(안티봇 문제 해결)
제가 TripAdvisor를 처음 스크래핑할 때 가장 필요했던 부분이 바로 여기입니다. 그런데 경쟁 가이드 중 구조적으로 정리해주는 곳은 거의 없습니다. TripAdvisor는 DataDome( 분석)와 Cloudflare WAF를 함께 사용합니다. 가장 흔한 실패 모드에 대한 진단표는 다음과 같습니다.
| 증상 | 가능한 원인 | 해결 방법 |
|---|---|---|
| HTTP 403 응답 | 헤더 누락 또는 수상한 헤더, Cloudflare JS 챌린지 | 현실적인 User-Agent, Accept-Language, Referer, Sec-CH-UA 헤더 사용. 헤더 일관성 유지. |
| 데이터 대신 CAPTCHA 페이지 표시 | 속도 제한 또는 브라우저 지문 탐지 | 리지덴셜 프록시 회전, 랜덤 지연 추가(요청 사이 2~7초) |
| HTML이 비어 있거나 본문이 없는 페이지 | requests가 JavaScript를 렌더링하지 않음 | Selenium으로 전환하거나 페이지 소스에서 숨겨진 JSON 추출 |
| 리뷰가 일부만 보이거나 “Read more”가 펼쳐지지 않음 | 클릭 이벤트에서 로딩되는 콘텐츠 | Selenium .click() 사용 또는 임베디드 JSON 블록에서 추출 |
| 리뷰가 한 언어로만 표시됨 | 언어 파라미터 누락 | 리뷰 URL에 ?filterLang=ALL 추가 |
| N페이지 이후 데이터 로딩 중단 | 세션 기반 속도 제한 | 세션 회전, 배치 사이 쿠키 초기화 |
| HTTP 1020 Access Denied | Cloudflare가 IP/ASN 차단 | 데이터센터 프록시 대신 리지덴셜 프록시 사용 |
| 챌린지 루프(CAPTCHA 무한 반복) | 쿠키 유지 실패 | 먼저 홈페이지를 방문해 세션 예열, 쿠키 잔존 유지 |
지수 백오프가 있는 재시도 로직
경쟁 글에서는 실제로 이 코드를 보여주지 않는 경우가 많습니다. 재사용 가능한 재시도 함수는 다음과 같습니다.
1import time
2import random
3import requests
4def fetch_with_retry(session, url, max_retries=4, base_delay=2, max_delay=60):
5 """
6 지수 백오프와 jitter를 사용해 URL을 가져옵니다.
7 재시도할 때마다 User-Agent를 바꿉니다.
8 """
9 user_agents = [
10 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36",
11 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36",
12 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
13 "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36",
14 ]
15 for attempt in range(max_retries):
16 # 재시도 시 User-Agent 변경
17 if attempt > 0:
18 session.headers["User-Agent"] = random.choice(user_agents)
19 try:
20 response = session.get(url, timeout=30)
21 if response.status_code == 200:
22 return response
23 if response.status_code == 429:
24 # Retry-After 헤더가 있으면 우선 존중
25 retry_after = int(response.headers.get("Retry-After", base_delay * (2 ** attempt)))
26 print(f" 속도 제한(429) 발생. {retry_after}초 대기 중...")
27 time.sleep(retry_after)
28 continue
29 if response.status_code in (403, 503):
30 wait = min(base_delay * (2 ** attempt) + random.uniform(0, 1), max_delay)
31 print(f" {response.status_code} 응답. {attempt + 1}/{max_retries}번째 재시도, {wait:.1f}초 후 다시 시도...")
32 time.sleep(wait)
33 continue
34 # 다른 오류 코드는 재시도하지 않음
35 print(f" {url}에 대해 예상치 못한 상태 코드 {response.status_code}")
36 return response
37 except requests.exceptions.Timeout:
38 wait = min(base_delay * (2 ** attempt) + random.uniform(0, 1), max_delay)
39 print(f" 시간 초과 발생. {attempt + 1}/{max_retries}번째 재시도, {wait:.1f}초 후 다시 시도...")
40 time.sleep(wait)
41 print(f" {url}에 대한 재시도 {max_retries}회가 모두 실패했습니다")
42 return None
헤더, 프록시, 세션 회전하기
지속적인 스크래핑을 하려면 헤더 세트와 프록시 풀을 함께 관리해야 합니다.
1import random
2HEADER_SETS = [
3 {
4 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36",
5 "Sec-CH-UA": '"Google Chrome";v="135", "Not-A.Brand";v="8", "Chromium";v="135"',
6 "Sec-CH-UA-Platform": '"Windows"',
7 },
8 {
9 "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36",
10 "Sec-CH-UA": '"Google Chrome";v="135", "Not-A.Brand";v="8", "Chromium";v="135"',
11 "Sec-CH-UA-Platform": '"macOS"',
12 },
13 {
14 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
15 "Sec-CH-UA": '"Google Chrome";v="134", "Not-A.Brand";v="8", "Chromium";v="134"',
16 "Sec-CH-UA-Platform": '"Windows"',
17 },
18]
19PROXY_LIST = [
20 "http://user:pass@residential-proxy-1:port",
21 "http://user:pass@residential-proxy-2:port",
22 # 더 많은 리지덴셜 프록시 추가
23]
24def get_rotated_session():
25 """회전된 헤더와 프록시를 사용해 새 세션 생성"""
26 session = requests.Session()
27 # 랜덤 헤더 세트 선택
28 header_set = random.choice(HEADER_SETS)
29 base_headers = {
30 "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
31 "Accept-Language": "en-US,en;q=0.9",
32 "Accept-Encoding": "gzip, deflate, br",
33 "Referer": "https://www.tripadvisor.com/",
34 "Sec-Fetch-Dest": "document",
35 "Sec-Fetch-Mode": "navigate",
36 "Sec-CH-UA-Mobile": "?0",
37 }
38 base_headers.update(header_set)
39 session.headers.update(base_headers)
40 # 랜덤 프록시 선택
41 if PROXY_LIST:
42 proxy = random.choice(PROXY_LIST)
43 session.proxies = {"http": proxy, "https": proxy}
44 return session
프록시 유형이 중요합니다. 데이터센터 프록시는 TripAdvisor에 거의 즉시 차단됩니다(HTTP 1020 Access Denied). 지속적인 스크래핑에는 리지덴셜 프록시가 사실상 필수입니다. 일반 사용자 ISP를 통해 라우팅되기 때문에 실제 사용자와 거의 구분되지 않습니다. 제공업체에 따라 GB당 2.50~8.40달러 정도의 비용을 예상하세요.
스크래핑한 TripAdvisor 데이터를 내보내고 저장하기
데이터를 확보했다면, 활용 가능한 형식으로 저장하는 일은 어렵지 않습니다.
CSV 내보내기(가장 일반적)
1import pandas as pd
2df = pd.DataFrame(all_hotels)
3df.to_csv("tripadvisor_hotels_paris.csv", index=False, encoding="utf-8-sig")
4print(f"CSV로 {len(df)}행을 내보냈습니다")
encoding='utf-8-sig'는 중요합니다. Excel에서 CSV를 열 때 프랑스어 악센트, 중국어 같은 비라틴 문자가 올바르게 표시되도록 해줍니다.
JSON 내보내기(중첩 데이터용)
호텔 아래에 리뷰가 중첩되어 있다면 JSON이 구조를 그대로 보존해 줍니다.
1# 계층 구조 예시
2hotel_data = {
3 "property_id": "d194317",
4 "name": "NH City Centre Amsterdam",
5 "rating": 4.0,
6 "reviews": [
7 {"title": "위치가 훌륭함", "rating": 5, "date": "2025-03-15", "text": "..."},
8 {"title": "평범한 숙박", "rating": 3, "date": "2025-03-10", "text": "..."},
9 ]
10}
11# 평평한 분석용으로는 json_normalize 사용
12flat_reviews = pd.json_normalize(
13 hotel_data,
14 record_path="reviews",
15 meta=["property_id", "name"]
16)
17flat_reviews.to_csv("reviews_flat.csv", index=False)
관계형 데이터를 위한 2개 파일 방식
대규모 데이터셋이라면 저는 두 개의 CSV 파일을 사용합니다.
hotels.csv— 숙소당 1행(평면 구조)reviews.csv— 리뷰당 1행,property_id를 외래 키로 사용
이 방식은 pandas에서 조인하기 쉽고, 데이터베이스에 넣거나 BI 도구로 가져오기에도 편합니다.
이런 내보내기 로직을 직접 다루고 싶지 않다면, Thunderbit는 Excel, Google Sheets, Airtable, Notion으로 내보낼 수 있습니다. 모두 무료이고 코드도 필요 없습니다. 기술에 익숙하지 않은 팀원과 결과를 공유할 때 특히 유용합니다.
책임감 있고 효율적으로 TripAdvisor를 스크래핑하는 팁
책임 있는 스크래핑을 위한 6가지 요점입니다.
robots.txt를 확인하세요: TripAdvisor의 robots.txt는 GPTBot, ClaudeBot 같은 AI 학습 봇을 완전히 차단합니다. 일반 크롤러는 일부 경로 제한을 받습니다.tripadvisor.com/robots.txt를 확인하세요.- 지연을 추가하세요: 요청 사이 3~7초는 안전한 범위입니다. IP당 분당 10~15요청보다 빠르면 속도 제한에 걸릴 수 있습니다.
- 공개 데이터만 스크래핑하세요. 로그인해야 볼 수 있는 콘텐츠는 접근하지 마세요.
- 데이터를 안전하게 저장하고, 리뷰어 이름 등 개인 정보를 다룬다면 GDPR/CCPA를 준수하세요.
- 상업적 규모의 데이터가 필요하다면 TripAdvisor 공식 API를 고려하세요. 은 비즈니스 정보와 위치당 리뷰 최대 5개, 사진 5개까지 제공합니다. 제한적이지만 합법적이고 안정적입니다.
- 법적 맥락을 인지하세요: 은 EU 전역에서 이용약관 기반 스크래핑 금지를 더 강하게 만들었습니다. TripAdvisor의 서비스 약관은 스크래핑을 명시적으로 금지합니다. 책임 있게, 그리고 본인 위험 하에 스크래핑하세요.
마무리
이제 전체 그림을 보셨습니다.
- requests + BeautifulSoup은 가장 단순한 경로입니다. 정적 목록 페이지에 잘 맞고, 설정이 거의 필요 없으며, 속도도 빠릅니다. 100페이지 미만을 스크래핑하고 JavaScript 렌더링 데이터가 필요 없다면 여기서 시작하세요.
- Selenium은 requests가 못 하는 일을 처리합니다. 동적 콘텐츠, “Read more” 버튼, 쿠키 배너가 대표적입니다. 속도는 5배 느리고 자원도 많이 쓰지만, 페이지와 상호작용해야 할 때는 유일한 선택지입니다.
- 숨겨진 JSON / GraphQL은 가장 깔끔하고 빠른 방식입니다. HTML 파싱 없이 구조화 데이터를 얻고, 요청 수를 줄여 프록시 필요성도 낮추며, 분석에 바로 쓸 수 있는 형식으로 데이터를 반환합니다. 대신 처음 역공학이 필요하고, TripAdvisor가 데이터 구조를 바꿀 때 가끔 유지보수가 필요합니다.
재사용 가능한 scrape_tripadvisor() 함수는 호텔, 레스토랑, 관광지를 모두 커버합니다. 더 이상 두 번째 튜토리얼은 필요 없을 겁니다.
그리고 튜토리얼 도중에 “아, 코딩은 나랑 안 맞는다”는 생각이 들거나, 오늘 안에 스프레드시트로 호텔 50개만 뽑으면 되는 상황이라면, 이 AI 기반 필드 감지, 자동 페이지네이션, Excel/Google Sheets 무료 내보내기로 두 번 클릭만에 처리해 줍니다. Python은 필요 없습니다.
더 깊이 배우고 싶다면 와 에서 더 많은 스크래핑 가이드를 확인해 보세요.
자주 묻는 질문
1. TripAdvisor를 스크래핑하는 것은 합법인가요?
TripAdvisor의 서비스 약관은 스크래핑을 명시적으로 금지합니다. 다만 법원들은 일반적으로 로그인 뒤에 있지 않은 공개 데이터의 스크래핑이 미국의 Computer Fraud and Abuse Act를 위반하지 않는다고 보아 왔습니다. 하지만 2025년 EU 법원 Ryanair 판결은 유럽에서 약관 기반 제한을 더 강하게 만들었습니다. 공개 데이터만 스크래핑하고, robots.txt를 존중하며, 저작권이 있는 콘텐츠를 재게시하지 말고, 상업적으로 사용할 경우 법률 자문을 받으세요.
2. Python 없이도 TripAdvisor를 스크래핑할 수 있나요?
네. 같은 노코드 도구를 쓰면 브라우저에서 바로 AI 기반 필드 감지와 자동 페이지네이션으로 TripAdvisor를 스크래핑할 수 있습니다. 브라우저 확장 프로그램, Google Sheets 애드온, 상용 스크래핑 API도 사용할 수 있습니다. Python이 가장 많은 제어권과 유연성을 제공하지만, 유일한 방법은 아닙니다.
3. TripAdvisor 스크래핑 시 차단을 피하려면 어떻게 해야 하나요?
핵심은 현실적이고 일관된 헤더 사용(특히 User-Agent와 Sec-CH-UA), 리지덴셜 프록시 회전(데이터센터 IP는 즉시 차단), 요청 사이 3~7초 랜덤 지연, 총 요청 수를 줄이기 위한 숨겨진 JSON 방식 사용, 지수 백오프 재시도 로직 구현, 그리고 깊은 페이지에 들어가기 전에 홈페이지로 세션을 예열하는 것입니다.
4. TripAdvisor에서 어떤 데이터를 스크래핑할 수 있나요?
호텔, 레스토랑, 관광지의 이름, 평점, 리뷰 수, 가격대, 주소, 좌표, 편의시설(호텔), 요리 유형(레스토랑), 투어 시간(관광지), 그리고 개별 평점과 날짜가 포함된 전체 리뷰 텍스트를 추출할 수 있습니다. 숨겨진 JSON과 GraphQL 방식은 요청 1회당 가장 풍부한 데이터를 제공합니다.
5. 하루에 TripAdvisor 페이지를 몇 개까지 스크래핑할 수 있나요?
단일 IP와 적절한 지연을 사용하면 하루 약 600~1,000페이지 정도가 현실적입니다. 20개의 회전형 리지덴셜 프록시를 쓰면 요청 기반 방식으로 하루 약 20만~30만 페이지까지도 가능합니다. Selenium은 더 느려서 프록시당 하루 8,000~12,000페이지 정도를 예상하면 됩니다. 숨겨진 JSON/GraphQL 방식은 요청당 데이터가 가장 많기 때문에, 같은 정보를 얻기 위해 총 페이지 수가 훨씬 적어질 수 있습니다.
더 알아보기
