이미지 수집 100장 상한 추가

feature-thumbnail
김성경 2026-07-13 15:13:24 +09:00
parent 593b6d9922
commit 236c7916a7
1 changed files with 25 additions and 33 deletions

View File

@ -37,10 +37,11 @@ class NvMapScraper:
data_source_identifier = "nv" data_source_identifier = "nv"
SUPPLEMENT_THRESHOLD = 30 # 업체 사진이 이 수 미만일 때 방문자 사진으로 보충 SUPPLEMENT_THRESHOLD = 30 # 업체 사진이 이 수 미만일 때 방문자 사진으로 보충
# 방문자 사진 보충 시의 합산 상한 (필터링·정책상 제한). # 방문자 사진 보충 시의 합산 상한 (필터링·정책상 제한).
# 업체 제공 사진은 필터링 면제 대상이므로 이 상한과 무관하게 전량 사용한다. # 업체 제공 사진은 필터링 면제 대상이므로 이 상한과 무관하게 수집분을 전부 사용한다
# (수집 자체는 BIZ_MAX_PAGES가 상한 — 초과 시 warning 로그 발생).
MAX_IMAGES = 50 MAX_IMAGES = 50
BIZ_PAGE_SIZE = 20 # getPhotoViewerItems 'biz' 커서의 페이지당 사진 수 BIZ_PAGE_SIZE = 20 # getPhotoViewerItems 'biz' 커서의 페이지당 사진 수
BIZ_MAX_PAGES = 5 # 업체 사진 페이지네이션 안전 상한 (5×20=100장) BIZ_MAX_PAGES = 5 # 업체 사진 수집 상한 (5×20=100장, 도달 시 warning)
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}) {
@ -282,7 +283,7 @@ query getVisitorReviewStats($id: String!) {
# (필터링 없는 기본 조립. 마케팅 적합성 필터를 적용하려면 호출측에서 # (필터링 없는 기본 조립. 마케팅 적합성 필터를 적용하려면 호출측에서
# owner_images / extra_photo_urls를 직접 사용해 image_filter.assemble_images로 재조립할 것.) # owner_images / extra_photo_urls를 직접 사용해 image_filter.assemble_images로 재조립할 것.)
# MAX_IMAGES 상한은 방문자 사진이 섞이는 보충 경로에만 적용하며(필터링·정책상 제한), # MAX_IMAGES 상한은 방문자 사진이 섞이는 보충 경로에만 적용하며(필터링·정책상 제한),
# 업체 제공 사진만으로 구성되는 경우는 전량 사용한다. # 업체 제공 사진만으로 구성되는 경우는 수집분(최대 BIZ_MAX_PAGES 페이지)을 전부 사용한다.
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(
@ -366,16 +367,16 @@ query getVisitorReviewStats($id: String!) {
}, },
"query": self.PHOTO_VIEWER_QUERY, "query": self.PHOTO_VIEWER_QUERY,
} }
# 업체 등록 사진 전체. placeDetail.images는 대표 사진 일부(1~수 장)만 반환하는 # 업체 등록 사진. placeDetail.images는 대표 사진 일부(1~수 장)만 반환하는
# 경우가 있어, 사진 탭이 실제 사용하는 'biz' 커서로 전량을 별도 조회해 보강한다. # 경우가 있어, 사진 탭이 실제 사용하는 'biz' 커서로 별도 조회해 보강한다.
# biz 커서는 페이지당 BIZ_PAGE_SIZE(20)장이며 lastCursor가 단순 인덱스 문자열 # biz 커서는 페이지당 BIZ_PAGE_SIZE(20)장이며 lastCursor가 단순 인덱스 문자열
# ("20", "40")이라 이전 응답 없이도 다음 페이지를 미리 요청할 수 있고, 범위를 # ("20", "40")이라 이전 응답 없이도 모든 페이지를 미리 만들 수 있고, 범위를
# 벗어난 페이지는 빈 목록을 반환하므로 블라인드 요청해도 안전하다. # 벗어난 페이지는 빈 목록을 반환하므로 블라인드 요청해도 안전하다.
# 첫 배치에 3페이지를 실어 보내고, 마지막 페이지가 가득 차 있으면(=더 있을 수 # 따라서 상한(BIZ_MAX_PAGES)까지의 전 페이지를 첫 배치에 한꺼번에 실어 보낸다
# 있음) 추가 배치를 반복 요청해 업체 사진을 전량 수집한다. # — 추가 왕복(페이지 재탐색 + WTM 토큰 재캡처)이 없고, 개별 페이지 실패가
BIZ_INITIAL_PAGES = 3 # 이후 페이지 수집을 막지 못한다.
biz_payloads = [ biz_payloads = [
self._build_biz_payload(place_id, page) for page in range(BIZ_INITIAL_PAGES) self._build_biz_payload(place_id, page) for page in range(self.BIZ_MAX_PAGES)
] ]
payloads = [overview_payload, stats_payload, interior_payload, exterior_payload, review_payload, *biz_payloads] payloads = [overview_payload, stats_payload, interior_payload, exterior_payload, review_payload, *biz_payloads]
@ -416,30 +417,21 @@ query getVisitorReviewStats($id: String!) {
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_pages = results[5:]
biz_urls = [url for page_result in biz_pages for url in self._extract_photo_viewer_urls(page_result)] biz_urls = [url for page_result in biz_pages for url in self._extract_photo_viewer_urls(page_result)]
# 마지막 페이지가 가득 차 있으면 다음 배치를 계속 요청해 업체 사진을 전량 수집한다 # 개별 페이지 실패(None)는 해당 20장만 누락되고 나머지 페이지는 영향 없다 (best-effort).
# (업체 사진은 필터링 면제 대상이라 많을수록 영상 소재 다양성이 좋아짐). failed_biz_pages = sum(1 for r in biz_pages if r is None)
# 후속 배치 실패는 warning만 남기고 이미 수집한 분량으로 진행한다 (best-effort). if failed_biz_pages:
next_page = BIZ_INITIAL_PAGES logger.warning(
while ( f"[NvMapScraper] 업체 사진 {failed_biz_pages}개 페이지 응답 실패 "
biz_pages f"— 페이지당 최대 {self.BIZ_PAGE_SIZE}장 누락 가능 (수집분으로 진행)"
and self._raw_photo_count(biz_pages[-1]) >= self.BIZ_PAGE_SIZE )
and next_page < self.BIZ_MAX_PAGES # 마지막 페이지까지 가득 차 있으면 상한 밖에 사진이 더 있을 수 있다.
): if biz_pages and self._raw_photo_count(biz_pages[-1]) >= self.BIZ_PAGE_SIZE:
chunk_pages = range(next_page, min(next_page + 3, self.BIZ_MAX_PAGES)) logger.warning(
chunk_payloads = [self._build_biz_payload(place_id, p) for p in chunk_pages] f"[NvMapScraper] 업체 사진이 수집 상한(BIZ_MAX_PAGES={self.BIZ_MAX_PAGES}, "
try: f"{self.BIZ_MAX_PAGES * self.BIZ_PAGE_SIZE}장)까지 가득 참 — 초과분은 수집되지 않음"
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(