diff --git a/app/home/api/routers/v1/home.py b/app/home/api/routers/v1/home.py index 6ecd95c..a2ddbc7 100644 --- a/app/home/api/routers/v1/home.py +++ b/app/home/api/routers/v1/home.py @@ -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: diff --git a/app/utils/nvMapPwScraper.py b/app/utils/nvMapPwScraper.py index cbce28e..0fc13f3 100644 --- a/app/utils/nvMapPwScraper.py +++ b/app/utils/nvMapPwScraper.py @@ -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&") diff --git a/app/utils/nvMapScraper.py b/app/utils/nvMapScraper.py index 7bc12fc..5538139 100644 --- a/app/utils/nvMapScraper.py +++ b/app/utils/nvMapScraper.py @@ -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}장)" ) + self.image_link_list = combined[: self.MAX_IMAGES] else: - combined = self.owner_images - - self.image_link_list = combined[: self.MAX_IMAGES] + 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를 호출하여 숙소 정보를 가져옵니다.