fix: 네이버 가게명 추출(og:title) + 뷰어 재생바 노출 + 생성 진행 UI 단순화

- naver.py: span.GHAhO 난독 클래스 변경으로 가게명이 'AI 브리핑'으로
  잘못 잡히던 버그 수정. og:title 메타 우선 추출(_store_name/_clean_store),
  섹션명·'네이버' 포함값 배제. 브리핑·사진 크롤 양쪽 적용.
- 뷰어: 하단 제목/설명 오버레이가 네이티브 재생바를 가리던 문제 →
  56px 위로 올리고 pointer-events:none 으로 탐색바 조작 가능하게.
- 생성 진행: 시나리오/가게/로그 등 제거, '영상 생성 중' + 진행바 + 퍼센트만.
  step(0~4)→% (완료 전 96% 상한, 완성 100%), shimmer 애니메이션.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main
jwkim 2026-07-08 16:56:06 +09:00
parent 39483e2bfb
commit 061d6b36b8
3 changed files with 62 additions and 40 deletions

View File

@ -55,6 +55,39 @@ def looks_like_naver_place(s: str) -> bool:
return bool(s) and ("naver.me/" in s or "place.naver.com" in s or "map.naver.com" in s)
def _clean_store(raw: str) -> str:
"""og:title/title 값에서 실제 가게명만 정리. 예: '골목냉면 : 네이버\\x1c''골목냉면'."""
if not raw:
return ""
s = re.sub(r"[\x00-\x1f]", "", raw) # 제어문자 제거
s = re.split(r"\s*[:\-|]\s*네이버", s)[0] # ' : 네이버', ' - 네이버 지도' 접미사 제거
s = re.sub(r"\s+", " ", s).strip()
return s.splitlines()[0].strip() if s else ""
async def _store_name(page) -> str:
"""가게명 추출. og:title 메타(가장 안정적) 우선 → title → 화면 요소.
섹션 헤딩('AI 브리핑' )이나 '네이버' 포함 값은 배제한다.
(네이버가 span.GHAhO 같은 난독 클래스를 자주 바꿔 화면 셀렉터만으론 불안정하므로 메타 우선.)"""
async def _meta():
return await page.locator("meta[property='og:title']").first.get_attribute("content", timeout=1500)
for getter in (_meta, page.title):
try:
name = _clean_store(await getter())
if name and name != "AI 브리핑" and "네이버" not in name:
return name
except Exception:
pass
for sel in ("span.GHAhO", "#_title span", "h2"): # 최후 폴백(섹션명 배제)
try:
t = (await page.locator(sel).first.inner_text(timeout=1200)).strip().splitlines()[0]
if t and t != "AI 브리핑" and not t.startswith("AI "):
return t
except Exception:
pass
return ""
async def _new_page(p, headful=False, viewport=None, device_scale_factor=None):
"""공통 브라우저 컨텍스트(스텔스 + 모바일 UA) → (browser, ctx, page)."""
browser = await p.chromium.launch(
@ -136,15 +169,7 @@ async def _fetch(url, headful=False):
await page.wait_for_timeout(600)
await page.wait_for_timeout(800)
store = ""
for sel in ["span.GHAhO", "h2", "title"]:
try:
t = await page.locator(sel).first.inner_text(timeout=1500)
if t and t.strip():
store = t.strip().splitlines()[0]
break
except Exception:
pass
store = await _store_name(page)
res = await page.evaluate(_EXTRACT_JS)
await browser.close()
@ -213,11 +238,7 @@ async def crawl_and_download(url, dst_dir, max_candidates=14, headful=False):
if not pid:
await browser.close()
raise RuntimeError("place id 를 못 찾음.")
try:
store = (await page.locator("span.GHAhO, h2").first.inner_text(timeout=1500)).strip()
store = store.splitlines()[0]
except Exception:
store = ""
store = await _store_name(page)
await page.goto(f"https://m.place.naver.com/{cat}/{pid}/photo",
wait_until="domcontentloaded", timeout=30000)

View File

@ -402,8 +402,7 @@ export function CreateSheet({ open, onClose, onCreated }: {
const [phase, setPhase] = useState<Phase>('pick')
const [scn, setScn] = useState<Scen | null>(null)
const [input, setInput] = useState('')
const [status, setStatus] = useState('')
const [log, setLog] = useState<string[]>([])
const [pct, setPct] = useState(0)
const [err, setErr] = useState('')
const [results, setResults] = useState<PlaceItem[]>([])
const [selected, setSelected] = useState<PlaceItem | null>(null)
@ -414,7 +413,7 @@ export function CreateSheet({ open, onClose, onCreated }: {
useEffect(() => {
if (open) {
setPhase('pick'); setScn(null); setInput('')
setStatus(''); setLog([]); setErr('')
setPct(0); setErr('')
setResults([]); setSelected(null); setSearching(false); setSearched(false)
}
return () => { if (timer.current) window.clearTimeout(timer.current) }
@ -440,14 +439,12 @@ export function CreateSheet({ open, onClose, onCreated }: {
async function poll(id: string) {
try {
const j = await getJob(id)
setLog(j.log || [])
if (j.status === 'done') { setStatus('완성!'); await onCreated(); onClose(); return }
if (j.status === 'done') { setPct(100); await onCreated(); onClose(); return }
if (j.status === 'error') { setErr(j.error || '생성 중 오류가 발생했어요.'); return }
const names = ['대본 생성', '스토리보드', '이미지·음성', '영상 합성']
setStatus(j.status === 'queued' ? '대기 중…' : `${names[Math.max(0, (j.step || 1) - 1)]}… (${j.step || 1}/4)`)
} catch {
setStatus('상태 확인 중…')
}
// step(0~4) → 퍼센트. 완료 전엔 96%로 상한(완성 시 100). 뒤로는 안 감.
const step = j.status === 'queued' ? 0 : (j.step || 1)
setPct((prev) => Math.max(prev, Math.min(96, step * 25)))
} catch { /* 일시적 네트워크 — 다음 폴에서 복구 */ }
timer.current = window.setTimeout(() => poll(id), 3000)
}
@ -455,7 +452,7 @@ export function CreateSheet({ open, onClose, onCreated }: {
if (!scn) return
// 선택된 가게가 있으면 그 place_url 로 정확히 크롤링. 아니면 붙여넣은 URL 그대로.
const payload = selected ? selected.place_url : input.trim()
setPhase('making'); setErr(''); setStatus('생성 준비 중…')
setPhase('making'); setErr(''); setPct(0)
try {
const res = await createJob({ scenario: scn, input: payload })
poll(res.id)
@ -543,14 +540,10 @@ export function CreateSheet({ open, onClose, onCreated }: {
<div className="making" style={sc(s.hex)}>
{!err ? (
<>
<div className="spin" />
<h4>{s.emoji} {s.name} </h4>
<p>{status}<br />AI . 2~4 .</p>
<div className="job">
<div>· &nbsp;<b>{s.name}</b></div>
<div>· &nbsp;<b>{selected ? selected.title : input}</b></div>
{selected && <div>· &nbsp;<b>{selected.address || selected.roadAddress}</b></div>}
{log.length > 0 && <div style={{ marginTop: 8, whiteSpace: 'pre-wrap' }}>{log.slice(-4).join('\n')}</div>}
<h4> </h4>
<div className="prog">
<div className="prog-bar"><div className="prog-fill" style={{ width: pct + '%' }} /></div>
<div className="prog-pct">{pct}%</div>
</div>
</>
) : (

View File

@ -255,13 +255,20 @@ ul{list-style:none}
.cta:disabled{background:var(--surface-2);color:var(--faint);box-shadow:none;cursor:not-allowed}
/* 생성중 상태 */
.making{padding:30px 20px;text-align:center}
.making .spin{width:52px;height:52px;border-radius:50%;border:4px solid var(--surface-2);border-top-color:var(--sc,#dc2743);margin:0 auto 18px;animation:sp 1s linear infinite}
.making{padding:44px 24px;text-align:center}
@keyframes sp{to{transform:rotate(360deg)}}
.making h4{font-size:17px;font-weight:800}
.making p{font-size:13px;color:var(--muted);margin-top:8px;line-height:1.6}
.making .job{margin-top:16px;background:var(--surface-2);border-radius:12px;padding:12px 14px;font-size:12.5px;text-align:left;color:var(--muted);line-height:1.7}
.making .job b{color:var(--fg)}
/* 생성 진행: 바 + 퍼센트만 */
.making .prog{margin-top:22px;display:flex;align-items:center;gap:12px}
.making .prog-bar{flex:1;height:10px;border-radius:99px;background:var(--surface-2);overflow:hidden;position:relative}
.making .prog-fill{height:100%;border-radius:99px;background:var(--sc,#dc2743);
transition:width .5s cubic-bezier(.22,1,.36,1);position:relative;overflow:hidden}
.making .prog-fill::after{content:"";position:absolute;inset:0;
background:linear-gradient(90deg,transparent,rgba(255,255,255,.45),transparent);
animation:progsh 1.15s linear infinite}
@keyframes progsh{from{transform:translateX(-100%)}to{transform:translateX(100%)}}
.making .prog-pct{font-size:14px;font-weight:800;color:var(--fg);min-width:40px;text-align:right;font-variant-numeric:tabular-nums}
/* ============================ 릴스형 뷰어 ============================ */
.viewer{position:absolute;inset:0;z-index:60;background:#000;display:none;flex-direction:column}
@ -284,9 +291,10 @@ ul{list-style:none}
.vrail{position:absolute;right:10px;bottom:120px;z-index:3;display:flex;flex-direction:column;gap:20px;align-items:center}
.vrail button{color:#fff;display:flex;flex-direction:column;align-items:center;gap:4px;font-size:11px;font-weight:600;font-variant-numeric:tabular-nums}
.vrail svg{width:29px;height:29px;fill:#fff}
.vbottom{position:absolute;left:0;right:64px;bottom:0;z-index:3;padding:16px;
padding-bottom:calc(20px + env(safe-area-inset-bottom));
background:linear-gradient(0deg,rgba(0,0,0,.6),transparent)}
/* 하단 제목/설명 — 네이티브 재생바(약 46px) 위로 올리고, 재생바 조작을 막지 않도록 클릭 통과 */
.vbottom{position:absolute;left:0;right:64px;bottom:0;z-index:3;padding:16px;pointer-events:none;
padding-bottom:calc(56px + env(safe-area-inset-bottom));
background:linear-gradient(0deg,rgba(0,0,0,.55),transparent)}
.vbottom .vt{color:#fff;font-weight:800;font-size:17px;line-height:1.3}
.vbottom .vn{color:rgba(255,255,255,.86);font-size:13px;margin-top:8px;line-height:1.5;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}