o2o-castad-backend/generator/naver.py

984 lines
48 KiB
Python

# -*- coding: utf-8 -*-
"""
네이버 플레이스 크롤링 레이어 (AI브리핑 · 사진) — animation 3종(조선/삼국지/그리스로마) 공유본
====================================================================================
animation 계열이 공유하는 '링크 1개' 네이버 크롤러. ★ 이 파일 하나로 자립 동작.
· fetch_briefing 가게이름 + 업종타입(cat) + AI 브리핑(불릿)
· fetch_and_select 공식 사진 크롤 + Gemini 비전 선별(★인물 항상 제외)
공유 헬퍼(MOBILE_UA / STEALTH / extract_place)는 한 번만 정의.
※ 클립(fetch_clips)·지도(fetch_map)는 narration/kakao 폴더 삭제와 함께 제거됨(animation 미사용).
[정리한 것]
- 방문자 후기(fetch_reviews) 제거: AI요약(브리핑)만 나레이션 근거로 쓰므로 불필요.
- fetch_briefing 은 항상 3-tuple (store, bullets, cat) 반환. cat 안 쓰는 형식은 그냥 무시.
- 사진 선별은 항상 '인물 사진 제외'(초상권). 끄고 싶으면 fetch_and_select(..., exclude_person=False).
[봇 차단 회피] 자동화 흔적 줄인 스텔스 컨텍스트 + 모바일 UA 로 접근.
설치: pip install playwright google-genai pillow httpx ; playwright install chromium
"""
import asyncio
import json
import random
import re
import shutil
from pathlib import Path
from urllib.parse import unquote, urlparse
import httpx
from google.genai import types
from PIL import Image
from playwright.async_api import async_playwright
# ============================== 공유 헬퍼 (UA · 스텔스 · place id) ==============================
# ── 안티봇 핑거프린트 회피용 브라우저 프로필 후보군 ────────────────────────────────
# 네이버가 '같은 지문의 자동화 접근'을 반복 감지하면 캡차 벽을 내린다(_check_blocked 참고).
# 요청마다 프로필을 섞어 지문을 분산시킨다. ★ 핵심: 한 프로필 안의 UA·client-hint(sec-ch-ua)·
# platform·viewport 는 서로 '모순 없이' 맞물려야 한다 — 실제 브라우저면 절대 어긋나지 않는 값들이라
# 하나라도 어긋나면 그 자체가 봇 신호가 된다.
# · iOS Safari 는 sec-ch-ua 계열을 아예 보내지 않음 → 힌트 비움(client_hints=None).
# · Android Chrome 은 sec-ch-ua / -mobile / -platform 3종을 UA 버전과 일치시켜 보냄.
BROWSER_PROFILES = [
{
"name": "ios-safari-16",
"ua": ("Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1"),
"platform": "iPhone",
"viewport": {"width": 414, "height": 896},
"dsf": 3,
"client_hints": None, # Safari 는 client-hint 미전송
},
{
"name": "ios-safari-17",
"ua": ("Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"),
"platform": "iPhone",
"viewport": {"width": 390, "height": 844},
"dsf": 3,
"client_hints": None,
},
{
"name": "android-chrome-126",
"ua": ("Mozilla/5.0 (Linux; Android 14; SM-S918N) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Mobile Safari/537.36"),
"platform": "Linux armv81",
"viewport": {"width": 412, "height": 915},
"dsf": 2.625,
"client_hints": {
"sec-ch-ua": '"Not/A)Brand";v="8", "Chromium";v="126", "Google Chrome";v="126"',
"sec-ch-ua-mobile": "?1",
"sec-ch-ua-platform": '"Android"',
},
},
{
"name": "android-chrome-125-pixel",
"ua": ("Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/125.0.0.0 Mobile Safari/537.36"),
"platform": "Linux armv81",
"viewport": {"width": 412, "height": 883},
"dsf": 2.625,
"client_hints": {
"sec-ch-ua": '"Google Chrome";v="125", "Chromium";v="125", "Not.A/Brand";v="24"',
"sec-ch-ua-mobile": "?1",
"sec-ch-ua-platform": '"Android"',
},
},
]
def pick_profile(profile=None):
"""브라우저 프로필 선택. 문자열이면 name 매칭, dict 면 그대로, None 이면 랜덤."""
if isinstance(profile, dict):
return profile
if isinstance(profile, str):
for pr in BROWSER_PROFILES:
if pr["name"] == profile:
return pr
return random.choice(BROWSER_PROFILES)
def _stealth_script(platform: str) -> str:
"""프로필 platform 과 일관된 자동화 탐지 우회 init 스크립트.
Safari(iPhone) 프로필에선 Chromium 이 노출하는 navigator.userAgentData(실제 Safari 엔 없음)를
제거해, UA 는 Safari 인데 JS 지문은 Chromium 인 모순을 없앤다."""
script = (
"Object.defineProperty(navigator,'webdriver',{get:()=>undefined});"
"Object.defineProperty(navigator,'languages',{get:()=>['ko-KR','ko']});"
f"Object.defineProperty(navigator,'platform',{{get:()=>{json.dumps(platform)}}});"
"window.chrome={runtime:{}};"
)
if platform == "iPhone":
script += "try{Object.defineProperty(navigator,'userAgentData',{get:()=>undefined});}catch(e){}"
return script
# 하위호환: 기존에 MOBILE_UA/STEALTH 를 직접 참조하던 코드용(기본 프로필 값).
MOBILE_UA = BROWSER_PROFILES[0]["ua"]
STEALTH = _stealth_script(BROWSER_PROFILES[0]["platform"])
VISION_MODEL = "gemini-2.5-flash"
def extract_place(final_url: str):
"""리다이렉트된 최종 URL → (업종타입, place id). 예: ('restaurant', '1234567')."""
m = re.search(r"place\.naver\.com/([a-z]+)/(\d+)", final_url)
if m:
return m.group(1), m.group(2)
m2 = re.search(r"(\d{7,})", final_url)
return "restaurant", (m2.group(1) if m2 else None)
def looks_like_naver_place(s: str) -> bool:
return bool(s) and ("naver.me/" in s or "place.naver.com" in s or "map.naver.com" in s)
async def _goto_tolerant(page, url, timeout=30000):
"""naver.me 단축링크는 즉시 리다이렉트하며 최초 내비게이션을 Chromium 이 ERR_ABORTED 로 끊을
때가 있다(원문서가 커밋되기 전에 리다이렉트됨). goto 예외가 ABORTED/net::ERR 계열이면 리다이렉트는
실제로 착지하므로 예외를 삼키고 잠깐 기다렸다 진행한다(그 외 예외는 그대로 전파)."""
try:
await page.goto(url, wait_until="domcontentloaded", timeout=timeout)
except Exception as e:
if "ERR_ABORTED" not in str(e) and "net::ERR" not in str(e):
raise
await page.wait_for_timeout(1500)
async def _resolve_place_home(page, url):
"""링크 → 실제 업종 홈으로 진입하고 (cat, pid) 반환.
naver.me 는 UA(프로필)에 따라 앱브릿지(m.map.naver.com/appLink)로 새어 업종을 못 읽을 때가 있다.
→ place id 만 먼저 뽑은 뒤, 업종 비의존 제네릭 진입(m.place.naver.com/place/{id}/home)으로
재진입해 네이버가 실제 업종(accommodation/restaurant/…)으로 리다이렉트하게 한다. pid 없으면 예외.
※ naver.me goto 는 ERR_ABORTED 로 끊기거나 리다이렉트 실패로 about:blank 에 머물 수 있어 최대 3회 재시도."""
pid = None
for _ in range(3):
await _goto_tolerant(page, url)
await page.wait_for_timeout(2500)
_, pid = extract_place(page.url)
if pid:
break
await page.wait_for_timeout(1200) # 미착지(about:blank) → 잠깐 쉬고 goto 재시도
if not pid:
raise RuntimeError("place id 를 못 찾음. URL 확인 또는 headful=True 로 화면 확인.")
await _goto_tolerant(page, f"https://m.place.naver.com/place/{pid}/home")
# 제네릭 '/place/' → 실제 업종('/accommodation/' 등)으로 리다이렉트될 때까지 폴링(최대 ~6s)
cat = "place"
for _ in range(12):
await page.wait_for_timeout(500)
c, _pid = extract_place(page.url)
if c and c != "place":
cat = c
break
return cat, pid
class NaverBlockedError(RuntimeError):
"""네이버 봇 탐지(캡차/보안 확인)로 접근이 막힌 상태. 프로필 전환 재시도의 트리거."""
# 봇벽 차단 시 재시도 한도 = 최대 3회(최초 1회 + 재시도 3회 = 총 4회). 프로필 개수로도 상한.
MAX_BLOCK_RETRIES = 3
# 프로필 전환 재시도 사이 점진적 대기(초). 같은 IP 를 연달아 두드려 차단이 굳는 걸 늦춘다.
BLOCK_RETRY_DELAYS = [3, 6, 12]
def _shuffled_profiles():
"""중복 없이 섞은 프로필 순서 — 재시도마다 서로 다른 지문을 쓰도록 보장."""
order = BROWSER_PROFILES[:]
random.shuffle(order)
return order
async def _check_blocked(page):
"""네이버 봇 탐지(보안 확인/캡차) 페이지인지 확인. 걸리면 뒤 폴링/파싱은 항상 빈 결과만
내놓아 'AI 브리핑/리뷰가 없다'로 오인되므로, 원인이 드러나도록 즉시 예외로 구분한다."""
try:
text = await page.evaluate("document.body.innerText")
except Exception:
return
if "보안 확인을 완료해" in text:
raise NaverBlockedError(
"네이버가 자동화 접근으로 판단해 보안 인증(캡차)을 요구했어요. "
"브리핑/리뷰가 없는 게 아니라 접근이 막힌 상태 — 잠시 후 다시 시도하세요."
)
async def _with_profile_retry(attempt, label=""):
"""attempt(profile) 코루틴을 프로필을 바꿔가며 시도한다. NaverBlockedError(봇벽) 에만
다른 프로필로 재시도하고, 그 외 예외/정상 반환은 즉시 전파한다(잘못된 URL·브리핑 부재 등은
프로필을 바꿔도 결과가 같으므로 재시도하지 않는다). 마지막 시도 실패 시 마지막 예외를 던진다.
시도 횟수 = min(프로필 수, MAX_BLOCK_RETRIES + 1)."""
profiles = _shuffled_profiles()[: MAX_BLOCK_RETRIES + 1]
last_exc = None
for i, pr in enumerate(profiles):
try:
return await attempt(pr)
except NaverBlockedError as e:
last_exc = e
if i < len(profiles) - 1:
delay = BLOCK_RETRY_DELAYS[min(i, len(BLOCK_RETRY_DELAYS) - 1)]
print(f"■ {label}프로필 '{pr['name']}' 차단 → {delay}s 후 다른 프로필로 재시도...")
await asyncio.sleep(delay)
raise last_exc
def _clean_store(raw: str) -> str:
"""og:title/title 값에서 실제 가게명만 정리. 예: '골목냉면 : 네이버\\x1c' → '골목냉면'."""
if not raw:
return ""
s = re.sub(r"[\x00-\x1f]", "", raw) # 제어문자 제거
s = re.split(r"\s*[:\-|]\s*네이버", s)[0] # ' : 네이버', ' - 네이버 지도' 접미사 제거
s = re.sub(r"\s+", " ", s).strip()
return s.splitlines()[0].strip() if s else ""
async def _store_name(page) -> str:
"""가게명 추출. og:title 메타(가장 안정적) 우선 → title → 화면 요소.
섹션 헤딩('AI 브리핑' 등)이나 '네이버' 포함 값은 배제한다.
(네이버가 span.GHAhO 같은 난독 클래스를 자주 바꿔 화면 셀렉터만으론 불안정하므로 메타 우선.)"""
async def _meta():
return await page.locator("meta[property='og:title']").first.get_attribute("content", timeout=1500)
for getter in (_meta, page.title):
try:
name = _clean_store(await getter())
if name and name != "AI 브리핑" and "네이버" not in name:
return name
except Exception:
pass
for sel in ("span.GHAhO", "#_title span", "h2"): # 최후 폴백(섹션명 배제)
try:
t = (await page.locator(sel).first.inner_text(timeout=1200)).strip().splitlines()[0]
if t and t != "AI 브리핑" and not t.startswith("AI "):
return t
except Exception:
pass
return ""
async def _new_page(p, headful=False, viewport=None, device_scale_factor=None, profile=None):
"""공통 브라우저 컨텍스트(스텔스 + 프로필 지문) → (browser, ctx, page).
profile: name 문자열 / dict / None(랜덤). UA·client-hint·platform·viewport 를 한 세트로 적용.
viewport·device_scale_factor 인자를 명시하면 프로필 값보다 우선(사진 크롤 등 해상도 고정용)."""
pr = pick_profile(profile)
browser = await p.chromium.launch(
headless=not headful,
args=["--disable-blink-features=AutomationControlled", "--no-sandbox"])
headers = {"Accept-Language": "ko-KR,ko;q=0.9"}
if pr.get("client_hints"):
headers.update(pr["client_hints"])
else:
# Safari 프로필: Chromium 이 기본으로 흘리는 sec-ch-ua(…HeadlessChrome…) 를 빈 값으로 덮어
# 'Safari UA + Chromium 힌트' 모순 지문을 차단(실제 Safari 는 이 헤더들을 안 보냄).
headers.update({"sec-ch-ua": "", "sec-ch-ua-mobile": "", "sec-ch-ua-platform": ""})
kwargs = dict(user_agent=pr["ua"], locale="ko-KR",
viewport=viewport or pr.get("viewport") or {"width": 414, "height": 896},
extra_http_headers=headers)
dsf = device_scale_factor or pr.get("dsf")
if dsf:
kwargs["device_scale_factor"] = dsf
ctx = await browser.new_context(**kwargs)
await ctx.add_init_script(_stealth_script(pr.get("platform", "iPhone")))
page = await ctx.new_page()
return browser, ctx, page
# ============================== 브리핑 (이름 · 업종타입 · AI요약) ==============================
# AI 브리핑은 DOM 스크래핑이 아니라 네이버 플레이스가 진입 직후 쏘는 GraphQL(getAiBriefing) 응답에서
# 구조화된 형태로 온다: data.aiBriefing.textSummaries[].sentence. DOM 렌더/스크롤 타이밍에 의존하는
# 기존 방식(_EXTRACT_JS)은 (a) '상단 1줄 요약'과 '하단 상세 브리핑'을 혼동하고, (b) 브리핑이 있어도
# 상단 '요약 티저'가 없는 업체를 놓치는 취약점이 있어, 응답 자체를 가로채는 방식으로 대체했다.
# getAiBriefing 배치 GraphQL 응답의 인트로(preamble) 문장 — 마케팅 소재론 불필요하므로 버린다.
_BRIEFING_INTRO_RE = re.compile(
r"(리뷰[를에]?\s*(바탕|기반|활용))|요약(했|해|합|하면)|정리한\s*(정보|내용)|다음과\s*같습니다")
def _is_ai_briefing_response(resp):
"""이 응답이 getAiBriefing 오퍼레이션을 포함한 GraphQL 배치 응답인가."""
if "graphql" not in resp.url:
return False
req = resp.request
if req.method != "POST":
return False
return "getAiBriefing" in (req.post_data or "")
def _parse_ai_briefing(txt):
"""getAiBriefing 배치 응답(JSON 문자열) 파싱.
반환:
[불릿...] — aiBriefing.textSummaries 에 내용 있음(브리핑 존재). 인트로 문장·'객실 정보:' 류
라벨 제거 + <b> 태그 제거 + 중복 제거까지 마친 마케팅용 불릿.
[] — aiBriefing 필드는 있으나 null/빈 내용(브리핑 없는 업체) → 확정적 '없음'.
None — 배치에 aiBriefing 필드 자체가 없음(무관한 배치/파싱 실패) → 판정 불가(대기 계속)."""
try:
batch = json.loads(txt)
except Exception:
return None
if not isinstance(batch, list):
batch = [batch]
for item in batch:
data = (item or {}).get("data") or {}
if "aiBriefing" not in data:
continue
ai = data.get("aiBriefing") or {}
summaries = ai.get("textSummaries") or []
out, seen = [], set()
for ts in summaries:
s = re.sub(r"</?b>", "", (ts.get("sentence") or "")).strip()
if not s or _BRIEFING_INTRO_RE.search(s):
continue # 빈 문장·인트로(preamble) 제외
s = re.sub(r"^[가-힣A-Za-z ]{1,12}:\s*", "", s) # '객실 정보: ' 류 라벨 제거
s = re.sub(r"\s+", " ", s).strip()
if s and s not in seen:
seen.add(s); out.append(s)
return out # 내용 있으면 [불릿...], 없으면 []
return None
AI_BRIEFING_TIMEOUT = 18 # getAiBriefing 응답 대기(초). 정상은 ~6s, 이 안에 안 오면 스로틀/지연으로 본다.
# place 홈 진입 후 getAiBriefing '요청' 발사를 기다리는 창(초).
# 브리핑이 없는 업체엔 네이버가 요청 자체를 만들지 않는다 — 실측(2026-08-24, zzz/probe_ai_briefing.py):
# 브리핑 있는 업체는 진입 0.8초 뒤 발사·1.1초에 응답, 없는 업체는 45초를 봐도 0회.
# 이 창을 넘기면 '없음'이 확정이므로 응답을 기다릴 이유도, 새 세션으로 재시도할 이유도 없다.
AI_BRIEFING_REQUEST_WINDOW = 6
AI_BRIEFING_RETRIES = 2 # 응답이 끝내 안 올 때(kind='timeout') 새 세션으로 재시도할 횟수.
AI_BRIEFING_RETRY_DELAY = 4 # 재시도 전 대기(초) — 연속 타격으로 스로틀이 굳는 걸 늦춘다.
async def _fetch(url, headful=False):
"""봇벽 차단 시 프로필을 바꿔가며 재시도(_with_profile_retry). 추가로, getAiBriefing 응답이
끝내 안 오면(kind='timeout', 스로틀/지연 추정) 새 세션으로 최대 AI_BRIEFING_RETRIES 회 재시도한다.
반환은 공개 API 형식인 3-튜플 (store, bullets, cat) — 내부 kind 는 여기서 소비.
kind='briefing' → 브리핑 확보(bullets 채움)
kind='none' → 브리핑 없는 업체 확정(요청 미발사 또는 빈 응답; bullets=None → 호출부가 리뷰 폴백)
kind='timeout' → 요청은 나갔으나 응답 보류(재시도 가치 있음); 소진 시 bullets=None(→ 리뷰 폴백)."""
last_store, last_cat = None, None
for i in range(AI_BRIEFING_RETRIES + 1):
store, bullets, cat, kind = await _with_profile_retry(
lambda pr: _fetch_once(url, pr, headful=headful), label="브리핑 ")
last_store, last_cat = store, cat
if kind in ("briefing", "none"):
return store, bullets, cat # 있음/없음 확정 → 재시도 무의미
# kind == 'timeout': getAiBriefing 응답이 안 옴 → 새 세션 재시도
if i < AI_BRIEFING_RETRIES:
print(f"■ '{store or '이 가게'}' AI 브리핑 응답 지연(스로틀 추정) → "
f"{AI_BRIEFING_RETRY_DELAY}s 후 새 세션 재시도 ({i + 1}/{AI_BRIEFING_RETRIES})...")
await asyncio.sleep(AI_BRIEFING_RETRY_DELAY)
return last_store, None, last_cat # 재시도 소진 → 리뷰 폴백
async def _fetch_once(url, profile, headful=False):
"""네이버 플레이스 진입 → getAiBriefing GraphQL 응답을 가로채 브리핑을 파싱.
반환 4-튜플 (store, bullets, cat, kind):
kind='briefing' → bullets=[불릿...] (브리핑 존재)
kind='none' → bullets=None (요청 미발사 또는 aiBriefing null/빈 = 브리핑 없는 업체)
kind='timeout' → bullets=None (요청은 나갔으나 응답이 AI_BRIEFING_TIMEOUT 내 안 옴 = 스로틀/지연)."""
async with async_playwright() as p:
browser, ctx, page = await _new_page(p, headful=headful, profile=profile)
# getAiBriefing 요청/응답을 진입 전에 미리 리스닝(진입 직후 발사되므로 놓치지 않게).
# parsed: [불릿...] 또는 [](없음 확정) 이면 done, None(무관/파싱실패)이면 계속 대기.
captured = {"bullets": None}
done = asyncio.Event()
requested = asyncio.Event() # getAiBriefing 요청이 실제로 나갔는가
async def on_response(resp):
if done.is_set() or not _is_ai_briefing_response(resp):
return
try:
txt = await resp.text()
except Exception:
return # 응답 바디를 못 읽음(경합 등) → 무시
parsed = _parse_ai_briefing(txt)
if parsed is None:
return # aiBriefing 필드 없는 배치 → 계속 대기
captured["bullets"] = parsed # [불릿...] 또는 [](없음) → 확정
done.set()
def on_request(req):
if requested.is_set() or "graphql" not in req.url or req.method != "POST":
return
if "getAiBriefing" in (req.post_data or ""):
requested.set()
page.on("request", on_request)
page.on("response", lambda r: asyncio.ensure_future(on_response(r)))
try:
cat, _pid = await _resolve_place_home(page, url)
except NaverBlockedError:
await browser.close()
raise # 봇벽 → 프로필 전환 재시도(_with_profile_retry)
except Exception:
# 단축링크가 끝내 안 풀림(about:blank 등, 대개 일시적 스로틀) → 예외로 죽이지 말고
# timeout 으로 돌려 새 세션 재시도 대상이 되게 한다(소진 시 None → 호출부 리뷰 폴백).
await browser.close()
return None, None, None, "timeout"
store = await _store_name(page)
try:
await _check_blocked(page)
except Exception:
await browser.close()
raise
# 요청이 창 안에 안 나가면 브리핑 미제공 업체로 확정한다. 예전에는 이 경우도
# 'timeout' 으로 보고 새 세션 재시도를 2회 돌아 업체당 55~65초를 버렸다.
try:
await asyncio.wait_for(requested.wait(), timeout=AI_BRIEFING_REQUEST_WINDOW)
except asyncio.TimeoutError:
await browser.close()
return store, None, cat, "none"
# 요청은 나갔다 → 응답을 기다린다(스크롤 불필요 — 진입 직후 XHR 로 발사됨).
got = True
try:
await asyncio.wait_for(done.wait(), timeout=AI_BRIEFING_TIMEOUT)
except asyncio.TimeoutError:
got = False
await browser.close()
if not got:
return store, None, cat, "timeout" # 응답 보류 → 재시도 대상
bullets = captured["bullets"]
if not bullets: # [] = aiBriefing null/빈 → 브리핑 없음
return store, None, cat, "none"
return store, bullets, cat, "briefing"
def fetch_briefing(url, headful=False):
"""동기 진입점. (가게이름, [불릿...], 업종타입) 반환.
업종타입 = 링크의 플레이스 타입(restaurant/accommodation/hairshop…). 브리핑 없으면 불릿이 None.
※ animation 처럼 업종이 필요 없는 형식은 3번째 값을 그냥 무시하면 된다."""
return asyncio.run(_fetch(url, headful=headful))
# ============================== 리뷰 크롤링 (브리핑 없을 때 폴백) ==============================
# 방문자 리뷰 '본문' 셀렉터. 네이버가 난독 클래스(pui__…)를 자주 바꾸므로 우선순위대로 시도해
# '충분히(3개+) 잡히는 첫 셀렉터'를 쓴다. ★ 최다매칭이 아님 — 작성자/메타 링크(a.pui__GStJHb 등)가
# 더 많이 잡혀서 그걸 고르면 후기가 아니라 '작성자 이름·리뷰수'가 수집되는 문제가 있었다.
# 리뷰 본문 전용 클래스(.pui__vn15t2)를 최우선으로 둔다.
_REVIEW_JS = r"""() => {
const SELS = ['.pui__vn15t2', 'a.pui__xtsQN-', 'div.pui__vn15t2', 'span.pui__gfuUIT'];
const clean = s => (s || '').replace(/\s*더보기\s*$/,'').replace(/\s+/g,' ').trim();
for (const sel of SELS) {
let els;
try { els = [...document.querySelectorAll(sel)]; } catch (e) { continue; }
const texts = els.map(e => clean(e.innerText))
.filter(t => t.length >= 8 && /[가-힣]/.test(t));
if (texts.length >= 3) return texts;
}
return [];
}"""
async def _fetch_reviews(url, max_reviews=30, headful=False):
"""봇벽 차단 시 프로필을 바꿔가며 재시도(최대 MAX_BLOCK_RETRIES 회)."""
return await _with_profile_retry(
lambda pr: _fetch_reviews_once(url, pr, max_reviews=max_reviews, headful=headful),
label="리뷰 ")
async def _fetch_reviews_once(url, profile, max_reviews=30, headful=False):
"""방문자 리뷰 탭에서 '더보기'를 모두 펼쳐 리뷰 본문을 수집. (가게이름, [리뷰문장…]) 반환."""
async with async_playwright() as p:
browser, ctx, page = await _new_page(p, headful=headful, profile=profile)
try:
cat, pid = await _resolve_place_home(page, url)
except Exception:
await browser.close()
raise
store = await _store_name(page)
try:
await _check_blocked(page)
except Exception:
await browser.close()
raise
# 방문자 리뷰 탭으로 이동(구조에 따라 /review/visitor 또는 /review)
reviews, seen = [], set()
for tab in ("review/visitor", "review"):
try:
resp = await page.goto(f"https://m.place.naver.com/{cat}/{pid}/{tab}",
wait_until="domcontentloaded", timeout=25000)
except Exception:
continue
if resp and resp.status >= 400:
continue
await page.wait_for_timeout(2200)
# 스크롤로 리뷰를 더 로드하면서, 매 라운드 '더보기'를 눌러 본문을 전부 펼친 뒤 수집.
stagnant = 0
for _ in range(16):
# ★ 리뷰 '본문(.pui__vn15t2)' 안의 더보기만 클릭한다.
# 페이지 전체의 '더보기'를 무차별 클릭하면 사진/방문정보 더보기가 눌려
# 리뷰 목록이 통째로 사라지는(0개 수집) 문제가 있었다 → 본문 내부로 한정.
try:
bodies = page.locator(".pui__vn15t2")
bn = await bodies.count()
for i in range(bn):
mb = bodies.nth(i).locator(
"xpath=.//*[normalize-space(text())='더보기']")
if await mb.count():
try:
await mb.first.click(timeout=400)
except Exception:
pass
except Exception:
pass
await page.wait_for_timeout(400)
for t in await page.evaluate(_REVIEW_JS):
if t not in seen:
seen.add(t); reviews.append(t)
if len(reviews) >= max_reviews:
break
before = len(reviews)
# 무한스크롤 트리거: 문서 맨 아래로 점프 + 휠 + 대기(리뷰 추가 로드 유발)
try:
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
except Exception:
pass
await page.mouse.wheel(0, 3200)
await page.wait_for_timeout(1100)
stagnant = stagnant + 1 if len(reviews) == before else 0
if stagnant >= 4: # 4라운드 연속 새 리뷰 없음 → 끝까지 봤다고 판단
break
if reviews: # 이 탭에서 뭔가 건졌으면 다른 탭은 시도 안 함
break
await browser.close()
return store, reviews[:max_reviews]
def fetch_reviews(url, max_reviews=30, headful=False):
"""동기 진입점. 방문자 리뷰 본문을 '더보기'까지 펼쳐 최대 max_reviews 개 수집. (가게이름, [리뷰…])."""
return asyncio.run(_fetch_reviews(url, max_reviews=max_reviews, headful=headful))
_REVIEW_SUMMARY_SCHEMA = {
"type": "object",
"properties": {
"bullets": {"type": "array", "items": {"type": "string"},
"description": "가게 홍보 소재로 쓸 핵심 특징 불릿(각 한 문장)"},
},
"required": ["bullets"],
}
def summarize_reviews(client, store, reviews, model="gemini-2.5-flash", n=4):
"""방문자 리뷰들을 Gemini 로 요약해 '브리핑 대체' 불릿 리스트로 반환(실패 시 빈 리스트).
AI 브리핑이 없는 가게에서, 브리핑과 같은 형식(짧은 특징 문장들)을 만들어 나레이션 근거로 쓴다."""
reviews = [r for r in reviews if r][:40]
if not reviews:
return []
joined = "\n".join(f"- {r}" for r in reviews)
sname = store or "이 가게"
prompt = (
f"다음은 '{sname}'를 방문한 손님들이 남긴 실제 리뷰 모음이다.\n"
f"이 리뷰들을 종합해서, 가게를 홍보 영상 소재로 쓸 수 있도록 핵심 특징을 "
f"{n}개의 짧은 불릿으로 요약하라.\n"
f"- ★ 첫 번째 불릿은 반드시 가게 이름 '{sname}'(으)로 시작해, 이 가게가 어떤 곳/무슨 메뉴인지 "
f"한 문장으로 소개하라. (예: '{sname}은(는) …가 유명한 …맛집입니다.')\n"
f"- 나머지 불릿에도 맥락이 필요하면 '{sname}'을(를) 자연스럽게 써도 된다. 절대 'OO식당' 같은 "
"익명 표현으로 바꾸지 마라 — 반드시 실제 상호를 쓴다.\n"
"- 각 불릿은 한 문장. 메뉴/맛/분위기/서비스/특징 위주로.\n"
"- 여러 리뷰에서 반복되는 공통점을 우선. 한두 명만 말한 지엽적 내용은 제외.\n"
"- 리뷰에 없는 사실을 지어내지 마라(과장·거짓 금지).\n\n"
f"[리뷰]\n{joined}"
)
try:
resp = client.models.generate_content(
model=model, contents=prompt,
config=types.GenerateContentConfig(
temperature=0.4, response_mime_type="application/json",
response_schema=_REVIEW_SUMMARY_SCHEMA),
)
out, seen = [], set()
for b in json.loads(resp.text).get("bullets", []):
b = re.sub(r"\s+", " ", str(b)).strip()
if b and b not in seen:
seen.add(b); out.append(b)
return out
except Exception:
return []
# ============================== 사진 (크롤 + Gemini 비전 선별, 인물 제외) ==============================
# 공식 대표사진 CDN(업체 등록)만 후보로 사용.
# 손님 리뷰 사진(pup-review-*)은 저작권/초상권이 '리뷰 작성자 개인'에게 있어
# 가게 동의만으론 광고 영상에 쓸 수 없으므로 제외한다.
# 공식 대표사진이 오는 호스트(둘 다 '업체가 직접 등록'한 사진이라 광고 사용 가능):
# · ldb-phinf : 음식점/일반 업체가 등록한 공식 대표사진
# · naverbooking-phinf : 네이버 '예약'형 숙소(펜션·게하·글램핑)의 공식 객실 사진
# → 예약 숙소는 ldb 대신 이쪽으로 온다 (이게 'accommodation 사진 0장'의 원인이었음)
# 손님 리뷰 사진(pup-review-*)은 초상권 문제로 의도적 제외.
OFFICIAL_HOST = "ldb-phinf.pstatic.net"
BOOKING_HOST = "naverbooking-phinf.pstatic.net"
REVIEW_HOST = "pup-review-phinf.pstatic.net" # (참고: 의도적으로 후보에서 제외)
CAND_HOSTS = (OFFICIAL_HOST, BOOKING_HOST)
def _underlying(url: str) -> str:
"""search.pstatic.net/common?...&src=<진짜URL> 프록시면 src 를 풀어서 반환."""
m = re.search(r"[?&]src=([^&]+)", url)
if m:
return unquote(m.group(1))
return url
async def crawl_and_download(url, dst_dir, max_candidates=14, headful=False):
"""사진 탭 → 공식 대표사진을 dst_dir 에 내려받고 (가게이름, [경로]) 반환."""
dst_dir = Path(dst_dir)
dst_dir.mkdir(parents=True, exist_ok=True)
seen, seen_tab, store = [], [], ""
cur = {"label": "업체"} # 현재 보고 있는 사진탭 카테고리(응답에 태깅)
async with async_playwright() as p:
browser, ctx, page = await _new_page(p, headful=headful)
def on_resp(resp):
u = resp.url
if "phinf.pstatic.net" in u or "search.pstatic.net/common" in u:
seen.append(u); seen_tab.append(cur["label"])
page.on("response", on_resp)
# 링크 → place id → 사진 탭
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
await page.wait_for_timeout(2000)
cat, pid = extract_place(page.url)
if not pid:
await browser.close()
raise RuntimeError("place id 를 못 찾음.")
store = await _store_name(page)
await page.goto(f"https://m.place.naver.com/{cat}/{pid}/photo",
wait_until="domcontentloaded", timeout=30000)
await page.wait_for_timeout(2000)
# [핵심] 사진탭 상단 카테고리 탭(업체/내부/음식·음료/외부/풍경...)을 돌며 로드.
# 기본 '업체' 탭엔 대표사진 몇 장뿐 → 다른 탭을 눌러야 음식·음료(와인 등)·내부가 나온다.
# (세션이 길면 헤드리스 브라우저가 불안정해지므로 탭 수·스크롤을 적당히 제한)
try:
chips = [c.strip() for c in await page.locator("a.sbkBy").all_inner_texts()]
except Exception:
chips = []
targets = [c for c in chips if c and c != "클립"][:6] or [None] # 클립(영상)은 제외
for label in targets:
cur["label"] = label or "업체" # 이 탭에서 로드되는 사진을 이 라벨로 태깅
try:
if label is not None:
await page.locator("a.sbkBy", has_text=label).first.click(timeout=3000)
await page.wait_for_timeout(900)
for _ in range(5): # 각 탭에서 스크롤하며 lazy 로드 유발
await page.mouse.wheel(0, 2500)
await page.wait_for_timeout(300)
except Exception:
break # 연결 끊기면 모은 만큼으로 진행
await page.wait_for_timeout(300)
# 후보 = 탭(업체/내부/음식·음료/외부…)을 '고루 섞어' 뽑는다.
# 앞에서 N장만 자르면 먼저 로드된 업체(food) 탭이 다 차지해 내부/외부가 잘린다
# → 탭별로 모은 뒤 라운드로빈으로 채워 다양성을 강제한다. 각 탭 안에선 공식 먼저.
tab_imgs, bases = {}, set() # tab -> [(host, base)…]
official_n = 0
for u, tab in zip(seen, seen_tab):
real = _underlying(u)
host = urlparse(real).netloc
if host not in CAND_HOSTS: # 공식 대표사진(ldb-phinf)만 통과
continue
if "icon" in real or "profile" in real:
continue
base = real.split("?")[0]
if base in bases:
continue
bases.add(base)
tab_imgs.setdefault(tab, []).append((host, base))
official_n += 1
# 메뉴판·동영상 탭은 광고컷으로 약하므로 뒤로 미룬다
def _tab_rank(t):
return 1 if (t and ("메뉴" in t or "동영상" in t)) else 0
order = sorted(tab_imgs.keys(), key=_tab_rank)
candidates, ptr = [], {t: 0 for t in order}
while len(candidates) < max_candidates and any(ptr[t] < len(tab_imgs[t]) for t in order):
for t in order:
if ptr[t] < len(tab_imgs[t]):
candidates.append(tab_imgs[t][ptr[t]][1]); ptr[t] += 1
if len(candidates) >= max_candidates:
break
tabsum = ", ".join(f"{t}:{len(v)}" for t, v in tab_imgs.items())
print(f"■ 사진 후보 {len(candidates)}장 (공식 {official_n}, "
f"전체 응답 {len(seen)}) / 탭별 [{tabsum}]")
# [진단] 후보 0인데 응답은 많을 때, 실제로 어떤 CDN 호스트에서 왔는지 확인용.
# 여기에 CAND_HOSTS 가 아닌 호스트가 잡히면 (리뷰/블로그 아닌 한) 추가하면 된다.
if official_n == 0 and seen:
from collections import Counter
hosts = Counter(urlparse(_underlying(u)).netloc for u in seen)
print(" [진단] 응답 호스트별: "
+ ", ".join(f"{h}:{c}" for h, c in hosts.most_common()))
# 1차: 브라우저 컨텍스트로 다운로드(쿠키/헤더 유지). 연결 끊기면 남은 건 httpx 폴백.
paths, got = [], set()
for i, u in enumerate(candidates, 1):
try:
r = await ctx.request.get(u, timeout=15000)
if not r.ok:
continue
data = await r.body()
if len(data) < 3000: # 너무 작으면 깨진/아이콘
continue
fp = dst_dir / f"cand{i:02d}.jpg"
fp.write_bytes(data)
paths.append(fp); got.add(i)
except Exception:
break # 연결이 죽었을 가능성 → 폴백으로
try:
await browser.close()
except Exception:
pass
# 2차(폴백): 1차에서 못 받은 후보를 httpx 로 직접 다운로드 (공개 CDN, Referer 헤더로 차단 회피)
missing = [(i, u) for i, u in enumerate(candidates, 1) if i not in got]
if missing:
hdr = {"User-Agent": MOBILE_UA, "Referer": "https://m.place.naver.com/"}
with httpx.Client(headers=hdr, timeout=15, follow_redirects=True) as hc:
for i, u in missing:
try:
rr = hc.get(u)
if rr.status_code == 200 and len(rr.content) >= 3000:
fp = dst_dir / f"cand{i:02d}.jpg"
fp.write_bytes(rr.content); paths.append(fp)
except Exception:
continue
paths.sort()
print(f"■ 다운로드 {len(paths)}장 → {dst_dir}")
return store, paths
# 비전 채점 스키마 — has_person 포함(인물 제외 판정용).
_SCORE_SCHEMA = {
"type": "object",
"properties": {
"scores": {
"type": "array",
"items": {
"type": "object",
"properties": {
"index": {"type": "integer"},
"category": {"type": "string",
"enum": ["food", "interior", "exterior", "menu", "drink", "other"]},
"subject": {"type": "string"}, # 핵심 피사체 한두 단어(다양성 판단용)
"quality": {"type": "integer"}, # 1~10
"good_for_ad": {"type": "boolean"},
"has_person": {"type": "boolean"}, # 사람(전신/일부/뒷모습 포함)이 보이면 true
},
"required": ["index", "category", "subject", "quality", "good_for_ad", "has_person"],
},
}
},
"required": ["scores"],
}
def vision_score(client, paths, cap=16):
"""각 사진을 Gemini 비전으로 채점. [{index,category,subject,quality,good_for_ad,has_person}] 반환."""
paths = paths[:cap]
parts = [types.Part.from_text(text=(
"다음 사진들은 한 음식점의 사진이다. 각 사진을 평가하라.\n"
"- category: food(음식)/drink(음료)/interior(내부)/exterior(외부/간판)/menu(메뉴판)/other\n"
"- subject: 핵심 피사체를 한두 단어로(예: 구운고기, 랍스터, 스테이크, 와인, 파스타, 수영장야경, 실내홀, 외관).\n"
" ★ 거의 같은 피사체면 같은 단어로 통일하라(예: 그릴에 구운 고기 사진 여러 장은 전부 '구운고기').\n"
" 이 값으로 비슷한 사진을 묶어 다양하게 고른다.\n"
"- quality: 광고 영상에 쓸 만큼 잘 나왔는지 1~10 (선명·밝기·구도·먹음직스러움). 흐리거나 어둡거나 잘리면 낮게.\n"
"- has_person: 사진 안에 사람이 조금이라도 보이면 true (얼굴·전신·일부·뒷모습·손 등 무엇이든).\n"
" 사람이 전혀 없는 빈 공간/음식/사물 사진만 false.\n"
"- good_for_ad: 가게 홍보 컷으로 바로 써도 좋은가(true/false).\n"
" ★ 다음은 무조건 category='other', good_for_ad=false, quality 1~3 으로:\n"
" · 로고/브랜드 글자/간판 텍스트만 큰 이미지\n"
" · 메뉴판·가격표·영수증 등 글자 위주 이미지\n"
" · 사람이 보이는 사진(초상권 문제 — 사람 없는 컷이 많으니 사람 있으면 제외), 텍스트 그래픽/포스터만 있는 이미지\n"
" 즉 '음식·음료·매장 공간'이 또렷하게 보이고 사람이 없는 사진만 good_for_ad=true.\n"
"각 사진 바로 앞에 [사진 N] 표시가 있다. 그 N 을 index 로.")), ]
for i, p in enumerate(paths):
parts.append(types.Part.from_text(text=f"[사진 {i}]"))
parts.append(types.Part.from_bytes(data=Path(p).read_bytes(), mime_type="image/jpeg"))
resp = client.models.generate_content(
model=VISION_MODEL, contents=parts,
config=types.GenerateContentConfig(
temperature=0.2, response_mime_type="application/json",
response_schema=_SCORE_SCHEMA),
)
return json.loads(resp.text).get("scores", [])
BAD_CATS = {"menu", "other"}
def _dhash(path, size=8):
"""차이 해시(dHash, 64bit). 시각적으로 비슷한 사진끼리는 해밍거리가 작다.
(같은 그릴/같은 수영장뷰처럼 '거의 같은 그림'을 잡아내 중복 선택을 막는 용도.)"""
try:
img = Image.open(path).convert("L").resize((size + 1, size), Image.LANCZOS)
px = list(img.getdata())
bits = 0
for r in range(size):
row = px[r * (size + 1):(r + 1) * (size + 1)]
for c in range(size):
bits = (bits << 1) | (1 if row[c] < row[c + 1] else 0)
return bits
except Exception:
return None
def _ham(a, b):
if a is None or b is None:
return 999
return bin(a ^ b).count("1")
def too_similar(path, others, thresh=8):
"""path 의 차이해시(dHash)가 others(이미지 경로 리스트) 중 어느 것과든 해밍거리 <= thresh 면 True.
= 시각적으로 '거의 같은 그림'. 사진/클립이 서로 너무 비슷하게 뽑히는 걸 막는 데 쓴다(중복 방지)."""
h = _dhash(path)
if h is None:
return False
return any(_ham(h, _dhash(o)) <= thresh for o in others)
def _subj(s):
"""다양성 판단 키: 피사체(subject) 우선, 없으면 종류(category)."""
return (s.get("subject") or s.get("category") or "other").strip().lower()
def _ngrams(s, k=2):
s = s.replace(" ", "")
return {s[i:i + k] for i in range(len(s) - k + 1)} or {s}
def _sim_subj(a, b):
"""두 피사체가 사실상 같은 종류인가(예: '구운고기' vs '구운고기새우' → True)."""
if not a or not b:
return False
if a in b or b in a:
return True
return bool(_ngrams(a) & _ngrams(b)) # 2글자 조각이 하나라도 겹치면 비슷
def pick_best(paths, scores, n=4, per_cat=2, dup_thresh=8, exclude_person=True):
"""채점 결과로 '종류·피사체 안 겹치게 잘 나온' N장 선택 → 경로 리스트.
비전 모델이 피사체를 너무 잘게 쪼개는 경우가 있어(구운고기/구운고기새우/문어새우 …
전부 사실상 '그릴 음식') 단순 피사체 분산만으론 비슷한 그림이 몰린다. 그래서:
1차) 종류(category) 우선 분산 + 피사체 비유사 — 최대한 다른 종류·다른 피사체로.
2차) 같은 종류 2번째 허용하되 '비유사 피사체'만(그릴 다음엔 디저트 같은 식).
3차) 그래도 모자라면 피사체 유사 허용(동일사진 hash 만 제외).
4차) 최후: 동일사진만 피해 채움.
로고/메뉴판/텍스트(other·menu, good_for_ad=false)는 후보에서 제외.
★ exclude_person=True(기본): 사람이 보이는 사진은 초상권 문제로 끝까지 제외.
"""
by_idx = {s["index"]: s for s in scores if 0 <= s.get("index", -1) < len(paths)}
ranked = sorted(by_idx.values(),
key=lambda s: (s.get("good_for_ad", False), s.get("quality", 0)),
reverse=True)
usable = [s for s in ranked
if s.get("good_for_ad") and s.get("category") not in BAD_CATS
and not (exclude_person and s.get("has_person"))] # 인물 제외(초상권)
hashes = {paths[s["index"]]: _dhash(paths[s["index"]]) for s in ranked}
chosen, chosen_h, chosen_subj, cat_cnt = [], [], [], {}
def is_dup(p):
h = hashes.get(p)
return any(_ham(h, ch) <= dup_thresh for ch in chosen_h)
def take(s):
p = paths[s["index"]]
chosen.append(p); chosen_h.append(hashes.get(p)); chosen_subj.append(_subj(s))
c = s.get("category", "other"); cat_cnt[c] = cat_cnt.get(c, 0) + 1
def fits(s, new_cat, distinct_subj, respect_cap):
p = paths[s["index"]]; c = s.get("category", "other")
if p in chosen or is_dup(p):
return False
if respect_cap and cat_cnt.get(c, 0) >= per_cat:
return False
if new_cat and cat_cnt.get(c, 0) >= 1:
return False
if distinct_subj and any(_sim_subj(_subj(s), cs) for cs in chosen_subj):
return False
return True
# (카테고리상한, 새카테고리우선, 피사체비유사) 단계적 완화.
# 3단계: 한 카테고리로 쏠린 가게(전부 food 등)는 상한을 풀되 '피사체 다양성'은 끝까지 지킨다.
# → 한정식집처럼 다 음식이어도 돌솥밥/불고기/생선구이/전… 서로 다른 걸 고른다.
for new_cat, distinct, cap in [
(True, True, True), (False, True, True),
(False, True, False), (False, False, False)]:
for s in usable:
if len(chosen) >= n:
break
if fits(s, new_cat, distinct, cap):
take(s)
if len(chosen) >= n:
break
# 최후: 동일사진만 피해 나머지로 채움 (단, exclude_person 이면 사람 있는 사진은 끝까지 제외)
if len(chosen) < n:
for s in ranked:
if len(chosen) >= n:
break
if exclude_person and s.get("has_person"):
continue
p = paths[s["index"]]
if p not in chosen and not is_dup(p):
chosen.append(p); chosen_h.append(hashes.get(p)); chosen_subj.append(_subj(s))
if chosen_subj:
print(f" 선별 피사체: {', '.join(chosen_subj)}")
return chosen
def fetch_and_select(url, out_dir, n=4, client=None, max_candidates=16,
headful=False, exclude_person=True):
"""전체 파이프라인: 크롤링 → (있으면)비전 선별 → out_dir 에 photoNN.jpg 저장.
반환 (가게이름, [최종 사진 경로]). client 없으면 비전 없이 앞에서 N장.
exclude_person=True(기본): 인물 사진 제외(초상권). 셋(animation/narration/kakao) 다 기본 적용.
"""
out_dir = Path(out_dir)
tmp = out_dir / "_cand"
if tmp.exists():
shutil.rmtree(tmp, ignore_errors=True)
store, cand = asyncio.run(crawl_and_download(url, tmp, max_candidates, headful))
if not cand:
shutil.rmtree(tmp, ignore_errors=True)
return store, []
if client is not None and len(cand) > n:
try:
scores = vision_score(client, cand)
chosen = pick_best(cand, scores, n=n, exclude_person=exclude_person)
print(f"■ 비전 선별: {len(cand)}장 → {len(chosen)}장")
except Exception as e:
print(f" (비전 선별 실패 → 앞에서 {n}장: {type(e).__name__})")
chosen = cand[:n]
else:
chosen = cand[:n]
out_dir.mkdir(parents=True, exist_ok=True)
finals = []
for i, src in enumerate(chosen, 1):
dst = out_dir / f"photo{i:02d}.jpg"
shutil.copyfile(src, dst)
finals.append(dst)
shutil.rmtree(tmp, ignore_errors=True)
return store, finals