diff --git a/backend/generator/animation/fonts/brand_logo.png b/backend/generator/animation/fonts/brand_logo.png new file mode 100644 index 0000000..a0a5a94 Binary files /dev/null and b/backend/generator/animation/fonts/brand_logo.png differ diff --git a/backend/generator/animation/main.py b/backend/generator/animation/main.py index 71e2baf..d0c1827 100644 --- a/backend/generator/animation/main.py +++ b/backend/generator/animation/main.py @@ -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: diff --git a/backend/generator/animation_greekroman/fonts/brand_logo.png b/backend/generator/animation_greekroman/fonts/brand_logo.png new file mode 100644 index 0000000..a0a5a94 Binary files /dev/null and b/backend/generator/animation_greekroman/fonts/brand_logo.png differ diff --git a/backend/generator/animation_greekroman/main.py b/backend/generator/animation_greekroman/main.py index 8a7234c..205511f 100644 --- a/backend/generator/animation_greekroman/main.py +++ b/backend/generator/animation_greekroman/main.py @@ -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: diff --git a/backend/generator/animation_samgukji/fonts/brand_logo.png b/backend/generator/animation_samgukji/fonts/brand_logo.png new file mode 100644 index 0000000..a0a5a94 Binary files /dev/null and b/backend/generator/animation_samgukji/fonts/brand_logo.png differ diff --git a/backend/generator/animation_samgukji/main.py b/backend/generator/animation_samgukji/main.py index 78de314..a1696ed 100644 --- a/backend/generator/animation_samgukji/main.py +++ b/backend/generator/animation_samgukji/main.py @@ -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: diff --git a/backend/generator/naver.py b/backend/generator/naver.py index 8f7fdf4..14e3824 100644 --- a/backend/generator/naver.py +++ b/backend/generator/naver.py @@ -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(업체 등록)만 후보로 사용.