fix: 방문자 리뷰 본문 없을 때 블로그 리뷰로 보충

This commit is contained in:
김성경 2026-08-27 10:34:23 +09:00
parent 5de5c7507d
commit 43709bf7e0
6 changed files with 153 additions and 28 deletions

View File

@ -164,7 +164,11 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
except Exception as e: except Exception as e:
await session.rollback() await session.rollback()
# status_code < 500인 도메인 예외(계정 미연동 등)는 정상적인 비즈니스 흐름이므로 ERROR로 남기지 않음 # status_code < 500인 도메인 예외(계정 미연동 등)는 정상적인 비즈니스 흐름이므로 ERROR로 남기지 않음
if getattr(e, "status_code", 500) < 500: # FastShipError 계열(InsufficientCreditError 등)은 status_code 가 아니라 status 속성을 쓴다
status_code = getattr(e, "status_code", None)
if status_code is None:
status_code = getattr(e, "status", 500)
if status_code < 500:
logger.warning( logger.warning(
f"[get_session] ROLLBACK - client error: {type(e).__name__}: {e}, " f"[get_session] ROLLBACK - client error: {type(e).__name__}: {e}, "
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms" f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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: