From 3cddd17eedfcbfd8efd2c88cd7c01a8140c7c0d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Mon, 7 Sep 2026 13:42:00 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=B0=A9=EB=AC=B8=EC=9E=90=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B3=B8=EB=AC=B8=20=EC=97=86=EC=9D=84=20=EB=95=8C?= =?UTF-8?q?=20=EB=B8=94=EB=A1=9C=EA=B7=B8=20=EB=A6=AC=EB=B7=B0=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=B4=EC=B6=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- generator/animation/main.py | 19 +++-- generator/animation_greekroman/main.py | 19 +++-- generator/animation_odyssey/main.py | 41 ++++++----- generator/animation_samgukji/main.py | 19 +++-- generator/naver.py | 99 +++++++++++++++++++++++++- 5 files changed, 159 insertions(+), 38 deletions(-) diff --git a/generator/animation/main.py b/generator/animation/main.py index 5821f57..10f3bad 100644 --- a/generator/animation/main.py +++ b/generator/animation/main.py @@ -145,14 +145,21 @@ def main(): try: rstore, reviews = NV.fetch_reviews(args.link, max_reviews=30) except Exception as e: - sys.exit(f"[!] 리뷰 수집 실패: {type(e).__name__}: {e}") + # 리뷰 수집 실패로 생성 전체를 죽이지 않는다 — 가게명만으로라도 계속 간다. + print(f"[!] 리뷰 수집 실패(수집된 정보만으로 계속): {type(e).__name__}: {e}") + rstore, reviews = None, [] 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)}줄 (브리핑 대체)") + bullets = (NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews) + if reviews else []) + if bullets: + print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)") + elif brief_store: + # 브리핑·방문자 리뷰·블로그 리뷰 모두 부실 — 에러로 끝내지 않고 + # 그때까지 수집된 정보(가게명)만으로 생성을 계속한다. + print(f"■ 소재 부족 → 수집된 정보(가게명 '{brief_store}')만으로 생성 진행") + bullets = [brief_store] + # 가게명조차 없으면 bullets 가 비어 아래 product 가드에서 종료된다. product = " ".join(bullets) print(f"■ 가게: {brief_store or '?'} / 소재 {len(bullets)}줄 → 키워드로 사용") for b in bullets: diff --git a/generator/animation_greekroman/main.py b/generator/animation_greekroman/main.py index f43cae7..935ff50 100644 --- a/generator/animation_greekroman/main.py +++ b/generator/animation_greekroman/main.py @@ -145,14 +145,21 @@ def main(): try: rstore, reviews = NV.fetch_reviews(args.link, max_reviews=30) except Exception as e: - sys.exit(f"[!] 리뷰 수집 실패: {type(e).__name__}: {e}") + # 리뷰 수집 실패로 생성 전체를 죽이지 않는다 — 가게명만으로라도 계속 간다. + print(f"[!] 리뷰 수집 실패(수집된 정보만으로 계속): {type(e).__name__}: {e}") + rstore, reviews = None, [] 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)}줄 (브리핑 대체)") + bullets = (NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews) + if reviews else []) + if bullets: + print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)") + elif brief_store: + # 브리핑·방문자 리뷰·블로그 리뷰 모두 부실 — 에러로 끝내지 않고 + # 그때까지 수집된 정보(가게명)만으로 생성을 계속한다. + print(f"■ 소재 부족 → 수집된 정보(가게명 '{brief_store}')만으로 생성 진행") + bullets = [brief_store] + # 가게명조차 없으면 bullets 가 비어 아래 product 가드에서 종료된다. product = " ".join(bullets) print(f"■ 가게: {brief_store or '?'} / 소재 {len(bullets)}줄 → 키워드로 사용") for b in bullets: diff --git a/generator/animation_odyssey/main.py b/generator/animation_odyssey/main.py index 32c2d2a..935ff50 100644 --- a/generator/animation_odyssey/main.py +++ b/generator/animation_odyssey/main.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- """ -키워드 → 병맛 오디세이 마케팅 쇼츠 (완전 자동, 원커맨드) +키워드 → 병맛 그리스 로마 신화 마케팅 쇼츠 (완전 자동, 원커맨드) ========================================================== 마케팅하고 싶은 대상을 '자유로운 키워드/문장'으로 주면: - 1) 랜덤으로 오디세우스 귀향 모험 '썰' 매칭 + 1) 랜덤으로 그리스 로마 신화 신·영웅 '썰' 매칭 2) Gemini 로 병맛 마케팅 나레이션 생성 3) 장면 분할(스토리보드) 4) 장면별 이미지 + 음성 생성 @@ -12,17 +12,17 @@ 6) 한 작업의 결과(나레이션·스토리보드·이미지·음성·mp4)는 모두 output/<작업폴더>/ 안에만 저장. 완성 후 중간 파일(assets)은 자동 삭제. -선행: scraper.py 로 만든 data/odyssey_keywords_*.csv 가 있어야 함. +선행: scraper.py 로 만든 data/greek_keywords_*.csv 가 있어야 함. API 키: GEMINI_API_KEY 환경변수 (또는 --api-key). 이미지 생성은 결제 활성화 필요. ★ 실행 (shorts_all 폴더 안에서, 키워드는 자유롭게): - python animation_odyssey\\main.py 감성 펜션 왕의휴식 객실 노천탕 평창 숲속 1박 12만원 - python animation_odyssey\\main.py "강원도 평창 '왕의휴식' 감성 펜션, 객실 노천탕, 1박 12만원" - python animation_odyssey\\main.py https://naver.me/xxxx + python animation_greekroman\\main.py 감성 펜션 왕의휴식 객실 노천탕 평창 숲속 1박 12만원 + python animation_greekroman\\main.py "강원도 평창 '왕의휴식' 감성 펜션, 객실 노천탕, 1박 12만원" + python animation_greekroman\\main.py https://naver.me/xxxx 옵션: --label 왕의휴식 결과 폴더 이름에 쓸 라벨 (기본: 키워드 앞부분 자동) - --king 오디세우스 특정 인물 고정 (기본: 랜덤) + --king 제우스 특정 신/영웅 고정 (기본: 랜덤) --scenes 12 장면 수=이미지 수 (기본 12) --seconds 45 목표 길이 초 (기본 45) --voice Puck TTS 목소리 @@ -51,7 +51,7 @@ import render as MS # animation 영상합성 엔진(이전 make_sho import naver as NV # 공유 네이버 크롤러/브리핑/사진 선별 HERE = Path(__file__).parent -OUT_DIR = HERE.parent / "output" / "animation_odyssey" # 통합 출력: shorts_all/output/animation_odyssey/ +OUT_DIR = HERE.parent / "output" / "animation_greekroman" # 통합 출력: shorts_all/output/animation_greekroman/ TEXT_MODEL = "gemini-2.5-flash" @@ -62,7 +62,7 @@ def safe_name(s: str) -> str: def main(): ap = argparse.ArgumentParser( - description="키워드 또는 네이버 링크 → 병맛 오디세이 쇼츠 (완전 자동)", + description="키워드 또는 네이버 링크 → 병맛 그리스 로마 신화 쇼츠 (완전 자동)", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="예) python main.py 감성 펜션 왕의휴식 객실 노천탕 1박 12만원\n" " python main.py https://naver.me/xxxx (링크만 — AI브리핑 자동 키워드화)", @@ -73,7 +73,7 @@ def main(): # 결과 폴더 라벨 ap.add_argument("--label", help="결과 폴더 라벨 (기본: 키워드 앞부분 자동)") # 생성 옵션 - ap.add_argument("--king", help="특정 인물 고정 (예: 오디세우스, 기본: 랜덤)") + ap.add_argument("--king", help="특정 신/영웅 고정 (예: 제우스, 기본: 랜덤)") ap.add_argument("--scenes", type=int, default=12, help="장면 수=이미지 수 (기본 12)") ap.add_argument("--seconds", type=int, default=45, help="목표 길이(초) (기본 45)") ap.add_argument("--voice", default=MS.TTS_VOICE, help=f"TTS 목소리 (기본 {MS.TTS_VOICE})") @@ -145,14 +145,21 @@ def main(): try: rstore, reviews = NV.fetch_reviews(args.link, max_reviews=30) except Exception as e: - sys.exit(f"[!] 리뷰 수집 실패: {type(e).__name__}: {e}") + # 리뷰 수집 실패로 생성 전체를 죽이지 않는다 — 가게명만으로라도 계속 간다. + print(f"[!] 리뷰 수집 실패(수집된 정보만으로 계속): {type(e).__name__}: {e}") + rstore, reviews = None, [] 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)}줄 (브리핑 대체)") + bullets = (NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews) + if reviews else []) + if bullets: + print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)") + elif brief_store: + # 브리핑·방문자 리뷰·블로그 리뷰 모두 부실 — 에러로 끝내지 않고 + # 그때까지 수집된 정보(가게명)만으로 생성을 계속한다. + print(f"■ 소재 부족 → 수집된 정보(가게명 '{brief_store}')만으로 생성 진행") + bullets = [brief_store] + # 가게명조차 없으면 bullets 가 비어 아래 product 가드에서 종료된다. product = " ".join(bullets) print(f"■ 가게: {brief_store or '?'} / 소재 {len(bullets)}줄 → 키워드로 사용") for b in bullets: @@ -207,7 +214,7 @@ def main(): print(f"■ 마케팅 대상 : {product}") print(f"■ 매칭된 썰 : {seed['king']} / \"{seed['keyword']}\"\n") - # 2) 병맛 나레이션 (+ SNS 업로드용 캡션) + # 2) 병맛 나레이션 print("[1/4] 나레이션 생성...") store_name = brief_store or getattr(args, "store", None) or label narr = gen.generate(client, TEXT_MODEL, seed, product, args.seconds, store=store_name) diff --git a/generator/animation_samgukji/main.py b/generator/animation_samgukji/main.py index a07a3e6..ab7d57c 100644 --- a/generator/animation_samgukji/main.py +++ b/generator/animation_samgukji/main.py @@ -145,14 +145,21 @@ def main(): try: rstore, reviews = NV.fetch_reviews(args.link, max_reviews=30) except Exception as e: - sys.exit(f"[!] 리뷰 수집 실패: {type(e).__name__}: {e}") + # 리뷰 수집 실패로 생성 전체를 죽이지 않는다 — 가게명만으로라도 계속 간다. + print(f"[!] 리뷰 수집 실패(수집된 정보만으로 계속): {type(e).__name__}: {e}") + rstore, reviews = None, [] 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)}줄 (브리핑 대체)") + bullets = (NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews) + if reviews else []) + if bullets: + print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)") + elif brief_store: + # 브리핑·방문자 리뷰·블로그 리뷰 모두 부실 — 에러로 끝내지 않고 + # 그때까지 수집된 정보(가게명)만으로 생성을 계속한다. + print(f"■ 소재 부족 → 수집된 정보(가게명 '{brief_store}')만으로 생성 진행") + bullets = [brief_store] + # 가게명조차 없으면 bullets 가 비어 아래 product 가드에서 종료된다. product = " ".join(bullets) print(f"■ 가게: {brief_store or '?'} / 소재 {len(bullets)}줄 → 키워드로 사용") for b in bullets: diff --git a/generator/naver.py b/generator/naver.py index 7b9f70e..0c19e5b 100644 --- a/generator/naver.py +++ b/generator/naver.py @@ -484,6 +484,80 @@ async def _fetch_reviews(url, max_reviews=30, headful=False): label="리뷰 ") +# 방문 메타데이터로 보이는 텍스트 — 리뷰 '본문'이 아니다. 영수증 인증만 있고 글이 없는 +# 업장에서는 _REVIEW_JS 셀렉터(.pui__vn15t2)가 "방문일 7.31.금…", "인증 수단 영수증" +# 같은 메타 요소만 잡아 와서, 쓰레기 텍스트가 Gemini 요약에 들어가 빈 요약 → 생성 실패로 +# 이어졌다(실측: 돈까스짱 금남시장점, zzz/_probe_blog_reviews.py). 본문 판정에서 걸러낸다. +_REVIEW_JUNK_RE = re.compile( + r"^(방문일|인증\s*수단|\d+번째\s*방문|예약\s*시간|대기\s*시간)" + r"|^\d{2,4}[.년]\s*\d" # "26.1.13.화", "2026년 1월 13일 …" 류 날짜 + r"|^리뷰\s*[\d,]+\s*사진\s*[\d,]+$" # 작성자 프로필의 "리뷰 N 사진 M" +) + +#: 이 개수 미만의 '실제 본문'만 건졌으면 소재가 부실하다고 보고 블로그 리뷰로 보충한다. +MIN_USABLE_REVIEWS = 5 + + +def _usable_reviews(texts): + """수집 텍스트에서 방문 메타(날짜/인증수단/방문차수 등)를 걸러 실제 본문만 남긴다.""" + return [t for t in texts if not _REVIEW_JUNK_RE.search(t)] + + +# 블로그 리뷰 탭(/review/ugc) 수집 스크립트. 항목(li.EblIP)마다 제목(.pui__dGLDWy)과 +# 본문 발췌(.pui__vn15t2)를 합쳐 한 건으로 만든다. 컨테이너 난독 클래스가 바뀌면 +# 본문 클래스 단독 수집으로 폴백한다(본문 클래스는 방문자 리뷰 탭과 공유되어 상대적으로 안정). +_BLOG_REVIEW_JS = r"""() => { + const clean = s => (s || '').replace(/\s*더보기\s*$/,'').replace(/\s+/g,' ').trim(); + const seen = new Set(), out = []; + const push = t => { + if (t.length >= 15 && /[가-힣]/.test(t) && !seen.has(t)) { seen.add(t); out.push(t); } + }; + const items = document.querySelectorAll('li.EblIP'); + if (items.length) { + for (const it of items) { + const title = clean(it.querySelector('.pui__dGLDWy')?.innerText); + const body = clean(it.querySelector('.pui__vn15t2')?.innerText); + push([title, body].filter(Boolean).join(' — ')); + } + } else { + for (const e of document.querySelectorAll('.pui__vn15t2')) push(clean(e.innerText)); + } + return out; +}""" + + +async def _collect_blog_reviews(page, cat, pid, need): + """블로그 리뷰 탭에서 제목+본문 발췌를 최대 need 건 수집. 실패·없음이면 빈 리스트.""" + if need <= 0: + return [] + try: + resp = await page.goto(f"https://m.place.naver.com/{cat}/{pid}/review/ugc", + wait_until="domcontentloaded", timeout=25000) + except Exception: + return [] + if resp and resp.status >= 400: + return [] + await page.wait_for_timeout(2200) + + texts, stagnant = [], 0 + for _ in range(10): + got = await page.evaluate(_BLOG_REVIEW_JS) + texts = got if len(got) > len(texts) else texts + if len(texts) >= need: + break + before = len(texts) + 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(texts) == before else 0 + if stagnant >= 3: + break + return texts[:need] + + async def _fetch_reviews_once(url, profile, max_reviews=30, headful=False): """방문자 리뷰 탭에서 '더보기'를 모두 펼쳐 리뷰 본문을 수집. (가게이름, [리뷰문장…]) 반환.""" async with async_playwright() as p: @@ -555,12 +629,28 @@ async def _fetch_reviews_once(url, profile, max_reviews=30, headful=False): if reviews: # 이 탭에서 뭔가 건졌으면 다른 탭은 시도 안 함 break + # 방문 메타(날짜/인증수단 등)를 걸러 '실제 본문'만 남긴다. 본문이 임계 미만이면 + # 블로그 리뷰 탭으로 보충한다(대체가 아니라 보충 — 건진 본문은 함께 쓴다). + usable = _usable_reviews(reviews) + if len(usable) < MIN_USABLE_REVIEWS: + try: + blog = await _collect_blog_reviews(page, cat, pid, max_reviews - len(usable)) + except Exception as e: + print(f"■ 블로그 리뷰 수집 실패(방문자 리뷰만으로 진행): {type(e).__name__}: {e}") + blog = [] + print(f"■ 방문자 리뷰 본문 {len(usable)}건(수집 {len(reviews)}건 중, 임계 " + f"{MIN_USABLE_REVIEWS}건 미만) → 블로그 리뷰 {len(blog)}건 보충") + known = set(usable) + usable += [b for b in blog if b not in known] + reviews = usable + await browser.close() return store, reviews[:max_reviews] def fetch_reviews(url, max_reviews=30, headful=False): - """동기 진입점. 방문자 리뷰 본문을 '더보기'까지 펼쳐 최대 max_reviews 개 수집. (가게이름, [리뷰…]).""" + """동기 진입점. 방문자 리뷰 본문을 '더보기'까지 펼쳐 최대 max_reviews 개 수집. + 본문이 부실하면(영수증 인증만 있는 업장 등) 블로그 리뷰 탭에서 보충한다. (가게이름, [리뷰…]).""" return asyncio.run(_fetch_reviews(url, max_reviews=max_reviews, headful=headful)) @@ -583,7 +673,8 @@ def summarize_reviews(client, store, reviews, model="gemini-2.5-flash", n=4): joined = "\n".join(f"- {r}" for r in reviews) sname = store or "이 가게" prompt = ( - f"다음은 '{sname}'를 방문한 손님들이 남긴 실제 리뷰 모음이다.\n" + f"다음은 '{sname}'를 방문한 손님들이 남긴 실제 리뷰 모음이다" + f"(방문자 리뷰가 부족하면 블로그 후기 발췌가 섞여 있을 수 있다).\n" f"이 리뷰들을 종합해서, 가게를 홍보 영상 소재로 쓸 수 있도록 핵심 특징을 " f"{n}개의 짧은 불릿으로 요약하라.\n" f"- ★ 첫 번째 불릿은 반드시 가게 이름 '{sname}'(으)로 시작해, 이 가게가 어떤 곳/무슨 메뉴인지 " @@ -592,7 +683,9 @@ def summarize_reviews(client, store, reviews, model="gemini-2.5-flash", n=4): "익명 표현으로 바꾸지 마라 — 반드시 실제 상호를 쓴다.\n" "- 각 불릿은 한 문장. 메뉴/맛/분위기/서비스/특징 위주로.\n" "- 여러 리뷰에서 반복되는 공통점을 우선. 한두 명만 말한 지엽적 내용은 제외.\n" - "- 리뷰에 없는 사실을 지어내지 마라(과장·거짓 금지).\n\n" + "- 리뷰에 없는 사실을 지어내지 마라(과장·거짓 금지).\n" + "- 체험단·협찬·쿠폰/할인 홍보 같은 광고성 문구는 소재에서 배제하고, 가게 자체의 " + "사실(메뉴/맛/분위기/서비스)만 쓴다.\n\n" f"[리뷰]\n{joined}" ) try: