네이버 정책 변경으로 인한 이미지 크롤링 코드 수정
parent
18ba089110
commit
593b6d9922
|
|
@ -304,11 +304,13 @@ async def _crawling_logic(
|
|||
extra_photo_urls = scraper.extra_photo_urls or []
|
||||
|
||||
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
|
||||
logger.info(
|
||||
f"[crawling] Step 3 SKIP - 업체 사진 {len(owner_images)}장 "
|
||||
f"≥ {NvMapScraper.SUPPLEMENT_THRESHOLD}장 → 방문자 사진 필터링 생략"
|
||||
f"≥ {NvMapScraper.SUPPLEMENT_THRESHOLD}장 → 방문자 사진 필터링 생략, 전량 사용"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -48,6 +48,30 @@ class NvMapPwScraper():
|
|||
]
|
||||
_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
|
||||
page = None
|
||||
|
||||
|
|
@ -89,6 +113,17 @@ class NvMapPwScraper():
|
|||
if old_context:
|
||||
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"
|
||||
|
||||
@classmethod
|
||||
|
|
@ -116,7 +151,7 @@ class NvMapPwScraper():
|
|||
logger.warning("[NvMapPwScraper] fetch_graphql: scraper가 초기화되지 않았습니다")
|
||||
return None
|
||||
|
||||
page = await cls._context.new_page()
|
||||
page = await cls._new_stealth_page()
|
||||
captured: dict = {}
|
||||
|
||||
def on_request(req):
|
||||
|
|
@ -205,35 +240,7 @@ class NvMapPwScraper():
|
|||
await self.page.close()
|
||||
|
||||
async def create_page(self):
|
||||
self.page = await self._context.new_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']
|
||||
})
|
||||
self.page = await self._new_stealth_page()
|
||||
await self.page.goto("http://google.com")
|
||||
|
||||
async def goto_url(self, url, wait_until="domcontentloaded", timeout=20000):
|
||||
|
|
@ -415,23 +422,24 @@ patchedGetter.toString();''')
|
|||
return best['place_url']
|
||||
|
||||
# 2순위(안전망): allSearch 캡처 실패 시 기존 iframe HTML 파싱 방식으로 폴백
|
||||
logger.warning("[FALLBACK] allSearch 캡처 실패 → iframe 파싱 방식 시도")
|
||||
iframe_candidates = []
|
||||
for _ in range(3): # 500ms × 3 = 최대 1.5초
|
||||
if "/place/" in self.page.url:
|
||||
return self.page.url
|
||||
iframe_candidates = await self._extract_candidates_from_list_page()
|
||||
if iframe_candidates:
|
||||
break
|
||||
await self.page.wait_for_timeout(500)
|
||||
|
||||
if iframe_candidates:
|
||||
best = self._select_best_candidate(iframe_candidates, title, address)
|
||||
logger.info(
|
||||
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']
|
||||
# ── 잠시 비활성화 ──
|
||||
# logger.warning("[FALLBACK] allSearch 캡처 실패 → iframe 파싱 방식 시도")
|
||||
# iframe_candidates = []
|
||||
# for _ in range(3): # 500ms × 3 = 최대 1.5초
|
||||
# if "/place/" in self.page.url:
|
||||
# return self.page.url
|
||||
# iframe_candidates = await self._extract_candidates_from_list_page()
|
||||
# if iframe_candidates:
|
||||
# break
|
||||
# await self.page.wait_for_timeout(500)
|
||||
#
|
||||
# if iframe_candidates:
|
||||
# best = self._select_best_candidate(iframe_candidates, title, address)
|
||||
# logger.info(
|
||||
# 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']
|
||||
|
||||
# isCorrectAnswer=true 로 강제 단일결과 재시도 (원본 로직 유지)
|
||||
correct_url = self.page.url.replace("?", "?isCorrectAnswer=true&")
|
||||
|
|
|
|||
|
|
@ -36,7 +36,11 @@ class NvMapScraper:
|
|||
REQUEST_TIMEOUT = 120 # 초
|
||||
data_source_identifier = "nv"
|
||||
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 = """
|
||||
query getAccommodation($id: String!, $deviceType: String) {
|
||||
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"])
|
||||
]
|
||||
|
||||
@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
|
||||
def _dedup_by_original(images: list[dict]) -> list[dict]:
|
||||
"""{"preview","original"} dict 리스트를 original URL 기준으로 순서를 유지한 채 중복 제거한다."""
|
||||
|
|
@ -215,7 +255,7 @@ query getVisitorReviewStats($id: String!) {
|
|||
# self.scrap_type = "GraphQL-Browser"
|
||||
|
||||
# ── 실제 브라우저로 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)
|
||||
fac_data = await self._get_facility_string(place_id)
|
||||
self.scrap_type = "GraphQL-Browser"
|
||||
|
|
@ -226,8 +266,11 @@ query getVisitorReviewStats($id: String!) {
|
|||
self.rawdata["facilities"] = fac_data
|
||||
business = data["data"]["business"]
|
||||
|
||||
# 업체 등록 이미지 (마케팅 적합성 필터 제외 대상 — 자체 dedup)
|
||||
self.owner_images = self._dedup_by_original(self._extract_origins(business.get("images") or {}))
|
||||
# 업체 등록 이미지 (마케팅 적합성 필터 제외 대상 — 자체 dedup).
|
||||
# placeDetail.images가 대표 사진 일부만 반환하는 경우가 있어 biz 커서 결과를 병합한다.
|
||||
self.owner_images = self._dedup_by_original(
|
||||
self._extract_origins(business.get("images") or {}) + biz_photo_urls
|
||||
)
|
||||
|
||||
# 방문자/AI View 보충 사진 (마케팅 적합성 필터 대상). owner에 이미 있는 원본은 제외.
|
||||
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로 재조립할 것.)
|
||||
# MAX_IMAGES 상한은 방문자 사진이 섞이는 보충 경로에만 적용하며(필터링·정책상 제한),
|
||||
# 업체 제공 사진만으로 구성되는 경우는 전량 사용한다.
|
||||
if len(self.owner_images) < self.SUPPLEMENT_THRESHOLD:
|
||||
combined = self._dedup_by_original(self.owner_images + self.extra_photo_urls)
|
||||
logger.info(
|
||||
f"[NvMapScraper] 업체 사진 {len(self.owner_images)}장 < {self.SUPPLEMENT_THRESHOLD}장 "
|
||||
f"→ 보충 사진 {len(self.extra_photo_urls)}장 추가 (합산 {len(combined)}장, 상한 {self.MAX_IMAGES}장)"
|
||||
)
|
||||
else:
|
||||
combined = self.owner_images
|
||||
|
||||
self.image_link_list = combined[: self.MAX_IMAGES]
|
||||
else:
|
||||
self.image_link_list = list(self.owner_images)
|
||||
self.base_info = data["data"]["business"]["base"]
|
||||
self.facility_info = fac_data
|
||||
self.voted_keyword_stats = stats_data
|
||||
|
|
@ -255,11 +299,11 @@ query getVisitorReviewStats($id: String!) {
|
|||
|
||||
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을 호출한다.
|
||||
|
||||
Returns:
|
||||
(overview_data, review_stats_details, extra_photo_urls)
|
||||
(overview_data, review_stats_details, extra_photo_urls, biz_photo_urls)
|
||||
|
||||
Raises:
|
||||
GraphQLException: 브라우저 폴백마저 실패한 경우
|
||||
|
|
@ -322,8 +366,19 @@ query getVisitorReviewStats($id: String!) {
|
|||
},
|
||||
"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
|
||||
results = 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)
|
||||
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)
|
||||
|
||||
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
|
||||
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}")
|
||||
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:
|
||||
"""GraphQL API를 호출하여 숙소 정보를 가져옵니다.
|
||||
|
|
|
|||
Loading…
Reference in New Issue