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
parent
39483e2bfb
commit
061d6b36b8
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>· 시나리오 <b>{s.name}</b></div>
|
||||
<div>· 가게 <b>{selected ? selected.title : input}</b></div>
|
||||
{selected && <div>· 위치 <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>
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in New Issue