네이버 정책 변경으로 인한 이미지 크롤링 코드 수정
parent
18ba089110
commit
593b6d9922
|
|
@ -304,11 +304,13 @@ async def _crawling_logic(
|
||||||
extra_photo_urls = scraper.extra_photo_urls or []
|
extra_photo_urls = scraper.extra_photo_urls or []
|
||||||
|
|
||||||
if len(owner_images) >= NvMapScraper.SUPPLEMENT_THRESHOLD:
|
if len(owner_images) >= NvMapScraper.SUPPLEMENT_THRESHOLD:
|
||||||
scraper.image_link_list = owner_images[: NvMapScraper.MAX_IMAGES]
|
# 업체 제공 사진은 필터링 면제 대상이므로 상한 없이 전량 사용한다
|
||||||
|
# (MAX_IMAGES 상한은 방문자 사진이 섞이는 보충 경로에만 적용).
|
||||||
|
scraper.image_link_list = list(owner_images)
|
||||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[crawling] Step 3 SKIP - 업체 사진 {len(owner_images)}장 "
|
f"[crawling] Step 3 SKIP - 업체 사진 {len(owner_images)}장 "
|
||||||
f"≥ {NvMapScraper.SUPPLEMENT_THRESHOLD}장 → 방문자 사진 필터링 생략"
|
f"≥ {NvMapScraper.SUPPLEMENT_THRESHOLD}장 → 방문자 사진 필터링 생략, 전량 사용"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,30 @@ class NvMapPwScraper():
|
||||||
]
|
]
|
||||||
_current_profile = None
|
_current_profile = None
|
||||||
|
|
||||||
|
# 헤드리스 자동화 탐지 신호(navigator.webdriver, 빈 plugins/languages, window.chrome 부재,
|
||||||
|
# permissions.query 이상 동작)를 정상 브라우저처럼 위장하는 스텔스 패치.
|
||||||
|
# create_page()와 fetch_graphql() 양쪽에서 생성하는 모든 page에 반드시 적용해야 한다 —
|
||||||
|
# 한쪽이라도 빠지면 그 경로만 헤드리스로 노출되어 안티봇 캡차 트리거 확률이 올라간다.
|
||||||
|
_STEALTH_INIT_SCRIPT = '''
|
||||||
|
Object.defineProperty(Navigator.prototype, "webdriver", {
|
||||||
|
set: undefined,
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true,
|
||||||
|
get: () => false,
|
||||||
|
});
|
||||||
|
Object.defineProperty(navigator, "plugins", { get: () => [1, 2, 3, 4, 5] });
|
||||||
|
Object.defineProperty(navigator, "languages", { get: () => ["ko-KR", "ko"] });
|
||||||
|
window.chrome = window.chrome || { runtime: {} };
|
||||||
|
const originalQuery = window.navigator.permissions && window.navigator.permissions.query;
|
||||||
|
if (originalQuery) {
|
||||||
|
window.navigator.permissions.query = (parameters) => (
|
||||||
|
parameters.name === "notifications"
|
||||||
|
? Promise.resolve({ state: Notification.permission })
|
||||||
|
: originalQuery(parameters)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
|
||||||
# instance var
|
# instance var
|
||||||
page = None
|
page = None
|
||||||
|
|
||||||
|
|
@ -89,6 +113,17 @@ class NvMapPwScraper():
|
||||||
if old_context:
|
if old_context:
|
||||||
await old_context.close()
|
await old_context.close()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _new_stealth_page(cls):
|
||||||
|
"""스텔스 패치(webdriver 위장 등)와 sec-ch-ua 헤더가 적용된 새 page를 생성한다."""
|
||||||
|
page = await cls._context.new_page()
|
||||||
|
await page.add_init_script(cls._STEALTH_INIT_SCRIPT)
|
||||||
|
if cls._current_profile:
|
||||||
|
await page.set_extra_http_headers({
|
||||||
|
'sec-ch-ua': cls._current_profile['sec_ch_ua']
|
||||||
|
})
|
||||||
|
return page
|
||||||
|
|
||||||
GRAPHQL_URL = "https://pcmap-api.place.naver.com/graphql"
|
GRAPHQL_URL = "https://pcmap-api.place.naver.com/graphql"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -116,7 +151,7 @@ class NvMapPwScraper():
|
||||||
logger.warning("[NvMapPwScraper] fetch_graphql: scraper가 초기화되지 않았습니다")
|
logger.warning("[NvMapPwScraper] fetch_graphql: scraper가 초기화되지 않았습니다")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
page = await cls._context.new_page()
|
page = await cls._new_stealth_page()
|
||||||
captured: dict = {}
|
captured: dict = {}
|
||||||
|
|
||||||
def on_request(req):
|
def on_request(req):
|
||||||
|
|
@ -205,35 +240,7 @@ class NvMapPwScraper():
|
||||||
await self.page.close()
|
await self.page.close()
|
||||||
|
|
||||||
async def create_page(self):
|
async def create_page(self):
|
||||||
self.page = await self._context.new_page()
|
self.page = await self._new_stealth_page()
|
||||||
await self.page.add_init_script(
|
|
||||||
'''const defaultGetter = Object.getOwnPropertyDescriptor(
|
|
||||||
Navigator.prototype,
|
|
||||||
"webdriver"
|
|
||||||
).get;
|
|
||||||
defaultGetter.apply(navigator);
|
|
||||||
defaultGetter.toString();
|
|
||||||
Object.defineProperty(Navigator.prototype, "webdriver", {
|
|
||||||
set: undefined,
|
|
||||||
enumerable: true,
|
|
||||||
configurable: true,
|
|
||||||
get: new Proxy(defaultGetter, {
|
|
||||||
apply: (target, thisArg, args) => {
|
|
||||||
Reflect.apply(target, thisArg, args);
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const patchedGetter = Object.getOwnPropertyDescriptor(
|
|
||||||
Navigator.prototype,
|
|
||||||
"webdriver"
|
|
||||||
).get;
|
|
||||||
patchedGetter.apply(navigator);
|
|
||||||
patchedGetter.toString();''')
|
|
||||||
|
|
||||||
await self.page.set_extra_http_headers({
|
|
||||||
'sec-ch-ua': self._current_profile['sec_ch_ua']
|
|
||||||
})
|
|
||||||
await self.page.goto("http://google.com")
|
await self.page.goto("http://google.com")
|
||||||
|
|
||||||
async def goto_url(self, url, wait_until="domcontentloaded", timeout=20000):
|
async def goto_url(self, url, wait_until="domcontentloaded", timeout=20000):
|
||||||
|
|
@ -415,23 +422,24 @@ patchedGetter.toString();''')
|
||||||
return best['place_url']
|
return best['place_url']
|
||||||
|
|
||||||
# 2순위(안전망): allSearch 캡처 실패 시 기존 iframe HTML 파싱 방식으로 폴백
|
# 2순위(안전망): allSearch 캡처 실패 시 기존 iframe HTML 파싱 방식으로 폴백
|
||||||
logger.warning("[FALLBACK] allSearch 캡처 실패 → iframe 파싱 방식 시도")
|
# ── 잠시 비활성화 ──
|
||||||
iframe_candidates = []
|
# logger.warning("[FALLBACK] allSearch 캡처 실패 → iframe 파싱 방식 시도")
|
||||||
for _ in range(3): # 500ms × 3 = 최대 1.5초
|
# iframe_candidates = []
|
||||||
if "/place/" in self.page.url:
|
# for _ in range(3): # 500ms × 3 = 최대 1.5초
|
||||||
return self.page.url
|
# if "/place/" in self.page.url:
|
||||||
iframe_candidates = await self._extract_candidates_from_list_page()
|
# return self.page.url
|
||||||
if iframe_candidates:
|
# iframe_candidates = await self._extract_candidates_from_list_page()
|
||||||
break
|
# if iframe_candidates:
|
||||||
await self.page.wait_for_timeout(500)
|
# break
|
||||||
|
# await self.page.wait_for_timeout(500)
|
||||||
if iframe_candidates:
|
#
|
||||||
best = self._select_best_candidate(iframe_candidates, title, address)
|
# if iframe_candidates:
|
||||||
logger.info(
|
# best = self._select_best_candidate(iframe_candidates, title, address)
|
||||||
f"[AUTO-SELECT-IFRAME] '{title}' → '{best['title']}' "
|
# logger.info(
|
||||||
f"(name={best['_name_score']:.2f}, addr={best['_addr_score']:.2f}) {best['place_url']}"
|
# f"[AUTO-SELECT-IFRAME] '{title}' → '{best['title']}' "
|
||||||
)
|
# f"(name={best['_name_score']:.2f}, addr={best['_addr_score']:.2f}) {best['place_url']}"
|
||||||
return best['place_url']
|
# )
|
||||||
|
# return best['place_url']
|
||||||
|
|
||||||
# isCorrectAnswer=true 로 강제 단일결과 재시도 (원본 로직 유지)
|
# isCorrectAnswer=true 로 강제 단일결과 재시도 (원본 로직 유지)
|
||||||
correct_url = self.page.url.replace("?", "?isCorrectAnswer=true&")
|
correct_url = self.page.url.replace("?", "?isCorrectAnswer=true&")
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,11 @@ class NvMapScraper:
|
||||||
REQUEST_TIMEOUT = 120 # 초
|
REQUEST_TIMEOUT = 120 # 초
|
||||||
data_source_identifier = "nv"
|
data_source_identifier = "nv"
|
||||||
SUPPLEMENT_THRESHOLD = 30 # 업체 사진이 이 수 미만일 때 방문자 사진으로 보충
|
SUPPLEMENT_THRESHOLD = 30 # 업체 사진이 이 수 미만일 때 방문자 사진으로 보충
|
||||||
MAX_IMAGES = 50 # 업체+방문자 사진 합산 최대 장수
|
# 방문자 사진 보충 시의 합산 상한 (필터링·정책상 제한).
|
||||||
|
# 업체 제공 사진은 필터링 면제 대상이므로 이 상한과 무관하게 전량 사용한다.
|
||||||
|
MAX_IMAGES = 50
|
||||||
|
BIZ_PAGE_SIZE = 20 # getPhotoViewerItems 'biz' 커서의 페이지당 사진 수
|
||||||
|
BIZ_MAX_PAGES = 5 # 업체 사진 페이지네이션 안전 상한 (5×20=100장)
|
||||||
OVERVIEW_QUERY: str = """
|
OVERVIEW_QUERY: str = """
|
||||||
query getAccommodation($id: String!, $deviceType: String) {
|
query getAccommodation($id: String!, $deviceType: String) {
|
||||||
business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) {
|
business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) {
|
||||||
|
|
@ -151,6 +155,42 @@ query getVisitorReviewStats($id: String!) {
|
||||||
if p.get("originalUrl") and not NvMapScraper._is_gif_url(p["originalUrl"])
|
if p.get("originalUrl") and not NvMapScraper._is_gif_url(p["originalUrl"])
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _build_biz_payload(cls, place_id: str, page: int) -> dict:
|
||||||
|
"""'biz' 커서(업체 등록 사진)의 page번째(0-base) 페이지 요청 payload를 만든다."""
|
||||||
|
start_index = page * cls.BIZ_PAGE_SIZE
|
||||||
|
cursor: dict = {"id": "biz"}
|
||||||
|
if start_index > 0:
|
||||||
|
cursor.update({
|
||||||
|
"startIndex": start_index,
|
||||||
|
"hasNext": True,
|
||||||
|
"lastCursor": str(start_index),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"operationName": "getPhotoViewerItems",
|
||||||
|
"variables": {
|
||||||
|
"input": {
|
||||||
|
"businessId": place_id,
|
||||||
|
"cursors": [cursor],
|
||||||
|
"dateRange": "",
|
||||||
|
"excludeAuthorIds": [],
|
||||||
|
"excludeClipIds": [],
|
||||||
|
"excludeSection": [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"query": cls.PHOTO_VIEWER_QUERY,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _raw_photo_count(photo_viewer_raw: dict | None) -> int:
|
||||||
|
"""getPhotoViewerItems 응답의 사진 수(gif 필터링 전 원본 기준)를 반환한다.
|
||||||
|
|
||||||
|
페이지네이션 계속 여부 판단용 — gif가 걸러진 뒤의 수로 판단하면
|
||||||
|
마지막 페이지가 아닌데도 조기 종료할 수 있어 원본 개수를 사용한다.
|
||||||
|
"""
|
||||||
|
photos = ((photo_viewer_raw or {}).get("data") or {}).get("photoViewer") or {}
|
||||||
|
return len(photos.get("photos") or [])
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _dedup_by_original(images: list[dict]) -> list[dict]:
|
def _dedup_by_original(images: list[dict]) -> list[dict]:
|
||||||
"""{"preview","original"} dict 리스트를 original URL 기준으로 순서를 유지한 채 중복 제거한다."""
|
"""{"preview","original"} dict 리스트를 original URL 기준으로 순서를 유지한 채 중복 제거한다."""
|
||||||
|
|
@ -215,7 +255,7 @@ query getVisitorReviewStats($id: String!) {
|
||||||
# self.scrap_type = "GraphQL-Browser"
|
# self.scrap_type = "GraphQL-Browser"
|
||||||
|
|
||||||
# ── 실제 브라우저로 WTM 캡차 우회 ──
|
# ── 실제 브라우저로 WTM 캡차 우회 ──
|
||||||
data, stats_data, extra_photo_urls = await self._scrap_via_browser(place_id)
|
data, stats_data, extra_photo_urls, biz_photo_urls = await self._scrap_via_browser(place_id)
|
||||||
# 편의시설은 HTML 페이지 파싱이라 GraphQL(WTM) 차단과 별개로 직접 시도 (best-effort, 실패 시 None)
|
# 편의시설은 HTML 페이지 파싱이라 GraphQL(WTM) 차단과 별개로 직접 시도 (best-effort, 실패 시 None)
|
||||||
fac_data = await self._get_facility_string(place_id)
|
fac_data = await self._get_facility_string(place_id)
|
||||||
self.scrap_type = "GraphQL-Browser"
|
self.scrap_type = "GraphQL-Browser"
|
||||||
|
|
@ -226,8 +266,11 @@ query getVisitorReviewStats($id: String!) {
|
||||||
self.rawdata["facilities"] = fac_data
|
self.rawdata["facilities"] = fac_data
|
||||||
business = data["data"]["business"]
|
business = data["data"]["business"]
|
||||||
|
|
||||||
# 업체 등록 이미지 (마케팅 적합성 필터 제외 대상 — 자체 dedup)
|
# 업체 등록 이미지 (마케팅 적합성 필터 제외 대상 — 자체 dedup).
|
||||||
self.owner_images = self._dedup_by_original(self._extract_origins(business.get("images") or {}))
|
# placeDetail.images가 대표 사진 일부만 반환하는 경우가 있어 biz 커서 결과를 병합한다.
|
||||||
|
self.owner_images = self._dedup_by_original(
|
||||||
|
self._extract_origins(business.get("images") or {}) + biz_photo_urls
|
||||||
|
)
|
||||||
|
|
||||||
# 방문자/AI View 보충 사진 (마케팅 적합성 필터 대상). owner에 이미 있는 원본은 제외.
|
# 방문자/AI View 보충 사진 (마케팅 적합성 필터 대상). owner에 이미 있는 원본은 제외.
|
||||||
owner_originals = {img["original"] for img in self.owner_images}
|
owner_originals = {img["original"] for img in self.owner_images}
|
||||||
|
|
@ -238,16 +281,17 @@ query getVisitorReviewStats($id: String!) {
|
||||||
# 업체 사진이 임계값 미만이면 내부/외부/리뷰 사진으로 보충
|
# 업체 사진이 임계값 미만이면 내부/외부/리뷰 사진으로 보충
|
||||||
# (필터링 없는 기본 조립. 마케팅 적합성 필터를 적용하려면 호출측에서
|
# (필터링 없는 기본 조립. 마케팅 적합성 필터를 적용하려면 호출측에서
|
||||||
# owner_images / extra_photo_urls를 직접 사용해 image_filter.assemble_images로 재조립할 것.)
|
# owner_images / extra_photo_urls를 직접 사용해 image_filter.assemble_images로 재조립할 것.)
|
||||||
|
# MAX_IMAGES 상한은 방문자 사진이 섞이는 보충 경로에만 적용하며(필터링·정책상 제한),
|
||||||
|
# 업체 제공 사진만으로 구성되는 경우는 전량 사용한다.
|
||||||
if len(self.owner_images) < self.SUPPLEMENT_THRESHOLD:
|
if len(self.owner_images) < self.SUPPLEMENT_THRESHOLD:
|
||||||
combined = self._dedup_by_original(self.owner_images + self.extra_photo_urls)
|
combined = self._dedup_by_original(self.owner_images + self.extra_photo_urls)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[NvMapScraper] 업체 사진 {len(self.owner_images)}장 < {self.SUPPLEMENT_THRESHOLD}장 "
|
f"[NvMapScraper] 업체 사진 {len(self.owner_images)}장 < {self.SUPPLEMENT_THRESHOLD}장 "
|
||||||
f"→ 보충 사진 {len(self.extra_photo_urls)}장 추가 (합산 {len(combined)}장, 상한 {self.MAX_IMAGES}장)"
|
f"→ 보충 사진 {len(self.extra_photo_urls)}장 추가 (합산 {len(combined)}장, 상한 {self.MAX_IMAGES}장)"
|
||||||
)
|
)
|
||||||
|
self.image_link_list = combined[: self.MAX_IMAGES]
|
||||||
else:
|
else:
|
||||||
combined = self.owner_images
|
self.image_link_list = list(self.owner_images)
|
||||||
|
|
||||||
self.image_link_list = combined[: self.MAX_IMAGES]
|
|
||||||
self.base_info = data["data"]["business"]["base"]
|
self.base_info = data["data"]["business"]["base"]
|
||||||
self.facility_info = fac_data
|
self.facility_info = fac_data
|
||||||
self.voted_keyword_stats = stats_data
|
self.voted_keyword_stats = stats_data
|
||||||
|
|
@ -255,11 +299,11 @@ query getVisitorReviewStats($id: String!) {
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
async def _scrap_via_browser(self, place_id: str) -> tuple[dict, list[dict] | None, list[str]]:
|
async def _scrap_via_browser(self, place_id: str) -> tuple[dict, list[dict] | None, list[dict], list[dict]]:
|
||||||
"""직접 호출이 WTM 캡차에 막힌 경우, 실제 브라우저로 GraphQL을 호출한다.
|
"""직접 호출이 WTM 캡차에 막힌 경우, 실제 브라우저로 GraphQL을 호출한다.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(overview_data, review_stats_details, extra_photo_urls)
|
(overview_data, review_stats_details, extra_photo_urls, biz_photo_urls)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
GraphQLException: 브라우저 폴백마저 실패한 경우
|
GraphQLException: 브라우저 폴백마저 실패한 경우
|
||||||
|
|
@ -322,8 +366,19 @@ query getVisitorReviewStats($id: String!) {
|
||||||
},
|
},
|
||||||
"query": self.PHOTO_VIEWER_QUERY,
|
"query": self.PHOTO_VIEWER_QUERY,
|
||||||
}
|
}
|
||||||
|
# 업체 등록 사진 전체. placeDetail.images는 대표 사진 일부(1~수 장)만 반환하는
|
||||||
|
# 경우가 있어, 사진 탭이 실제 사용하는 'biz' 커서로 전량을 별도 조회해 보강한다.
|
||||||
|
# biz 커서는 페이지당 BIZ_PAGE_SIZE(20)장이며 lastCursor가 단순 인덱스 문자열
|
||||||
|
# ("20", "40")이라 이전 응답 없이도 다음 페이지를 미리 요청할 수 있고, 범위를
|
||||||
|
# 벗어난 페이지는 빈 목록을 반환하므로 블라인드 요청해도 안전하다.
|
||||||
|
# 첫 배치에 3페이지를 실어 보내고, 마지막 페이지가 가득 차 있으면(=더 있을 수
|
||||||
|
# 있음) 추가 배치를 반복 요청해 업체 사진을 전량 수집한다.
|
||||||
|
BIZ_INITIAL_PAGES = 3
|
||||||
|
biz_payloads = [
|
||||||
|
self._build_biz_payload(place_id, page) for page in range(BIZ_INITIAL_PAGES)
|
||||||
|
]
|
||||||
|
|
||||||
payloads = [overview_payload, stats_payload, interior_payload, exterior_payload, review_payload]
|
payloads = [overview_payload, stats_payload, interior_payload, exterior_payload, review_payload, *biz_payloads]
|
||||||
MAX_RETRY = 3
|
MAX_RETRY = 3
|
||||||
results = None
|
results = None
|
||||||
last_error: Exception | None = None
|
last_error: Exception | None = None
|
||||||
|
|
@ -360,13 +415,40 @@ query getVisitorReviewStats($id: String!) {
|
||||||
interior_urls = self._extract_photo_viewer_urls(results[2] if len(results) > 2 else None)
|
interior_urls = self._extract_photo_viewer_urls(results[2] if len(results) > 2 else None)
|
||||||
exterior_urls = self._extract_photo_viewer_urls(results[3] if len(results) > 3 else None)
|
exterior_urls = self._extract_photo_viewer_urls(results[3] if len(results) > 3 else None)
|
||||||
review_urls = self._extract_photo_viewer_urls(results[4] if len(results) > 4 else None)
|
review_urls = self._extract_photo_viewer_urls(results[4] if len(results) > 4 else None)
|
||||||
|
|
||||||
|
biz_pages = list(results[5:])
|
||||||
|
biz_urls = [url for page_result in biz_pages for url in self._extract_photo_viewer_urls(page_result)]
|
||||||
|
# 마지막 페이지가 가득 차 있으면 다음 배치를 계속 요청해 업체 사진을 전량 수집한다
|
||||||
|
# (업체 사진은 필터링 면제 대상이라 많을수록 영상 소재 다양성이 좋아짐).
|
||||||
|
# 후속 배치 실패는 warning만 남기고 이미 수집한 분량으로 진행한다 (best-effort).
|
||||||
|
next_page = BIZ_INITIAL_PAGES
|
||||||
|
while (
|
||||||
|
biz_pages
|
||||||
|
and self._raw_photo_count(biz_pages[-1]) >= self.BIZ_PAGE_SIZE
|
||||||
|
and next_page < self.BIZ_MAX_PAGES
|
||||||
|
):
|
||||||
|
chunk_pages = range(next_page, min(next_page + 3, self.BIZ_MAX_PAGES))
|
||||||
|
chunk_payloads = [self._build_biz_payload(place_id, p) for p in chunk_pages]
|
||||||
|
try:
|
||||||
|
chunk_results = await NvMapPwScraper.fetch_graphql(place_id, chunk_payloads)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[NvMapScraper] 업체 사진 추가 페이지 수집 실패(수집분으로 진행): {e}")
|
||||||
|
break
|
||||||
|
if not chunk_results:
|
||||||
|
logger.warning("[NvMapScraper] 업체 사진 추가 페이지 응답 없음(수집분으로 진행)")
|
||||||
|
break
|
||||||
|
biz_pages = [r for r in chunk_results if r is not None]
|
||||||
|
biz_urls += [url for page_result in biz_pages for url in self._extract_photo_viewer_urls(page_result)]
|
||||||
|
next_page += len(chunk_payloads)
|
||||||
|
|
||||||
extra_photo_urls = self._interleave(interior_urls, exterior_urls) + review_urls
|
extra_photo_urls = self._interleave(interior_urls, exterior_urls) + review_urls
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[NvMapScraper] 보충 이미지 - 내부:{len(interior_urls)} 외부:{len(exterior_urls)} 리뷰:{len(review_urls)}"
|
f"[NvMapScraper] 보충 이미지 - 내부:{len(interior_urls)} 외부:{len(exterior_urls)} "
|
||||||
|
f"리뷰:{len(review_urls)} / 업체(biz):{len(biz_urls)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"[NvMapScraper] 브라우저 폴백 SUCCESS - place_id: {place_id}")
|
logger.info(f"[NvMapScraper] 브라우저 폴백 SUCCESS - place_id: {place_id}")
|
||||||
return data, stats_data, extra_photo_urls
|
return data, stats_data, extra_photo_urls, biz_urls
|
||||||
|
|
||||||
async def _call_get_accommodation(self, place_id: str) -> dict:
|
async def _call_get_accommodation(self, place_id: str) -> dict:
|
||||||
"""GraphQL API를 호출하여 숙소 정보를 가져옵니다.
|
"""GraphQL API를 호출하여 숙소 정보를 가져옵니다.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue