벌크 자막/추출 개선, 검색 필터 무시, geocoding 필드 수정, 네이버맵 링크

- 벌크 자막: 브라우저 우선 + API fallback, 광고 즉시 skip, 대기 시간 단축
- 벌크 자막/추출: 선택한 영상만 처리 가능 (체크박스 선택 후 실행)
- 자막 실패 시 no_transcript 상태 마킹하여 재시도 방지
- 검색 시 필터 조건 무시 (채널/장르/가격/지역/영역 초기화)
- 리셋 버튼 클릭 시 검색어 입력란 초기화
- RestaurantMapper updateFields에 google_place_id, rating 등 geocoding 필드 추가
- SearchMapper에 tabling_url, catchtable_url, phone, website 필드 추가
- 식당 상세에 네이버 지도 링크 추가
- YouTubeService.getTranscriptApi public 전환

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
joungmin
2026-03-11 09:00:40 +09:00
parent cdee37e341
commit 0f985d52a9
13 changed files with 405 additions and 76 deletions

View File

@@ -393,35 +393,46 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
}
};
const startBulkStream = async (mode: "transcript" | "extract") => {
const startBulkStream = async (mode: "transcript" | "extract", ids?: string[]) => {
const isTranscript = mode === "transcript";
const setRunning = isTranscript ? setBulkTranscripting : setBulkExtracting;
const hasSelection = ids && ids.length > 0;
try {
const pending = isTranscript
? await api.getBulkTranscriptPending()
: await api.getBulkExtractPending();
if (pending.count === 0) {
alert(isTranscript ? "자막 없는 영상이 없습니다" : "추출 대기 중인 영상이 없습니다");
return;
let count: number;
if (hasSelection) {
count = ids.length;
} else {
const pending = isTranscript
? await api.getBulkTranscriptPending()
: await api.getBulkExtractPending();
if (pending.count === 0) {
alert(isTranscript ? "자막 없는 영상이 없습니다" : "추출 대기 중인 영상이 없습니다");
return;
}
count = pending.count;
}
const msg = isTranscript
? `자막 없는 영상 ${pending.count}개의 트랜스크립트를 수집하시겠습니까?\n(영상 당 5~15초 랜덤 딜레이)`
: `LLM 추출이 안된 영상 ${pending.count}개를 벌크 처리하시겠습니까?\n(영상 당 3~8초 랜덤 딜레이)`;
? `${hasSelection ? "선택한 " : "자막 없는 "}영상 ${count}개의 트랜스크립트를 수집하시겠습니까?`
: `${hasSelection ? "선택한 " : "LLM 추출이 안된 "}영상 ${count}개를 벌크 처리하시겠습니까?`;
if (!confirm(msg)) return;
setRunning(true);
setBulkProgress({
label: isTranscript ? "벌크 자막 수집" : "벌크 LLM 추출",
total: pending.count, current: 0, currentTitle: "", results: [],
total: count, current: 0, currentTitle: "", results: [],
});
const apiBase = process.env.NEXT_PUBLIC_API_URL || "";
const endpoint = isTranscript ? "/api/videos/bulk-transcript" : "/api/videos/bulk-extract";
const token = typeof window !== "undefined" ? localStorage.getItem("tasteby_token") : null;
const headers: Record<string, string> = {};
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
const resp = await fetch(`${apiBase}${endpoint}`, { method: "POST", headers });
const resp = await fetch(`${apiBase}${endpoint}`, {
method: "POST",
headers,
body: hasSelection ? JSON.stringify({ ids }) : undefined,
});
if (!resp.ok) {
alert(`벌크 요청 실패: ${resp.status} ${resp.statusText}`);
setRunning(false);
@@ -757,6 +768,20 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
)}
{isAdmin && selected.size > 0 && (
<>
<button
onClick={() => startBulkStream("transcript", Array.from(selected))}
disabled={bulkTranscripting || bulkExtracting}
className="bg-orange-500 text-white px-4 py-2 rounded text-sm hover:bg-orange-600 disabled:opacity-50"
>
({selected.size})
</button>
<button
onClick={() => startBulkStream("extract", Array.from(selected))}
disabled={bulkExtracting || bulkTranscripting}
className="bg-purple-500 text-white px-4 py-2 rounded text-sm hover:bg-purple-600 disabled:opacity-50"
>
LLM ({selected.size})
</button>
<button
onClick={handleBulkSkip}
className="bg-gray-500 text-white px-4 py-2 rounded text-sm hover:bg-gray-600"

View File

@@ -154,6 +154,8 @@ export default function Home() {
const [myReviews, setMyReviews] = useState<(Review & { restaurant_id: string; restaurant_name: string | null })[]>([]);
const [visits, setVisits] = useState<{ today: number; total: number } | null>(null);
const [userLoc, setUserLoc] = useState<{ lat: number; lng: number }>({ lat: 37.498, lng: 127.0276 });
const [isSearchResult, setIsSearchResult] = useState(false);
const [resetCount, setResetCount] = useState(0);
const geoApplied = useRef(false);
const regionTree = useMemo(() => buildRegionTree(restaurants), [restaurants]);
@@ -174,6 +176,13 @@ export default function Home() {
const filteredRestaurants = useMemo(() => {
const dist = (r: Restaurant) =>
(r.latitude - userLoc.lat) ** 2 + (r.longitude - userLoc.lng) ** 2;
if (isSearchResult) {
return [...restaurants].sort((a, b) => {
const da = dist(a), db = dist(b);
if (da !== db) return da - db;
return (b.rating || 0) - (a.rating || 0);
});
}
return restaurants.filter((r) => {
if (channelFilter && !(r.channels || []).includes(channelFilter)) return false;
if (cuisineFilter && !matchCuisineFilter(r.cuisine_type, cuisineFilter)) return false;
@@ -194,7 +203,7 @@ export default function Home() {
if (da !== db) return da - db;
return (b.rating || 0) - (a.rating || 0);
});
}, [restaurants, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]);
}, [restaurants, isSearchResult, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]);
// Set desktop default to map mode on mount + get user location
useEffect(() => {
@@ -217,6 +226,7 @@ export default function Home() {
// Load restaurants on mount and when channel filter changes
useEffect(() => {
setLoading(true);
setIsSearchResult(false);
api
.getRestaurants({ limit: 500, channel: channelFilter || undefined })
.then(setRestaurants)
@@ -253,6 +263,18 @@ export default function Home() {
setRestaurants(results);
setSelected(null);
setShowDetail(false);
setIsSearchResult(true);
// 검색 시 필터 초기화
setChannelFilter("");
setCuisineFilter("");
setPriceFilter("");
setCountryFilter("");
setCityFilter("");
setDistrictFilter("");
setBoundsFilterOn(false);
// 검색 결과에 맞게 지도 이동
const flyTo = computeFlyTo(results);
if (flyTo) setRegionFlyTo(flyTo);
} catch (e) {
console.error("Search failed:", e);
} finally {
@@ -350,6 +372,8 @@ export default function Home() {
setBoundsFilterOn(false);
setShowFavorites(false);
setShowMyReviews(false);
setIsSearchResult(false);
setResetCount((c) => c + 1);
api
.getRestaurants({ limit: 500 })
.then((data) => {
@@ -531,7 +555,7 @@ export default function Home() {
{/* Row 1: Search + dropdown filters */}
<div className="flex items-center gap-3">
<div className="w-96 shrink-0">
<SearchBar onSearch={handleSearch} isLoading={loading} />
<SearchBar key={resetCount} onSearch={handleSearch} isLoading={loading} />
</div>
<button
onClick={handleReset}
@@ -717,7 +741,7 @@ export default function Home() {
{/* ── Header row 2 (mobile only): search + toolbar ── */}
<div className={`md:hidden px-4 pb-3 space-y-2 ${mobileTab === "favorites" || mobileTab === "profile" ? "hidden" : ""}`}>
{/* Row 1: Search */}
<SearchBar onSearch={handleSearch} isLoading={loading} />
<SearchBar key={resetCount} onSearch={handleSearch} isLoading={loading} />
{/* Row 2: Toolbar */}
<div className="flex items-center gap-2">
<button

View File

@@ -123,7 +123,7 @@ export default function RestaurantDetail({
</p>
)}
{restaurant.google_place_id && (
<p>
<p className="flex gap-3">
<a
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(restaurant.name)}`}
target="_blank"
@@ -132,6 +132,14 @@ export default function RestaurantDetail({
>
Google Maps에서
</a>
<a
href={`https://map.naver.com/v5/search/${encodeURIComponent(restaurant.name)}`}
target="_blank"
rel="noopener noreferrer"
className="text-green-600 dark:text-green-400 hover:underline text-xs"
>
</a>
</p>
)}
</div>