네이버 정책 변경으로 인한 이미지 크롤링 코드 수정

feature-thumbnail
김성경 2026-07-13 14:47:13 +09:00
parent 18ba089110
commit 593b6d9922
3 changed files with 153 additions and 61 deletions

View File

@ -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:

View File

@ -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&")

View File

@ -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를 호출하여 숙소 정보를 가져옵니다.