fix: 방문자 리뷰 본문 없을 때 블로그 리뷰로 보충
This commit is contained in:
parent
5de5c7507d
commit
43709bf7e0
@ -164,7 +164,11 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
# 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(
|
||||
f"[get_session] ROLLBACK - client error: {type(e).__name__}: {e}, "
|
||||
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user