fix: 방문자 리뷰 본문 없을 때 블로그 리뷰로 보충
This commit is contained in:
parent
b3385e7df2
commit
3cddd17eed
@ -145,14 +145,21 @@ def main():
|
|||||||
try:
|
try:
|
||||||
rstore, reviews = NV.fetch_reviews(args.link, max_reviews=30)
|
rstore, reviews = NV.fetch_reviews(args.link, max_reviews=30)
|
||||||
except Exception as e:
|
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:
|
if rstore and not brief_store:
|
||||||
brief_store = rstore
|
brief_store = rstore
|
||||||
bullets = NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews)
|
bullets = (NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews)
|
||||||
if not bullets:
|
if reviews else [])
|
||||||
sys.exit(f"[!] '{brief_store or '이 가게'}'는 AI 브리핑도 리뷰도 못 얻었어요. "
|
if bullets:
|
||||||
"키워드를 직접 주거나 다른 링크를 쓰세요.")
|
print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)")
|
||||||
print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)")
|
elif brief_store:
|
||||||
|
# 브리핑·방문자 리뷰·블로그 리뷰 모두 부실 — 에러로 끝내지 않고
|
||||||
|
# 그때까지 수집된 정보(가게명)만으로 생성을 계속한다.
|
||||||
|
print(f"■ 소재 부족 → 수집된 정보(가게명 '{brief_store}')만으로 생성 진행")
|
||||||
|
bullets = [brief_store]
|
||||||
|
# 가게명조차 없으면 bullets 가 비어 아래 product 가드에서 종료된다.
|
||||||
product = " ".join(bullets)
|
product = " ".join(bullets)
|
||||||
print(f"■ 가게: {brief_store or '?'} / 소재 {len(bullets)}줄 → 키워드로 사용")
|
print(f"■ 가게: {brief_store or '?'} / 소재 {len(bullets)}줄 → 키워드로 사용")
|
||||||
for b in bullets:
|
for b in bullets:
|
||||||
|
|||||||
@ -145,14 +145,21 @@ def main():
|
|||||||
try:
|
try:
|
||||||
rstore, reviews = NV.fetch_reviews(args.link, max_reviews=30)
|
rstore, reviews = NV.fetch_reviews(args.link, max_reviews=30)
|
||||||
except Exception as e:
|
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:
|
if rstore and not brief_store:
|
||||||
brief_store = rstore
|
brief_store = rstore
|
||||||
bullets = NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews)
|
bullets = (NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews)
|
||||||
if not bullets:
|
if reviews else [])
|
||||||
sys.exit(f"[!] '{brief_store or '이 가게'}'는 AI 브리핑도 리뷰도 못 얻었어요. "
|
if bullets:
|
||||||
"키워드를 직접 주거나 다른 링크를 쓰세요.")
|
print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)")
|
||||||
print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)")
|
elif brief_store:
|
||||||
|
# 브리핑·방문자 리뷰·블로그 리뷰 모두 부실 — 에러로 끝내지 않고
|
||||||
|
# 그때까지 수집된 정보(가게명)만으로 생성을 계속한다.
|
||||||
|
print(f"■ 소재 부족 → 수집된 정보(가게명 '{brief_store}')만으로 생성 진행")
|
||||||
|
bullets = [brief_store]
|
||||||
|
# 가게명조차 없으면 bullets 가 비어 아래 product 가드에서 종료된다.
|
||||||
product = " ".join(bullets)
|
product = " ".join(bullets)
|
||||||
print(f"■ 가게: {brief_store or '?'} / 소재 {len(bullets)}줄 → 키워드로 사용")
|
print(f"■ 가게: {brief_store or '?'} / 소재 {len(bullets)}줄 → 키워드로 사용")
|
||||||
for b in bullets:
|
for b in bullets:
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
키워드 → 병맛 오디세이 마케팅 쇼츠 (완전 자동, 원커맨드)
|
키워드 → 병맛 그리스 로마 신화 마케팅 쇼츠 (완전 자동, 원커맨드)
|
||||||
==========================================================
|
==========================================================
|
||||||
|
|
||||||
마케팅하고 싶은 대상을 '자유로운 키워드/문장'으로 주면:
|
마케팅하고 싶은 대상을 '자유로운 키워드/문장'으로 주면:
|
||||||
1) 랜덤으로 오디세우스 귀향 모험 '썰' 매칭
|
1) 랜덤으로 그리스 로마 신화 신·영웅 '썰' 매칭
|
||||||
2) Gemini 로 병맛 마케팅 나레이션 생성
|
2) Gemini 로 병맛 마케팅 나레이션 생성
|
||||||
3) 장면 분할(스토리보드)
|
3) 장면 분할(스토리보드)
|
||||||
4) 장면별 이미지 + 음성 생성
|
4) 장면별 이미지 + 음성 생성
|
||||||
@ -12,17 +12,17 @@
|
|||||||
6) 한 작업의 결과(나레이션·스토리보드·이미지·음성·mp4)는
|
6) 한 작업의 결과(나레이션·스토리보드·이미지·음성·mp4)는
|
||||||
모두 output/<작업폴더>/ 안에만 저장. 완성 후 중간 파일(assets)은 자동 삭제.
|
모두 output/<작업폴더>/ 안에만 저장. 완성 후 중간 파일(assets)은 자동 삭제.
|
||||||
|
|
||||||
선행: scraper.py 로 만든 data/odyssey_keywords_*.csv 가 있어야 함.
|
선행: scraper.py 로 만든 data/greek_keywords_*.csv 가 있어야 함.
|
||||||
API 키: GEMINI_API_KEY 환경변수 (또는 --api-key). 이미지 생성은 결제 활성화 필요.
|
API 키: GEMINI_API_KEY 환경변수 (또는 --api-key). 이미지 생성은 결제 활성화 필요.
|
||||||
|
|
||||||
★ 실행 (shorts_all 폴더 안에서, 키워드는 자유롭게):
|
★ 실행 (shorts_all 폴더 안에서, 키워드는 자유롭게):
|
||||||
python animation_odyssey\\main.py 감성 펜션 왕의휴식 객실 노천탕 평창 숲속 1박 12만원
|
python animation_greekroman\\main.py 감성 펜션 왕의휴식 객실 노천탕 평창 숲속 1박 12만원
|
||||||
python animation_odyssey\\main.py "강원도 평창 '왕의휴식' 감성 펜션, 객실 노천탕, 1박 12만원"
|
python animation_greekroman\\main.py "강원도 평창 '왕의휴식' 감성 펜션, 객실 노천탕, 1박 12만원"
|
||||||
python animation_odyssey\\main.py https://naver.me/xxxx
|
python animation_greekroman\\main.py https://naver.me/xxxx
|
||||||
|
|
||||||
옵션:
|
옵션:
|
||||||
--label 왕의휴식 결과 폴더 이름에 쓸 라벨 (기본: 키워드 앞부분 자동)
|
--label 왕의휴식 결과 폴더 이름에 쓸 라벨 (기본: 키워드 앞부분 자동)
|
||||||
--king 오디세우스 특정 인물 고정 (기본: 랜덤)
|
--king 제우스 특정 신/영웅 고정 (기본: 랜덤)
|
||||||
--scenes 12 장면 수=이미지 수 (기본 12)
|
--scenes 12 장면 수=이미지 수 (기본 12)
|
||||||
--seconds 45 목표 길이 초 (기본 45)
|
--seconds 45 목표 길이 초 (기본 45)
|
||||||
--voice Puck TTS 목소리
|
--voice Puck TTS 목소리
|
||||||
@ -51,7 +51,7 @@ import render as MS # animation 영상합성 엔진(이전 make_sho
|
|||||||
import naver as NV # 공유 네이버 크롤러/브리핑/사진 선별
|
import naver as NV # 공유 네이버 크롤러/브리핑/사진 선별
|
||||||
|
|
||||||
HERE = Path(__file__).parent
|
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"
|
TEXT_MODEL = "gemini-2.5-flash"
|
||||||
|
|
||||||
|
|
||||||
@ -62,7 +62,7 @@ def safe_name(s: str) -> str:
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
ap = argparse.ArgumentParser(
|
ap = argparse.ArgumentParser(
|
||||||
description="키워드 또는 네이버 링크 → 병맛 오디세이 쇼츠 (완전 자동)",
|
description="키워드 또는 네이버 링크 → 병맛 그리스 로마 신화 쇼츠 (완전 자동)",
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
epilog="예) python main.py 감성 펜션 왕의휴식 객실 노천탕 1박 12만원\n"
|
epilog="예) python main.py 감성 펜션 왕의휴식 객실 노천탕 1박 12만원\n"
|
||||||
" python main.py https://naver.me/xxxx (링크만 — AI브리핑 자동 키워드화)",
|
" python main.py https://naver.me/xxxx (링크만 — AI브리핑 자동 키워드화)",
|
||||||
@ -73,7 +73,7 @@ def main():
|
|||||||
# 결과 폴더 라벨
|
# 결과 폴더 라벨
|
||||||
ap.add_argument("--label", help="결과 폴더 라벨 (기본: 키워드 앞부분 자동)")
|
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("--scenes", type=int, default=12, help="장면 수=이미지 수 (기본 12)")
|
||||||
ap.add_argument("--seconds", type=int, default=45, help="목표 길이(초) (기본 45)")
|
ap.add_argument("--seconds", type=int, default=45, help="목표 길이(초) (기본 45)")
|
||||||
ap.add_argument("--voice", default=MS.TTS_VOICE, help=f"TTS 목소리 (기본 {MS.TTS_VOICE})")
|
ap.add_argument("--voice", default=MS.TTS_VOICE, help=f"TTS 목소리 (기본 {MS.TTS_VOICE})")
|
||||||
@ -145,14 +145,21 @@ def main():
|
|||||||
try:
|
try:
|
||||||
rstore, reviews = NV.fetch_reviews(args.link, max_reviews=30)
|
rstore, reviews = NV.fetch_reviews(args.link, max_reviews=30)
|
||||||
except Exception as e:
|
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:
|
if rstore and not brief_store:
|
||||||
brief_store = rstore
|
brief_store = rstore
|
||||||
bullets = NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews)
|
bullets = (NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews)
|
||||||
if not bullets:
|
if reviews else [])
|
||||||
sys.exit(f"[!] '{brief_store or '이 가게'}'는 AI 브리핑도 리뷰도 못 얻었어요. "
|
if bullets:
|
||||||
"키워드를 직접 주거나 다른 링크를 쓰세요.")
|
print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)")
|
||||||
print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)")
|
elif brief_store:
|
||||||
|
# 브리핑·방문자 리뷰·블로그 리뷰 모두 부실 — 에러로 끝내지 않고
|
||||||
|
# 그때까지 수집된 정보(가게명)만으로 생성을 계속한다.
|
||||||
|
print(f"■ 소재 부족 → 수집된 정보(가게명 '{brief_store}')만으로 생성 진행")
|
||||||
|
bullets = [brief_store]
|
||||||
|
# 가게명조차 없으면 bullets 가 비어 아래 product 가드에서 종료된다.
|
||||||
product = " ".join(bullets)
|
product = " ".join(bullets)
|
||||||
print(f"■ 가게: {brief_store or '?'} / 소재 {len(bullets)}줄 → 키워드로 사용")
|
print(f"■ 가게: {brief_store or '?'} / 소재 {len(bullets)}줄 → 키워드로 사용")
|
||||||
for b in bullets:
|
for b in bullets:
|
||||||
@ -207,7 +214,7 @@ def main():
|
|||||||
print(f"■ 마케팅 대상 : {product}")
|
print(f"■ 마케팅 대상 : {product}")
|
||||||
print(f"■ 매칭된 썰 : {seed['king']} / \"{seed['keyword']}\"\n")
|
print(f"■ 매칭된 썰 : {seed['king']} / \"{seed['keyword']}\"\n")
|
||||||
|
|
||||||
# 2) 병맛 나레이션 (+ SNS 업로드용 캡션)
|
# 2) 병맛 나레이션
|
||||||
print("[1/4] 나레이션 생성...")
|
print("[1/4] 나레이션 생성...")
|
||||||
store_name = brief_store or getattr(args, "store", None) or label
|
store_name = brief_store or getattr(args, "store", None) or label
|
||||||
narr = gen.generate(client, TEXT_MODEL, seed, product, args.seconds, store=store_name)
|
narr = gen.generate(client, TEXT_MODEL, seed, product, args.seconds, store=store_name)
|
||||||
|
|||||||
@ -145,14 +145,21 @@ def main():
|
|||||||
try:
|
try:
|
||||||
rstore, reviews = NV.fetch_reviews(args.link, max_reviews=30)
|
rstore, reviews = NV.fetch_reviews(args.link, max_reviews=30)
|
||||||
except Exception as e:
|
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:
|
if rstore and not brief_store:
|
||||||
brief_store = rstore
|
brief_store = rstore
|
||||||
bullets = NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews)
|
bullets = (NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews)
|
||||||
if not bullets:
|
if reviews else [])
|
||||||
sys.exit(f"[!] '{brief_store or '이 가게'}'는 AI 브리핑도 리뷰도 못 얻었어요. "
|
if bullets:
|
||||||
"키워드를 직접 주거나 다른 링크를 쓰세요.")
|
print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)")
|
||||||
print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)")
|
elif brief_store:
|
||||||
|
# 브리핑·방문자 리뷰·블로그 리뷰 모두 부실 — 에러로 끝내지 않고
|
||||||
|
# 그때까지 수집된 정보(가게명)만으로 생성을 계속한다.
|
||||||
|
print(f"■ 소재 부족 → 수집된 정보(가게명 '{brief_store}')만으로 생성 진행")
|
||||||
|
bullets = [brief_store]
|
||||||
|
# 가게명조차 없으면 bullets 가 비어 아래 product 가드에서 종료된다.
|
||||||
product = " ".join(bullets)
|
product = " ".join(bullets)
|
||||||
print(f"■ 가게: {brief_store or '?'} / 소재 {len(bullets)}줄 → 키워드로 사용")
|
print(f"■ 가게: {brief_store or '?'} / 소재 {len(bullets)}줄 → 키워드로 사용")
|
||||||
for b in bullets:
|
for b in bullets:
|
||||||
|
|||||||
@ -484,6 +484,80 @@ async def _fetch_reviews(url, max_reviews=30, headful=False):
|
|||||||
label="리뷰 ")
|
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 def _fetch_reviews_once(url, profile, max_reviews=30, headful=False):
|
||||||
"""방문자 리뷰 탭에서 '더보기'를 모두 펼쳐 리뷰 본문을 수집. (가게이름, [리뷰문장…]) 반환."""
|
"""방문자 리뷰 탭에서 '더보기'를 모두 펼쳐 리뷰 본문을 수집. (가게이름, [리뷰문장…]) 반환."""
|
||||||
async with async_playwright() as p:
|
async with async_playwright() as p:
|
||||||
@ -555,12 +629,28 @@ async def _fetch_reviews_once(url, profile, max_reviews=30, headful=False):
|
|||||||
if reviews: # 이 탭에서 뭔가 건졌으면 다른 탭은 시도 안 함
|
if reviews: # 이 탭에서 뭔가 건졌으면 다른 탭은 시도 안 함
|
||||||
break
|
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()
|
await browser.close()
|
||||||
return store, reviews[:max_reviews]
|
return store, reviews[:max_reviews]
|
||||||
|
|
||||||
|
|
||||||
def fetch_reviews(url, max_reviews=30, headful=False):
|
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))
|
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)
|
joined = "\n".join(f"- {r}" for r in reviews)
|
||||||
sname = store or "이 가게"
|
sname = store or "이 가게"
|
||||||
prompt = (
|
prompt = (
|
||||||
f"다음은 '{sname}'를 방문한 손님들이 남긴 실제 리뷰 모음이다.\n"
|
f"다음은 '{sname}'를 방문한 손님들이 남긴 실제 리뷰 모음이다"
|
||||||
|
f"(방문자 리뷰가 부족하면 블로그 후기 발췌가 섞여 있을 수 있다).\n"
|
||||||
f"이 리뷰들을 종합해서, 가게를 홍보 영상 소재로 쓸 수 있도록 핵심 특징을 "
|
f"이 리뷰들을 종합해서, 가게를 홍보 영상 소재로 쓸 수 있도록 핵심 특징을 "
|
||||||
f"{n}개의 짧은 불릿으로 요약하라.\n"
|
f"{n}개의 짧은 불릿으로 요약하라.\n"
|
||||||
f"- ★ 첫 번째 불릿은 반드시 가게 이름 '{sname}'(으)로 시작해, 이 가게가 어떤 곳/무슨 메뉴인지 "
|
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"
|
"- 리뷰에 없는 사실을 지어내지 마라(과장·거짓 금지).\n"
|
||||||
|
"- 체험단·협찬·쿠폰/할인 홍보 같은 광고성 문구는 소재에서 배제하고, 가게 자체의 "
|
||||||
|
"사실(메뉴/맛/분위기/서비스)만 쓴다.\n\n"
|
||||||
f"[리뷰]\n{joined}"
|
f"[리뷰]\n{joined}"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user