feat(generator): AI브리핑 폴링 안정화 + 리뷰 요약 폴백 + 브랜드 로고 (shorts_all 동기화)
- naver.py: AI 브리핑이 하단 lazy-load 되는 특성 반영 — 섹션을 뷰에 올려 본문 불릿이 실제 로드될 때까지 스크롤·폴링(간헐 빈결과 수정). 리뷰 크롤·요약 함수(fetch_reviews/summarize_reviews) 추가. - main.py(3): AI 브리핑이 없는 가게는 방문자 리뷰를 긁어 Gemini로 요약해 소재로 사용하는 폴백 추가. - 각 엔진 fonts/brand_logo.png 추가(상단 밴드 제목 옆 로고 — 렌더에 필요). - emile 잔여물(generator/data/emile_*) 제거. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>main
parent
f24818860b
commit
cb7c463f98
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -137,10 +137,24 @@ def main():
|
|||
except Exception as e:
|
||||
sys.exit(f"[!] AI 브리핑 수집 실패: {type(e).__name__}: {e}")
|
||||
if not bullets:
|
||||
sys.exit(f"[!] '{brief_store or '이 가게'}'에 AI 브리핑이 없어요. "
|
||||
"키워드를 직접 주거나, 브리핑 있는 가게 링크를 쓰세요.")
|
||||
# AI 브리핑이 없으면 → 방문자 리뷰를 '더보기'까지 펼쳐 긁은 뒤 Gemini 로 요약(브리핑 대체)
|
||||
print(f"■ '{brief_store or '이 가게'}' AI 브리핑 없음 → 방문자 리뷰 크롤링 후 요약...")
|
||||
_key = args.api_key or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
|
||||
if not _key:
|
||||
sys.exit("[!] 리뷰 요약에 API 키 필요. setx GEMINI_API_KEY \"키\" 후 새 터미널, 또는 --api-key.")
|
||||
try:
|
||||
rstore, reviews = NV.fetch_reviews(args.link, max_reviews=30)
|
||||
except Exception as e:
|
||||
sys.exit(f"[!] 리뷰 수집 실패: {type(e).__name__}: {e}")
|
||||
if rstore and not brief_store:
|
||||
brief_store = rstore
|
||||
bullets = NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews)
|
||||
if not bullets:
|
||||
sys.exit(f"[!] '{brief_store or '이 가게'}'는 AI 브리핑도 리뷰도 못 얻었어요. "
|
||||
"키워드를 직접 주거나 다른 링크를 쓰세요.")
|
||||
print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)")
|
||||
product = " ".join(bullets)
|
||||
print(f"■ 가게: {brief_store or '?'} / 브리핑 {len(bullets)}줄 → 키워드로 사용")
|
||||
print(f"■ 가게: {brief_store or '?'} / 소재 {len(bullets)}줄 → 키워드로 사용")
|
||||
for b in bullets:
|
||||
print(f" • {b}")
|
||||
if not product:
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -137,10 +137,24 @@ def main():
|
|||
except Exception as e:
|
||||
sys.exit(f"[!] AI 브리핑 수집 실패: {type(e).__name__}: {e}")
|
||||
if not bullets:
|
||||
sys.exit(f"[!] '{brief_store or '이 가게'}'에 AI 브리핑이 없어요. "
|
||||
"키워드를 직접 주거나, 브리핑 있는 가게 링크를 쓰세요.")
|
||||
# AI 브리핑이 없으면 → 방문자 리뷰를 '더보기'까지 펼쳐 긁은 뒤 Gemini 로 요약(브리핑 대체)
|
||||
print(f"■ '{brief_store or '이 가게'}' AI 브리핑 없음 → 방문자 리뷰 크롤링 후 요약...")
|
||||
_key = args.api_key or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
|
||||
if not _key:
|
||||
sys.exit("[!] 리뷰 요약에 API 키 필요. setx GEMINI_API_KEY \"키\" 후 새 터미널, 또는 --api-key.")
|
||||
try:
|
||||
rstore, reviews = NV.fetch_reviews(args.link, max_reviews=30)
|
||||
except Exception as e:
|
||||
sys.exit(f"[!] 리뷰 수집 실패: {type(e).__name__}: {e}")
|
||||
if rstore and not brief_store:
|
||||
brief_store = rstore
|
||||
bullets = NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews)
|
||||
if not bullets:
|
||||
sys.exit(f"[!] '{brief_store or '이 가게'}'는 AI 브리핑도 리뷰도 못 얻었어요. "
|
||||
"키워드를 직접 주거나 다른 링크를 쓰세요.")
|
||||
print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)")
|
||||
product = " ".join(bullets)
|
||||
print(f"■ 가게: {brief_store or '?'} / 브리핑 {len(bullets)}줄 → 키워드로 사용")
|
||||
print(f"■ 가게: {brief_store or '?'} / 소재 {len(bullets)}줄 → 키워드로 사용")
|
||||
for b in bullets:
|
||||
print(f" • {b}")
|
||||
if not product:
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -137,10 +137,24 @@ def main():
|
|||
except Exception as e:
|
||||
sys.exit(f"[!] AI 브리핑 수집 실패: {type(e).__name__}: {e}")
|
||||
if not bullets:
|
||||
sys.exit(f"[!] '{brief_store or '이 가게'}'에 AI 브리핑이 없어요. "
|
||||
"키워드를 직접 주거나, 브리핑 있는 가게 링크를 쓰세요.")
|
||||
# AI 브리핑이 없으면 → 방문자 리뷰를 '더보기'까지 펼쳐 긁은 뒤 Gemini 로 요약(브리핑 대체)
|
||||
print(f"■ '{brief_store or '이 가게'}' AI 브리핑 없음 → 방문자 리뷰 크롤링 후 요약...")
|
||||
_key = args.api_key or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
|
||||
if not _key:
|
||||
sys.exit("[!] 리뷰 요약에 API 키 필요. setx GEMINI_API_KEY \"키\" 후 새 터미널, 또는 --api-key.")
|
||||
try:
|
||||
rstore, reviews = NV.fetch_reviews(args.link, max_reviews=30)
|
||||
except Exception as e:
|
||||
sys.exit(f"[!] 리뷰 수집 실패: {type(e).__name__}: {e}")
|
||||
if rstore and not brief_store:
|
||||
brief_store = rstore
|
||||
bullets = NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews)
|
||||
if not bullets:
|
||||
sys.exit(f"[!] '{brief_store or '이 가게'}'는 AI 브리핑도 리뷰도 못 얻었어요. "
|
||||
"키워드를 직접 주거나 다른 링크를 쓰세요.")
|
||||
print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)")
|
||||
product = " ".join(bullets)
|
||||
print(f"■ 가게: {brief_store or '?'} / 브리핑 {len(bullets)}줄 → 키워드로 사용")
|
||||
print(f"■ 가게: {brief_store or '?'} / 소재 {len(bullets)}줄 → 키워드로 사용")
|
||||
for b in bullets:
|
||||
print(f" • {b}")
|
||||
if not product:
|
||||
|
|
|
|||
|
|
@ -161,17 +161,33 @@ async def _fetch(url, headful=False):
|
|||
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)
|
||||
# [핵심] AI 브리핑은 페이지 하단에서 lazy-load 된다.
|
||||
# 헤더('AI 브리핑')가 보여도 본문(불릿)은 그 섹션이 화면에 들어와야 네트워크로 채워진다.
|
||||
# → 예전 코드는 헤더가 8초 안에 보이면 스크롤을 '안 하고' 바로 추출해서,
|
||||
# 본문이 아직 안 그려진 순간엔 빈 결과(found=False)가 나왔다(간헐 실패의 원인).
|
||||
# → 이제는 브리핑 섹션을 화면에 띄우고, 본문 불릿이 실제로 나올 때까지 스크롤하며 폴링한다.
|
||||
res = {"found": False, "bullets": []}
|
||||
for attempt in range(14): # 최대 ~14회(≈11초) 폴링
|
||||
# 브리핑 헤더를 뷰에 올려 본문 로딩을 유발(있으면). 없으면 아래로 조금씩 스크롤.
|
||||
try:
|
||||
loc = page.get_by_text("AI 브리핑", exact=False).first
|
||||
await loc.scroll_into_view_if_needed(timeout=1500)
|
||||
except Exception:
|
||||
await page.mouse.wheel(0, 1600)
|
||||
await page.wait_for_timeout(700)
|
||||
res = await page.evaluate(_EXTRACT_JS)
|
||||
if res.get("bullets"):
|
||||
# 불릿이 처음 잡혀도 본문이 아직 다 안 채워졌을 수 있다(2/3만 렌더된 순간).
|
||||
# 한 번 더 기다렸다 재추출해서 더 많이 잡히면 그걸 택한 뒤 종료.
|
||||
await page.wait_for_timeout(900)
|
||||
res2 = await page.evaluate(_EXTRACT_JS)
|
||||
if len(res2.get("bullets", [])) > len(res.get("bullets", [])):
|
||||
res = res2
|
||||
break
|
||||
|
||||
await browser.close()
|
||||
|
||||
if not res.get("found"):
|
||||
|
|
@ -191,6 +207,150 @@ def fetch_briefing(url, headful=False):
|
|||
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):
|
||||
"""방문자 리뷰 탭에서 '더보기'를 모두 펼쳐 리뷰 본문을 수집. (가게이름, [리뷰문장…]) 반환."""
|
||||
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 를 못 찾음.")
|
||||
store = await _store_name(page)
|
||||
|
||||
# 방문자 리뷰 탭으로 이동(구조에 따라 /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(업체 등록)만 후보로 사용.
|
||||
|
|
|
|||
Loading…
Reference in New Issue