563 lines
27 KiB
Python
563 lines
27 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 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) ==============================
|
|
|
|
MOBILE_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")
|
|
# 자동화 탐지 우회용 init 스크립트
|
|
STEALTH = ("Object.defineProperty(navigator,'webdriver',{get:()=>undefined});"
|
|
"Object.defineProperty(navigator,'languages',{get:()=>['ko-KR','ko']});"
|
|
"window.chrome={runtime:{}};")
|
|
|
|
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)
|
|
|
|
|
|
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):
|
|
"""공통 브라우저 컨텍스트(스텔스 + 모바일 UA) → (browser, ctx, page)."""
|
|
browser = await p.chromium.launch(
|
|
headless=not headful,
|
|
args=["--disable-blink-features=AutomationControlled", "--no-sandbox"])
|
|
kwargs = dict(user_agent=MOBILE_UA, locale="ko-KR",
|
|
viewport=viewport or {"width": 414, "height": 896},
|
|
extra_http_headers={"Accept-Language": "ko-KR,ko;q=0.9"})
|
|
if device_scale_factor:
|
|
kwargs["device_scale_factor"] = device_scale_factor
|
|
ctx = await browser.new_context(**kwargs)
|
|
await ctx.add_init_script(STEALTH)
|
|
page = await ctx.new_page()
|
|
return browser, ctx, page
|
|
|
|
|
|
# ============================== 브리핑 (이름 · 업종타입 · AI요약) ==============================
|
|
|
|
# 홈에서 'AI 브리핑' 불릿 문장만 뽑는 브라우저측 스크립트.
|
|
# [핵심] 브리핑 본문 문장은 출처번호(<a>1</a>…)를 '자식'으로 품은 노드 안에 있어서
|
|
# leaf(자식없는) 추출로는 절대 안 잡힌다(라벨/번호만 leaf로 남는다).
|
|
# → 인트로 + 본문을 모두 품은 컨테이너의 innerText 를 통째로 떠서 줄 단위로 판다.
|
|
# → 본문 불릿과 '출처 리뷰 인용문'을 가르는 구조 신호:
|
|
# 브리핑 불릿 문장 = 뒤에 출처번호만 따라옴.
|
|
# 리뷰 인용문 = 문장 뒤 몇 줄 안에 '날짜(26.05.13.)'가 붙는다.
|
|
# → 문장 뒤 3줄 안에 날짜가 나오면 거기서부터 리뷰 → 그 직전까지가 브리핑.
|
|
# 이 방식은 라벨(객실/시설/위치/서비스…)이 뭐로 바뀌든 안 흔들린다.
|
|
_EXTRACT_JS = r"""() => {
|
|
const hasHeader = [...document.querySelectorAll('body *')]
|
|
.some(e => e.children.length === 0 && /^AI\s*브리핑/.test((e.innerText||'').trim()));
|
|
|
|
// 인트로 + 본문을 함께 품은 '가장 안쪽' 컨테이너
|
|
const INTRO = /(리뷰[를에]?\s*(바탕|기반|활용))|요약(했|해|합|하면)|정리한 (정보|내용)|다음과 같습니다/;
|
|
const cands = [...document.querySelectorAll('body *')]
|
|
.filter(e => { const t = e.innerText || '';
|
|
return INTRO.test(t) && t.length > 40 && /[가-힣]{6,}/.test(t); });
|
|
cands.sort((a, b) => (a.innerText||'').length - (b.innerText||'').length);
|
|
if (!cands.length) return {found: hasHeader, bullets: []};
|
|
const block = cands[0].innerText || '';
|
|
|
|
const lines = block.split('\n').map(s => s.trim()).filter(Boolean);
|
|
const isNum = s => /^\d+$/.test(s);
|
|
const isDate = s => /^\d{2}\.\d{2}\.\d{2}\.?$/.test(s) || /^\d{4}\s*년/.test(s);
|
|
const isSent = s => s.length >= 12 && /[가-힣]/.test(s) && !isNum(s) && !INTRO.test(s);
|
|
|
|
// 인트로 다음부터, 리뷰 인용(문장 뒤 3줄 내 날짜) 시작 전까지의 문장 = 브리핑 불릿
|
|
let start = lines.findIndex(t => INTRO.test(t));
|
|
start = start < 0 ? 0 : start + 1;
|
|
const bullets = [];
|
|
for (let i = start; i < lines.length; i++) {
|
|
if (!isSent(lines[i])) continue;
|
|
if (lines.slice(i + 1, i + 4).some(isDate)) break; // 리뷰 인용 시작 → 종료
|
|
bullets.push(lines[i].replace(/^[가-힣A-Za-z ]{1,12}:\s*/, '')); // '객실 정보: ' 라벨 제거
|
|
}
|
|
return {found: hasHeader || bullets.length > 0, bullets};
|
|
}"""
|
|
|
|
|
|
async def _fetch(url, headful=False):
|
|
async with async_playwright() as p:
|
|
browser, ctx, page = await _new_page(p, headful=headful)
|
|
|
|
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
|
await page.wait_for_timeout(2500)
|
|
cat, pid = extract_place(page.url)
|
|
if not pid:
|
|
await browser.close()
|
|
raise RuntimeError("place id 를 못 찾음. URL 확인 또는 headful=True 로 화면 확인.")
|
|
|
|
# 항상 표준 홈 URL 로 이동(리다이렉트 직후엔 섹션이 덜 렌더됨)
|
|
await page.goto(f"https://m.place.naver.com/{cat}/{pid}/home",
|
|
wait_until="domcontentloaded", timeout=30000)
|
|
await page.wait_for_timeout(2000)
|
|
try:
|
|
await page.wait_for_selector("text=AI 브리핑", timeout=8000)
|
|
except Exception:
|
|
for _ in range(3):
|
|
await page.mouse.wheel(0, 1500)
|
|
await page.wait_for_timeout(600)
|
|
await page.wait_for_timeout(800)
|
|
|
|
store = await _store_name(page)
|
|
|
|
res = await page.evaluate(_EXTRACT_JS)
|
|
await browser.close()
|
|
|
|
if not res.get("found"):
|
|
return store, None, cat
|
|
seen, bullets = set(), []
|
|
for t in res.get("bullets", []):
|
|
t = re.sub(r"\s+", " ", t).strip()
|
|
if t and t not in seen:
|
|
seen.add(t); bullets.append(t)
|
|
return store, bullets, cat
|
|
|
|
|
|
def fetch_briefing(url, headful=False):
|
|
"""동기 진입점. (가게이름, [불릿...], 업종타입) 반환.
|
|
업종타입 = 링크의 플레이스 타입(restaurant/accommodation/hairshop…). 브리핑 없으면 불릿이 None.
|
|
※ animation 처럼 업종이 필요 없는 형식은 3번째 값을 그냥 무시하면 된다."""
|
|
return asyncio.run(_fetch(url, headful=headful))
|
|
|
|
|
|
# ============================== 사진 (크롤 + 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
|
|
|