984 lines
48 KiB
Python
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
|
|
|