From cdee37e341254ea72b1517992a43721fc2c34ad2 Mon Sep 17 00:00:00 2001 From: joungmin Date: Wed, 11 Mar 2026 00:49:16 +0900 Subject: [PATCH] =?UTF-8?q?UI/UX=20=EA=B0=9C=EC=84=A0:=20=EB=AA=A8?= =?UTF-8?q?=EB=B0=94=EC=9D=BC=20=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98,=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=AA=A8=EB=8B=AC,?= =?UTF-8?q?=20=EC=A7=80=EB=8F=84=20=EA=B8=B0=EB=8A=A5,=20=EC=BA=90?= =?UTF-8?q?=EC=B9=98=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모바일 하단 네비게이션(홈/식당목록/내주변/찜/내정보) 추가 - 로그인 버튼을 모달 방식으로 변경 (소셜 로그인 확장 가능) - 내위치 기반 정렬 및 영역 필터, 지도 내위치 버튼 추가 - 채널 필터 시 해당 채널만 마커/범례 표시 - 캐치테이블 검색/연동 (단건/벌크), NONE 저장 패턴 - 벌크 트랜스크립트 SSE (Playwright 브라우저 재사용) - 테이블링/캐치테이블 버튼 UI 차별화 - Google Maps 링크 모바일 호환, 초기화 버튼, 검색 라벨 개선 Co-Authored-By: Claude Opus 4.6 --- .../com/tasteby/config/DataSourceConfig.java | 32 ++ .../tasteby/controller/ChannelController.java | 18 +- .../controller/RestaurantController.java | 363 ++++++++++++++++ .../tasteby/controller/VideoController.java | 18 + .../controller/VideoSseController.java | 69 ++- .../java/com/tasteby/domain/Restaurant.java | 2 + .../com/tasteby/mapper/RestaurantMapper.java | 4 + .../com/tasteby/service/OciGenAiService.java | 87 ++-- .../tasteby/service/RestaurantService.java | 8 + .../com/tasteby/service/YouTubeService.java | 278 ++++++------ .../src/main/resources/application.yml | 2 +- .../mybatis/mapper/RestaurantMapper.xml | 30 +- .../resources/mybatis/mapper/VideoMapper.xml | 5 +- .../main/resources/mybatis/mybatis-config.xml | 1 + frontend/src/app/admin/page.tsx | 225 ++++++++++ frontend/src/app/globals.css | 5 + frontend/src/app/page.tsx | 405 +++++++++++++----- frontend/src/components/LoginMenu.tsx | 60 +++ frontend/src/components/MapView.tsx | 65 +-- frontend/src/components/RestaurantDetail.tsx | 35 +- frontend/src/components/SearchBar.tsx | 2 +- frontend/src/lib/api.ts | 35 ++ nginx.conf | 41 ++ 23 files changed, 1465 insertions(+), 325 deletions(-) create mode 100644 frontend/src/components/LoginMenu.tsx diff --git a/backend-java/src/main/java/com/tasteby/config/DataSourceConfig.java b/backend-java/src/main/java/com/tasteby/config/DataSourceConfig.java index f874e80..fe672d1 100644 --- a/backend-java/src/main/java/com/tasteby/config/DataSourceConfig.java +++ b/backend-java/src/main/java/com/tasteby/config/DataSourceConfig.java @@ -1,16 +1,29 @@ package com.tasteby.config; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.EventListener; +import javax.sql.DataSource; import jakarta.annotation.PostConstruct; @Configuration public class DataSourceConfig { + private static final Logger log = LoggerFactory.getLogger(DataSourceConfig.class); + @Value("${app.oracle.wallet-path:}") private String walletPath; + private final DataSource dataSource; + + public DataSourceConfig(DataSource dataSource) { + this.dataSource = dataSource; + } + @PostConstruct public void configureWallet() { if (walletPath != null && !walletPath.isBlank()) { @@ -18,4 +31,23 @@ public class DataSourceConfig { System.setProperty("oracle.net.wallet_location", walletPath); } } + + @EventListener(ApplicationReadyEvent.class) + public void runMigrations() { + migrate("ALTER TABLE restaurants ADD (tabling_url VARCHAR2(500))"); + migrate("ALTER TABLE restaurants ADD (catchtable_url VARCHAR2(500))"); + } + + private void migrate(String sql) { + try (var conn = dataSource.getConnection(); var stmt = conn.createStatement()) { + stmt.execute(sql); + log.info("[MIGRATE] {}", sql); + } catch (Exception e) { + if (e.getMessage() != null && e.getMessage().contains("ORA-01430")) { + log.debug("[MIGRATE] already done: {}", sql); + } else { + log.warn("[MIGRATE] failed: {} - {}", sql, e.getMessage()); + } + } + } } diff --git a/backend-java/src/main/java/com/tasteby/controller/ChannelController.java b/backend-java/src/main/java/com/tasteby/controller/ChannelController.java index 7b08465..a0df250 100644 --- a/backend-java/src/main/java/com/tasteby/controller/ChannelController.java +++ b/backend-java/src/main/java/com/tasteby/controller/ChannelController.java @@ -6,6 +6,7 @@ import com.tasteby.domain.Channel; import com.tasteby.security.AuthUtil; import com.tasteby.service.CacheService; import com.tasteby.service.ChannelService; +import com.tasteby.service.YouTubeService; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -18,11 +19,14 @@ import java.util.Map; public class ChannelController { private final ChannelService channelService; + private final YouTubeService youtubeService; private final CacheService cache; private final ObjectMapper objectMapper; - public ChannelController(ChannelService channelService, CacheService cache, ObjectMapper objectMapper) { + public ChannelController(ChannelService channelService, YouTubeService youtubeService, + CacheService cache, ObjectMapper objectMapper) { this.channelService = channelService; + this.youtubeService = youtubeService; this.cache = cache; this.objectMapper = objectMapper; } @@ -60,6 +64,18 @@ public class ChannelController { } } + @PostMapping("/{channelId}/scan") + public Map scan(@PathVariable String channelId, + @RequestParam(defaultValue = "false") boolean full) { + AuthUtil.requireAdmin(); + var result = youtubeService.scanChannel(channelId, full); + if (result == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Channel not found"); + } + cache.flush(); + return result; + } + @DeleteMapping("/{channelId}") public Map delete(@PathVariable String channelId) { AuthUtil.requireAdmin(); diff --git a/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java b/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java index bb6a1d1..a790880 100644 --- a/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java +++ b/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java @@ -2,24 +2,38 @@ package com.tasteby.controller; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.microsoft.playwright.*; import com.tasteby.domain.Restaurant; import com.tasteby.security.AuthUtil; import com.tasteby.service.CacheService; import com.tasteby.service.RestaurantService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadLocalRandom; @RestController @RequestMapping("/api/restaurants") public class RestaurantController { + private static final Logger log = LoggerFactory.getLogger(RestaurantController.class); + private final RestaurantService restaurantService; private final CacheService cache; private final ObjectMapper objectMapper; + private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); public RestaurantController(RestaurantService restaurantService, CacheService cache, ObjectMapper objectMapper) { this.restaurantService = restaurantService; @@ -83,6 +97,232 @@ public class RestaurantController { return Map.of("ok", true); } + /** 단건 테이블링 URL 검색 */ + @GetMapping("/{id}/tabling-search") + public List> tablingSearch(@PathVariable String id) { + AuthUtil.requireAdmin(); + var r = restaurantService.findById(id); + if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + + try (Playwright pw = Playwright.create()) { + try (Browser browser = launchBrowser(pw)) { + BrowserContext ctx = newContext(browser); + Page page = newPage(ctx); + return searchTabling(page, r.getName()); + } + } catch (Exception e) { + log.error("[TABLING] Search failed for '{}': {}", r.getName(), e.getMessage()); + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage()); + } + } + + /** 테이블링 미연결 식당 목록 */ + @GetMapping("/tabling-pending") + public Map tablingPending() { + AuthUtil.requireAdmin(); + var list = restaurantService.findWithoutTabling(); + var summary = list.stream() + .map(r -> Map.of("id", (Object) r.getId(), "name", (Object) r.getName())) + .toList(); + return Map.of("count", list.size(), "restaurants", summary); + } + + /** 벌크 테이블링 검색 (SSE) */ + @PostMapping("/bulk-tabling") + public SseEmitter bulkTabling() { + AuthUtil.requireAdmin(); + SseEmitter emitter = new SseEmitter(600_000L); + + executor.execute(() -> { + try { + var restaurants = restaurantService.findWithoutTabling(); + int total = restaurants.size(); + emit(emitter, Map.of("type", "start", "total", total)); + + if (total == 0) { + emit(emitter, Map.of("type", "complete", "total", 0, "linked", 0, "notFound", 0)); + emitter.complete(); + return; + } + + int linked = 0; + int notFound = 0; + + try (Playwright pw = Playwright.create()) { + try (Browser browser = launchBrowser(pw)) { + BrowserContext ctx = newContext(browser); + Page page = newPage(ctx); + + for (int i = 0; i < total; i++) { + var r = restaurants.get(i); + emit(emitter, Map.of("type", "processing", "current", i + 1, + "total", total, "name", r.getName())); + + try { + var results = searchTabling(page, r.getName()); + if (!results.isEmpty()) { + String url = String.valueOf(results.get(0).get("url")); + String title = String.valueOf(results.get(0).get("title")); + restaurantService.update(r.getId(), Map.of("tabling_url", url)); + linked++; + emit(emitter, Map.of("type", "done", "current", i + 1, + "name", r.getName(), "url", url, "title", title)); + } else { + restaurantService.update(r.getId(), Map.of("tabling_url", "NONE")); + notFound++; + emit(emitter, Map.of("type", "notfound", "current", i + 1, + "name", r.getName())); + } + } catch (Exception e) { + notFound++; + emit(emitter, Map.of("type", "error", "current", i + 1, + "name", r.getName(), "message", e.getMessage())); + } + + // Google 봇 판정 방지 랜덤 딜레이 (5~15초) + int delay = ThreadLocalRandom.current().nextInt(5000, 15001); + log.info("[TABLING] Waiting {}ms before next search...", delay); + page.waitForTimeout(delay); + } + } + } + + cache.flush(); + emit(emitter, Map.of("type", "complete", "total", total, "linked", linked, "notFound", notFound)); + emitter.complete(); + } catch (Exception e) { + log.error("[TABLING] Bulk search error", e); + emitter.completeWithError(e); + } + }); + + return emitter; + } + + /** 테이블링 URL 저장 */ + @PutMapping("/{id}/tabling-url") + public Map setTablingUrl(@PathVariable String id, @RequestBody Map body) { + AuthUtil.requireAdmin(); + var r = restaurantService.findById(id); + if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + String url = body.get("tabling_url"); + restaurantService.update(id, Map.of("tabling_url", url != null ? url : "")); + cache.flush(); + return Map.of("ok", true); + } + + /** 단건 캐치테이블 URL 검색 */ + @GetMapping("/{id}/catchtable-search") + public List> catchtableSearch(@PathVariable String id) { + AuthUtil.requireAdmin(); + var r = restaurantService.findById(id); + if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + try (Playwright pw = Playwright.create()) { + try (Browser browser = launchBrowser(pw)) { + BrowserContext ctx = newContext(browser); + Page page = newPage(ctx); + return searchCatchtable(page, r.getName()); + } + } catch (Exception e) { + log.error("[CATCHTABLE] Search failed for '{}': {}", r.getName(), e.getMessage()); + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage()); + } + } + + /** 캐치테이블 미연결 식당 목록 */ + @GetMapping("/catchtable-pending") + public Map catchtablePending() { + AuthUtil.requireAdmin(); + var list = restaurantService.findWithoutCatchtable(); + var summary = list.stream() + .map(r -> Map.of("id", (Object) r.getId(), "name", (Object) r.getName())) + .toList(); + return Map.of("count", list.size(), "restaurants", summary); + } + + /** 벌크 캐치테이블 검색 (SSE) */ + @PostMapping("/bulk-catchtable") + public SseEmitter bulkCatchtable() { + AuthUtil.requireAdmin(); + SseEmitter emitter = new SseEmitter(600_000L); + + executor.execute(() -> { + try { + var restaurants = restaurantService.findWithoutCatchtable(); + int total = restaurants.size(); + emit(emitter, Map.of("type", "start", "total", total)); + + if (total == 0) { + emit(emitter, Map.of("type", "complete", "total", 0, "linked", 0, "notFound", 0)); + emitter.complete(); + return; + } + + int linked = 0; + int notFound = 0; + + try (Playwright pw = Playwright.create()) { + try (Browser browser = launchBrowser(pw)) { + BrowserContext ctx = newContext(browser); + Page page = newPage(ctx); + + for (int i = 0; i < total; i++) { + var r = restaurants.get(i); + emit(emitter, Map.of("type", "processing", "current", i + 1, + "total", total, "name", r.getName())); + + try { + var results = searchCatchtable(page, r.getName()); + if (!results.isEmpty()) { + String url = String.valueOf(results.get(0).get("url")); + String title = String.valueOf(results.get(0).get("title")); + restaurantService.update(r.getId(), Map.of("catchtable_url", url)); + linked++; + emit(emitter, Map.of("type", "done", "current", i + 1, + "name", r.getName(), "url", url, "title", title)); + } else { + restaurantService.update(r.getId(), Map.of("catchtable_url", "NONE")); + notFound++; + emit(emitter, Map.of("type", "notfound", "current", i + 1, + "name", r.getName())); + } + } catch (Exception e) { + notFound++; + emit(emitter, Map.of("type", "error", "current", i + 1, + "name", r.getName(), "message", e.getMessage())); + } + + int delay = ThreadLocalRandom.current().nextInt(5000, 15001); + log.info("[CATCHTABLE] Waiting {}ms before next search...", delay); + page.waitForTimeout(delay); + } + } + } + + cache.flush(); + emit(emitter, Map.of("type", "complete", "total", total, "linked", linked, "notFound", notFound)); + emitter.complete(); + } catch (Exception e) { + log.error("[CATCHTABLE] Bulk search error", e); + emitter.completeWithError(e); + } + }); + + return emitter; + } + + /** 캐치테이블 URL 저장 */ + @PutMapping("/{id}/catchtable-url") + public Map setCatchtableUrl(@PathVariable String id, @RequestBody Map body) { + AuthUtil.requireAdmin(); + var r = restaurantService.findById(id); + if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + String url = body.get("catchtable_url"); + restaurantService.update(id, Map.of("catchtable_url", url != null ? url : "")); + cache.flush(); + return Map.of("ok", true); + } + @GetMapping("/{id}/videos") public List> videos(@PathVariable String id) { String key = cache.makeKey("restaurant_videos", id); @@ -98,4 +338,127 @@ public class RestaurantController { cache.set(key, result); return result; } + + // ─── Playwright helpers ────────────────────────────────────────────── + + private Browser launchBrowser(Playwright pw) { + return pw.chromium().launch(new BrowserType.LaunchOptions() + .setHeadless(false) + .setArgs(List.of("--disable-blink-features=AutomationControlled"))); + } + + private BrowserContext newContext(Browser browser) { + return browser.newContext(new Browser.NewContextOptions() + .setLocale("ko-KR").setViewportSize(1280, 900)); + } + + private Page newPage(BrowserContext ctx) { + Page page = ctx.newPage(); + page.addInitScript("Object.defineProperty(navigator, 'webdriver', {get: () => false})"); + return page; + } + + @SuppressWarnings("unchecked") + private List> searchTabling(Page page, String restaurantName) { + String query = "site:tabling.co.kr " + restaurantName; + log.info("[TABLING] Searching: {}", query); + + String searchUrl = "https://www.google.com/search?q=" + + URLEncoder.encode(query, StandardCharsets.UTF_8); + page.navigate(searchUrl); + page.waitForTimeout(3000); + + Object linksObj = page.evaluate(""" + () => { + const results = []; + const links = document.querySelectorAll('a[href]'); + for (const a of links) { + const href = a.href; + if (href.includes('tabling.co.kr/restaurant/') || href.includes('tabling.co.kr/place/')) { + const title = a.closest('[data-header-feature]')?.querySelector('h3')?.textContent + || a.querySelector('h3')?.textContent + || a.textContent?.trim()?.substring(0, 80) + || ''; + results.push({ title, url: href }); + } + } + const seen = new Set(); + return results.filter(r => { + if (seen.has(r.url)) return false; + seen.add(r.url); + return true; + }).slice(0, 5); + } + """); + + List> results = new ArrayList<>(); + if (linksObj instanceof List list) { + for (var item : list) { + if (item instanceof Map map) { + results.add(Map.of( + "title", String.valueOf(map.get("title")), + "url", String.valueOf(map.get("url")) + )); + } + } + } + log.info("[TABLING] Found {} results for '{}'", results.size(), restaurantName); + return results; + } + + @SuppressWarnings("unchecked") + private List> searchCatchtable(Page page, String restaurantName) { + String query = "site:app.catchtable.co.kr " + restaurantName; + log.info("[CATCHTABLE] Searching: {}", query); + + String searchUrl = "https://www.google.com/search?q=" + + URLEncoder.encode(query, StandardCharsets.UTF_8); + page.navigate(searchUrl); + page.waitForTimeout(3000); + + Object linksObj = page.evaluate(""" + () => { + const results = []; + const links = document.querySelectorAll('a[href]'); + for (const a of links) { + const href = a.href; + if (href.includes('catchtable.co.kr/') && (href.includes('/dining/') || href.includes('/shop/'))) { + const title = a.closest('[data-header-feature]')?.querySelector('h3')?.textContent + || a.querySelector('h3')?.textContent + || a.textContent?.trim()?.substring(0, 80) + || ''; + results.push({ title, url: href }); + } + } + const seen = new Set(); + return results.filter(r => { + if (seen.has(r.url)) return false; + seen.add(r.url); + return true; + }).slice(0, 5); + } + """); + + List> results = new ArrayList<>(); + if (linksObj instanceof List list) { + for (var item : list) { + if (item instanceof Map map) { + results.add(Map.of( + "title", String.valueOf(map.get("title")), + "url", String.valueOf(map.get("url")) + )); + } + } + } + log.info("[CATCHTABLE] Found {} results for '{}'", results.size(), restaurantName); + return results; + } + + private void emit(SseEmitter emitter, Map data) { + try { + emitter.send(SseEmitter.event().data(objectMapper.writeValueAsString(data))); + } catch (Exception e) { + log.debug("SSE emit error: {}", e.getMessage()); + } + } } diff --git a/backend-java/src/main/java/com/tasteby/controller/VideoController.java b/backend-java/src/main/java/com/tasteby/controller/VideoController.java index 0c69ed0..92c3886 100644 --- a/backend-java/src/main/java/com/tasteby/controller/VideoController.java +++ b/backend-java/src/main/java/com/tasteby/controller/VideoController.java @@ -103,6 +103,24 @@ public class VideoController { return Map.of("ok", true, "length", result.text().length(), "source", result.source()); } + /** 클라이언트(브라우저)에서 가져온 트랜스크립트를 저장 */ + @PostMapping("/{id}/upload-transcript") + public Map uploadTranscript(@PathVariable String id, + @RequestBody Map body) { + AuthUtil.requireAdmin(); + var video = videoService.findDetail(id); + if (video == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Video not found"); + + String text = body.get("text"); + if (text == null || text.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "text is required"); + } + + videoService.updateTranscript(id, text); + String source = body.getOrDefault("source", "browser"); + return Map.of("ok", true, "length", text.length(), "source", source); + } + @GetMapping("/extract/prompt") public Map getExtractPrompt() { return Map.of("prompt", extractorService.getPrompt()); diff --git a/backend-java/src/main/java/com/tasteby/controller/VideoSseController.java b/backend-java/src/main/java/com/tasteby/controller/VideoSseController.java index e3c4417..afa99ff 100644 --- a/backend-java/src/main/java/com/tasteby/controller/VideoSseController.java +++ b/backend-java/src/main/java/com/tasteby/controller/VideoSseController.java @@ -13,6 +13,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.util.*; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.ThreadLocalRandom; /** * SSE streaming endpoints for bulk operations. @@ -26,6 +27,7 @@ public class VideoSseController { private final VideoService videoService; private final RestaurantService restaurantService; private final PipelineService pipelineService; + private final YouTubeService youTubeService; private final OciGenAiService genAi; private final CacheService cache; private final ObjectMapper mapper; @@ -34,12 +36,14 @@ public class VideoSseController { public VideoSseController(VideoService videoService, RestaurantService restaurantService, PipelineService pipelineService, + YouTubeService youTubeService, OciGenAiService genAi, CacheService cache, ObjectMapper mapper) { this.videoService = videoService; this.restaurantService = restaurantService; this.pipelineService = pipelineService; + this.youTubeService = youTubeService; this.genAi = genAi; this.cache = cache; this.mapper = mapper; @@ -48,13 +52,70 @@ public class VideoSseController { @PostMapping("/bulk-transcript") public SseEmitter bulkTranscript() { AuthUtil.requireAdmin(); - SseEmitter emitter = new SseEmitter(600_000L); // 10 min timeout + SseEmitter emitter = new SseEmitter(1_800_000L); // 30 min timeout executor.execute(() -> { try { - // TODO: Implement when transcript extraction is available in Java - emit(emitter, Map.of("type", "start", "total", 0)); - emit(emitter, Map.of("type", "complete", "total", 0, "success", 0)); + var videos = videoService.findVideosWithoutTranscript(); + int total = videos.size(); + emit(emitter, Map.of("type", "start", "total", total)); + + if (total == 0) { + emit(emitter, Map.of("type", "complete", "total", 0, "success", 0, "failed", 0)); + emitter.complete(); + return; + } + + int success = 0; + int failed = 0; + + try (var session = youTubeService.createBrowserSession()) { + for (int i = 0; i < total; i++) { + var v = videos.get(i); + String videoId = (String) v.get("video_id"); + String title = (String) v.get("title"); + String id = (String) v.get("id"); + + emit(emitter, Map.of("type", "processing", "index", i, "title", title)); + + try { + // Playwright browser first (reuse page) + var result = youTubeService.getTranscriptWithPage(session.page(), videoId); + + // Fallback: thoroldvix API + if (result == null) { + log.warn("[BULK-TRANSCRIPT] Browser failed for {}, trying API", videoId); + result = youTubeService.getTranscript(videoId, "auto"); + } + + if (result != null) { + videoService.updateTranscript(id, result.text()); + success++; + emit(emitter, Map.of("type", "done", "index", i, + "title", title, "source", result.source(), + "length", result.text().length())); + } else { + failed++; + emit(emitter, Map.of("type", "error", "index", i, + "title", title, "message", "자막을 찾을 수 없음")); + } + } catch (Exception e) { + failed++; + log.error("[BULK-TRANSCRIPT] Error for {}: {}", videoId, e.getMessage()); + emit(emitter, Map.of("type", "error", "index", i, + "title", title, "message", e.getMessage())); + } + + // 봇 판정 방지 랜덤 딜레이 (5~15초) + if (i < total - 1) { + int delay = ThreadLocalRandom.current().nextInt(5000, 15001); + log.info("[BULK-TRANSCRIPT] Waiting {}ms before next...", delay); + session.page().waitForTimeout(delay); + } + } + } + + emit(emitter, Map.of("type", "complete", "total", total, "success", success, "failed", failed)); emitter.complete(); } catch (Exception e) { log.error("Bulk transcript error", e); diff --git a/backend-java/src/main/java/com/tasteby/domain/Restaurant.java b/backend-java/src/main/java/com/tasteby/domain/Restaurant.java index 5811c67..188933d 100644 --- a/backend-java/src/main/java/com/tasteby/domain/Restaurant.java +++ b/backend-java/src/main/java/com/tasteby/domain/Restaurant.java @@ -24,6 +24,8 @@ public class Restaurant { private String phone; private String website; private String googlePlaceId; + private String tablingUrl; + private String catchtableUrl; private String businessStatus; private Double rating; private Integer ratingCount; diff --git a/backend-java/src/main/java/com/tasteby/mapper/RestaurantMapper.java b/backend-java/src/main/java/com/tasteby/mapper/RestaurantMapper.java index 9b2010f..d38bc9d 100644 --- a/backend-java/src/main/java/com/tasteby/mapper/RestaurantMapper.java +++ b/backend-java/src/main/java/com/tasteby/mapper/RestaurantMapper.java @@ -55,6 +55,10 @@ public interface RestaurantMapper { void updateFoodsMentioned(@Param("id") String id, @Param("foods") String foods); + List findWithoutTabling(); + + List findWithoutCatchtable(); + List> findForRemapCuisine(); List> findForRemapFoods(); diff --git a/backend-java/src/main/java/com/tasteby/service/OciGenAiService.java b/backend-java/src/main/java/com/tasteby/service/OciGenAiService.java index 38f6a42..c23aa5e 100644 --- a/backend-java/src/main/java/com/tasteby/service/OciGenAiService.java +++ b/backend-java/src/main/java/com/tasteby/service/OciGenAiService.java @@ -16,6 +16,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -45,6 +46,8 @@ public class OciGenAiService { private final ObjectMapper mapper; private ConfigFileAuthenticationDetailsProvider authProvider; + private GenerativeAiInferenceClient chatClient; + private GenerativeAiInferenceClient embedClient; public OciGenAiService(ObjectMapper mapper) { this.mapper = mapper; @@ -55,45 +58,50 @@ public class OciGenAiService { try { ConfigFileReader.ConfigFile configFile = ConfigFileReader.parseDefault(); authProvider = new ConfigFileAuthenticationDetailsProvider(configFile); - log.info("OCI GenAI auth configured"); + chatClient = GenerativeAiInferenceClient.builder() + .endpoint(chatEndpoint).build(authProvider); + embedClient = GenerativeAiInferenceClient.builder() + .endpoint(embedEndpoint).build(authProvider); + log.info("OCI GenAI auth configured (clients initialized)"); } catch (Exception e) { log.warn("OCI config not found, GenAI features disabled: {}", e.getMessage()); } } + @PreDestroy + public void destroy() { + if (chatClient != null) chatClient.close(); + if (embedClient != null) embedClient.close(); + } + /** * Call OCI GenAI LLM (Chat). */ public String chat(String prompt, int maxTokens) { - if (authProvider == null) throw new IllegalStateException("OCI GenAI not configured"); + if (chatClient == null) throw new IllegalStateException("OCI GenAI not configured"); - try (var client = GenerativeAiInferenceClient.builder() - .endpoint(chatEndpoint) - .build(authProvider)) { + var textContent = TextContent.builder().text(prompt).build(); + var userMessage = UserMessage.builder().content(List.of(textContent)).build(); - var textContent = TextContent.builder().text(prompt).build(); - var userMessage = UserMessage.builder().content(List.of(textContent)).build(); + var chatRequest = GenericChatRequest.builder() + .messages(List.of(userMessage)) + .maxTokens(maxTokens) + .temperature(0.0) + .build(); - var chatRequest = GenericChatRequest.builder() - .messages(List.of(userMessage)) - .maxTokens(maxTokens) - .temperature(0.0) - .build(); + var chatDetails = ChatDetails.builder() + .compartmentId(compartmentId) + .servingMode(OnDemandServingMode.builder().modelId(chatModelId).build()) + .chatRequest(chatRequest) + .build(); - var chatDetails = ChatDetails.builder() - .compartmentId(compartmentId) - .servingMode(OnDemandServingMode.builder().modelId(chatModelId).build()) - .chatRequest(chatRequest) - .build(); + ChatResponse response = chatClient.chat( + ChatRequest.builder().chatDetails(chatDetails).build()); - ChatResponse response = client.chat( - ChatRequest.builder().chatDetails(chatDetails).build()); - - var chatResult = (GenericChatResponse) response.getChatResult().getChatResponse(); - var choice = chatResult.getChoices().get(0); - var content = ((TextContent) choice.getMessage().getContent().get(0)).getText(); - return content.trim(); - } + var chatResult = (GenericChatResponse) response.getChatResult().getChatResponse(); + var choice = chatResult.getChoices().get(0); + var content = ((TextContent) choice.getMessage().getContent().get(0)).getText(); + return content.trim(); } /** @@ -111,25 +119,22 @@ public class OciGenAiService { } private List> embedBatch(List texts) { - try (var client = GenerativeAiInferenceClient.builder() - .endpoint(embedEndpoint) - .build(authProvider)) { + if (embedClient == null) throw new IllegalStateException("OCI GenAI not configured"); - var embedDetails = EmbedTextDetails.builder() - .inputs(texts) - .servingMode(OnDemandServingMode.builder().modelId(embedModelId).build()) - .compartmentId(compartmentId) - .inputType(EmbedTextDetails.InputType.SearchDocument) - .build(); + var embedDetails = EmbedTextDetails.builder() + .inputs(texts) + .servingMode(OnDemandServingMode.builder().modelId(embedModelId).build()) + .compartmentId(compartmentId) + .inputType(EmbedTextDetails.InputType.SearchDocument) + .build(); - EmbedTextResponse response = client.embedText( - EmbedTextRequest.builder().embedTextDetails(embedDetails).build()); + EmbedTextResponse response = embedClient.embedText( + EmbedTextRequest.builder().embedTextDetails(embedDetails).build()); - return response.getEmbedTextResult().getEmbeddings() - .stream() - .map(emb -> emb.stream().map(Number::doubleValue).toList()) - .toList(); - } + return response.getEmbedTextResult().getEmbeddings() + .stream() + .map(emb -> emb.stream().map(Number::doubleValue).toList()) + .toList(); } /** diff --git a/backend-java/src/main/java/com/tasteby/service/RestaurantService.java b/backend-java/src/main/java/com/tasteby/service/RestaurantService.java index 7418b7f..3a5603a 100644 --- a/backend-java/src/main/java/com/tasteby/service/RestaurantService.java +++ b/backend-java/src/main/java/com/tasteby/service/RestaurantService.java @@ -26,6 +26,14 @@ public class RestaurantService { return restaurants; } + public List findWithoutTabling() { + return mapper.findWithoutTabling(); + } + + public List findWithoutCatchtable() { + return mapper.findWithoutCatchtable(); + } + public Restaurant findById(String id) { Restaurant restaurant = mapper.findById(id); if (restaurant == null) return null; diff --git a/backend-java/src/main/java/com/tasteby/service/YouTubeService.java b/backend-java/src/main/java/com/tasteby/service/YouTubeService.java index fa31664..f96faf7 100644 --- a/backend-java/src/main/java/com/tasteby/service/YouTubeService.java +++ b/backend-java/src/main/java/com/tasteby/service/YouTubeService.java @@ -208,13 +208,13 @@ public class YouTubeService { public TranscriptResult getTranscript(String videoId, String mode) { if (mode == null) mode = "auto"; - // 1) Fast path: youtube-transcript-api - TranscriptResult apiResult = getTranscriptApi(videoId, mode); - if (apiResult != null) return apiResult; + // 1) Playwright headed browser (봇 판정 회피) + TranscriptResult browserResult = getTranscriptBrowser(videoId); + if (browserResult != null) return browserResult; - // 2) Fallback: Playwright browser - log.warn("API failed for {}, trying Playwright browser", videoId); - return getTranscriptBrowser(videoId); + // 2) Fallback: youtube-transcript-api + log.warn("Browser failed for {}, trying API", videoId); + return getTranscriptApi(videoId, mode); } private TranscriptResult getTranscriptApi(String videoId, String mode) { @@ -262,151 +262,173 @@ public class YouTubeService { } } - // ─── Playwright browser fallback ─────────────────────────────────────────── + // ─── Playwright browser ─────────────────────────────────────────────────── + + /** + * Fetch transcript using an existing Playwright Page (for bulk reuse). + */ + @SuppressWarnings("unchecked") + public TranscriptResult getTranscriptWithPage(Page page, String videoId) { + return fetchTranscriptFromPage(page, videoId); + } + + /** + * Create a Playwright browser + context + page for transcript fetching. + * Caller must close the returned resources (Playwright, Browser). + */ + public record BrowserSession(Playwright playwright, Browser browser, Page page) implements AutoCloseable { + @Override + public void close() { + try { browser.close(); } catch (Exception ignored) {} + try { playwright.close(); } catch (Exception ignored) {} + } + } + + public BrowserSession createBrowserSession() { + Playwright pw = Playwright.create(); + Browser browser = pw.chromium().launch(new BrowserType.LaunchOptions() + .setHeadless(false) + .setArgs(List.of("--disable-blink-features=AutomationControlled"))); + BrowserContext ctx = browser.newContext(new Browser.NewContextOptions() + .setLocale("ko-KR") + .setViewportSize(1280, 900)); + loadCookies(ctx); + Page page = ctx.newPage(); + page.addInitScript("Object.defineProperty(navigator, 'webdriver', {get: () => false})"); + return new BrowserSession(pw, browser, page); + } @SuppressWarnings("unchecked") private TranscriptResult getTranscriptBrowser(String videoId) { - try (Playwright pw = Playwright.create()) { - BrowserType.LaunchOptions launchOpts = new BrowserType.LaunchOptions() - .setHeadless(false) - .setArgs(List.of("--disable-blink-features=AutomationControlled")); + try (BrowserSession session = createBrowserSession()) { + return fetchTranscriptFromPage(session.page(), videoId); + } catch (Exception e) { + log.error("[TRANSCRIPT] Playwright failed for {}: {}", videoId, e.getMessage()); + return null; + } + } - try (Browser browser = pw.chromium().launch(launchOpts)) { - Browser.NewContextOptions ctxOpts = new Browser.NewContextOptions() - .setLocale("ko-KR") - .setViewportSize(1280, 900); + @SuppressWarnings("unchecked") + private TranscriptResult fetchTranscriptFromPage(Page page, String videoId) { + try { + log.info("[TRANSCRIPT] Opening YouTube page for {}", videoId); + page.navigate("https://www.youtube.com/watch?v=" + videoId, + new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED).setTimeout(30000)); + page.waitForTimeout(5000); - BrowserContext ctx = browser.newContext(ctxOpts); + skipAds(page); - // Load YouTube cookies if available - loadCookies(ctx); + page.waitForTimeout(2000); + log.info("[TRANSCRIPT] Page loaded, looking for transcript button"); - Page page = ctx.newPage(); - - // Hide webdriver flag to reduce bot detection - page.addInitScript("Object.defineProperty(navigator, 'webdriver', {get: () => false})"); - - log.info("[TRANSCRIPT] Opening YouTube page for {}", videoId); - page.navigate("https://www.youtube.com/watch?v=" + videoId, - new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED).setTimeout(30000)); - page.waitForTimeout(5000); - - // Skip ads if present - skipAds(page); - - page.waitForTimeout(2000); - log.info("[TRANSCRIPT] Page loaded, looking for transcript button"); - - // Click "더보기" (expand description) - page.evaluate(""" - () => { - const moreBtn = document.querySelector('tp-yt-paper-button#expand'); - if (moreBtn) moreBtn.click(); - } - """); - page.waitForTimeout(2000); - - // Click transcript button - Object clicked = page.evaluate(""" - () => { - // Method 1: aria-label - for (const label of ['스크립트 표시', 'Show transcript']) { - const btns = document.querySelectorAll(`button[aria-label="${label}"]`); - for (const b of btns) { b.click(); return 'aria-label: ' + label; } - } - // Method 2: text content - const allBtns = document.querySelectorAll('button'); - for (const b of allBtns) { - const text = b.textContent.trim(); - if (text === '스크립트 표시' || text === 'Show transcript') { - b.click(); - return 'text: ' + text; - } - } - // Method 3: engagement panel buttons - const engBtns = document.querySelectorAll('ytd-button-renderer button, ytd-button-renderer a'); - for (const b of engBtns) { - const text = b.textContent.trim().toLowerCase(); - if (text.includes('transcript') || text.includes('스크립트')) { - b.click(); - return 'engagement: ' + text; - } - } - return false; - } - """); - log.info("[TRANSCRIPT] Clicked transcript button: {}", clicked); - - if (Boolean.FALSE.equals(clicked)) { - Object btnLabels = page.evaluate(""" - () => { - const btns = document.querySelectorAll('button[aria-label]'); - return Array.from(btns).map(b => b.getAttribute('aria-label')).slice(0, 30); - } - """); - log.warn("[TRANSCRIPT] Transcript button not found. Available buttons: {}", btnLabels); - return null; + // Click "더보기" (expand description) + page.evaluate(""" + () => { + const moreBtn = document.querySelector('tp-yt-paper-button#expand'); + if (moreBtn) moreBtn.click(); } + """); + page.waitForTimeout(2000); - // Wait for transcript segments to appear (max ~40s) + // Click transcript button + Object clicked = page.evaluate(""" + () => { + // Method 1: aria-label + for (const label of ['스크립트 표시', 'Show transcript']) { + const btns = document.querySelectorAll(`button[aria-label="${label}"]`); + for (const b of btns) { b.click(); return 'aria-label: ' + label; } + } + // Method 2: text content + const allBtns = document.querySelectorAll('button'); + for (const b of allBtns) { + const text = b.textContent.trim(); + if (text === '스크립트 표시' || text === 'Show transcript') { + b.click(); + return 'text: ' + text; + } + } + // Method 3: engagement panel buttons + const engBtns = document.querySelectorAll('ytd-button-renderer button, ytd-button-renderer a'); + for (const b of engBtns) { + const text = b.textContent.trim().toLowerCase(); + if (text.includes('transcript') || text.includes('스크립트')) { + b.click(); + return 'engagement: ' + text; + } + } + return false; + } + """); + log.info("[TRANSCRIPT] Clicked transcript button: {}", clicked); + + if (Boolean.FALSE.equals(clicked)) { + Object btnLabels = page.evaluate(""" + () => { + const btns = document.querySelectorAll('button[aria-label]'); + return Array.from(btns).map(b => b.getAttribute('aria-label')).slice(0, 30); + } + """); + log.warn("[TRANSCRIPT] Transcript button not found. Available buttons: {}", btnLabels); + return null; + } + + // Wait for transcript segments to appear (max ~40s) + page.waitForTimeout(3000); + for (int attempt = 0; attempt < 12; attempt++) { page.waitForTimeout(3000); - for (int attempt = 0; attempt < 12; attempt++) { - page.waitForTimeout(3000); - Object count = page.evaluate( - "() => document.querySelectorAll('ytd-transcript-segment-renderer').length"); - int segCount = count instanceof Number n ? n.intValue() : 0; - log.info("[TRANSCRIPT] Wait {}s: {} segments", (attempt + 1) * 3 + 3, segCount); - if (segCount > 0) break; - } + Object count = page.evaluate( + "() => document.querySelectorAll('ytd-transcript-segment-renderer').length"); + int segCount = count instanceof Number n ? n.intValue() : 0; + log.info("[TRANSCRIPT] Wait {}s: {} segments", (attempt + 1) * 3 + 3, segCount); + if (segCount > 0) break; + } - // Select Korean if available - selectKorean(page); - - // Scroll transcript panel and collect segments - Object segmentsObj = page.evaluate(""" - async () => { - const container = document.querySelector( - 'ytd-transcript-segment-list-renderer #segments-container, ' + - 'ytd-transcript-renderer #body' - ); - if (!container) { - const segs = document.querySelectorAll('ytd-transcript-segment-renderer'); - return Array.from(segs).map(s => { - const txt = s.querySelector('.segment-text, yt-formatted-string.segment-text'); - return txt ? txt.textContent.trim() : ''; - }).filter(t => t); - } - - let prevCount = 0; - for (let i = 0; i < 50; i++) { - container.scrollTop = container.scrollHeight; - await new Promise(r => setTimeout(r, 300)); - const segs = document.querySelectorAll('ytd-transcript-segment-renderer'); - if (segs.length === prevCount && i > 3) break; - prevCount = segs.length; - } + selectKorean(page); + // Scroll transcript panel and collect segments + Object segmentsObj = page.evaluate(""" + async () => { + const container = document.querySelector( + 'ytd-transcript-segment-list-renderer #segments-container, ' + + 'ytd-transcript-renderer #body' + ); + if (!container) { const segs = document.querySelectorAll('ytd-transcript-segment-renderer'); return Array.from(segs).map(s => { const txt = s.querySelector('.segment-text, yt-formatted-string.segment-text'); return txt ? txt.textContent.trim() : ''; }).filter(t => t); } - """); - if (segmentsObj instanceof List segments && !segments.isEmpty()) { - String text = segments.stream() - .map(Object::toString) - .collect(Collectors.joining(" ")); - log.info("[TRANSCRIPT] Browser success: {} chars from {} segments", text.length(), segments.size()); - return new TranscriptResult(text, "browser"); + let prevCount = 0; + for (let i = 0; i < 50; i++) { + container.scrollTop = container.scrollHeight; + await new Promise(r => setTimeout(r, 300)); + const segs = document.querySelectorAll('ytd-transcript-segment-renderer'); + if (segs.length === prevCount && i > 3) break; + prevCount = segs.length; + } + + const segs = document.querySelectorAll('ytd-transcript-segment-renderer'); + return Array.from(segs).map(s => { + const txt = s.querySelector('.segment-text, yt-formatted-string.segment-text'); + return txt ? txt.textContent.trim() : ''; + }).filter(t => t); } + """); - log.warn("[TRANSCRIPT] No segments found via browser for {}", videoId); - return null; + if (segmentsObj instanceof List segments && !segments.isEmpty()) { + String text = segments.stream() + .map(Object::toString) + .collect(Collectors.joining(" ")); + log.info("[TRANSCRIPT] Browser success: {} chars from {} segments", text.length(), segments.size()); + return new TranscriptResult(text, "browser"); } + + log.warn("[TRANSCRIPT] No segments found via browser for {}", videoId); + return null; } catch (Exception e) { - log.error("[TRANSCRIPT] Playwright failed for {}: {}", videoId, e.getMessage()); + log.error("[TRANSCRIPT] Page fetch failed for {}: {}", videoId, e.getMessage()); return null; } } diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index 2b8ca4a..1084fd9 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -39,7 +39,7 @@ app: expiration-days: 7 cors: - allowed-origins: http://localhost:3000,http://localhost:3001,https://www.tasteby.net,https://tasteby.net + allowed-origins: http://localhost:3000,http://localhost:3001,https://www.tasteby.net,https://tasteby.net,https://dev.tasteby.net oracle: wallet-path: ${ORACLE_WALLET:} diff --git a/backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml b/backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml index 68e3e40..0ceba53 100644 --- a/backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml +++ b/backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml @@ -16,6 +16,8 @@ + + @@ -26,7 +28,7 @@ SELECT r.id, r.name, r.address, r.region, r.latitude, r.longitude, r.cuisine_type, r.price_range, r.phone, r.website, r.google_place_id, - r.business_status, r.rating, r.rating_count + r.tabling_url, r.catchtable_url, r.business_status, r.rating, r.rating_count FROM restaurants r WHERE r.id = #{id} @@ -129,6 +131,12 @@ website = #{fields.website}, + + tabling_url = #{fields.tabling_url}, + + + catchtable_url = #{fields.catchtable_url}, + latitude = #{fields.latitude}, @@ -201,6 +209,24 @@ + + + + diff --git a/backend-java/src/main/resources/mybatis/mapper/VideoMapper.xml b/backend-java/src/main/resources/mybatis/mapper/VideoMapper.xml index a79b03f..1f4f023 100644 --- a/backend-java/src/main/resources/mybatis/mapper/VideoMapper.xml +++ b/backend-java/src/main/resources/mybatis/mapper/VideoMapper.xml @@ -186,7 +186,8 @@ INSERT INTO videos (id, channel_id, video_id, title, url, published_at) - VALUES (#{id}, #{channelId}, #{videoId}, #{title}, #{url}, #{publishedAt}) + VALUES (#{id}, #{channelId}, #{videoId}, #{title}, #{url}, + TO_TIMESTAMP(#{publishedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')) diff --git a/backend-java/src/main/resources/mybatis/mybatis-config.xml b/backend-java/src/main/resources/mybatis/mybatis-config.xml index 58d67af..bdc7d41 100644 --- a/backend-java/src/main/resources/mybatis/mybatis-config.xml +++ b/backend-java/src/main/resources/mybatis/mybatis-config.xml @@ -5,5 +5,6 @@ + diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 3044a9f..3552832 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -1495,6 +1495,12 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) { const [editForm, setEditForm] = useState>({}); const [saving, setSaving] = useState(false); const [videos, setVideos] = useState([]); + const [tablingSearching, setTablingSearching] = useState(false); + const [bulkTabling, setBulkTabling] = useState(false); + const [bulkTablingProgress, setBulkTablingProgress] = useState({ current: 0, total: 0, name: "", linked: 0, notFound: 0 }); + const [catchtableSearching, setCatchtableSearching] = useState(false); + const [bulkCatchtable, setBulkCatchtable] = useState(false); + const [bulkCatchtableProgress, setBulkCatchtableProgress] = useState({ current: 0, total: 0, name: "", linked: 0, notFound: 0 }); type RestSortKey = "name" | "region" | "cuisine_type" | "price_range" | "rating" | "business_status"; const [sortKey, setSortKey] = useState("name"); const [sortAsc, setSortAsc] = useState(true); @@ -1617,10 +1623,126 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) { )} + {isAdmin && (<> + + + )} {nameSearch ? `${sorted.length} / ` : ""}총 {restaurants.length}개 식당 + {bulkTabling && bulkTablingProgress.name && ( +
+
+ {bulkTablingProgress.current}/{bulkTablingProgress.total} - {bulkTablingProgress.name} + 연결: {bulkTablingProgress.linked} / 미발견: {bulkTablingProgress.notFound} +
+
+
+
+
+ )} + {bulkCatchtable && bulkCatchtableProgress.name && ( +
+
+ {bulkCatchtableProgress.current}/{bulkCatchtableProgress.total} - {bulkCatchtableProgress.name} + 연결: {bulkCatchtableProgress.linked} / 미발견: {bulkCatchtableProgress.notFound} +
+
+
+
+
+ )}
@@ -1730,6 +1852,109 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {

)} + {/* 테이블링 연결 */} + {isAdmin && ( +
+
+

테이블링

+ {selected.tabling_url === "NONE" ? ( + 검색완료-없음 + ) : selected.tabling_url ? ( + {selected.tabling_url} + ) : ( + 미연결 + )} + + {selected.tabling_url && ( + + )} +
+
+ )} + {/* 캐치테이블 연결 */} + {isAdmin && ( +
+
+

캐치테이블

+ {selected.catchtable_url === "NONE" ? ( + 검색완료-없음 + ) : selected.catchtable_url ? ( + {selected.catchtable_url} + ) : ( + 미연결 + )} + + {selected.catchtable_url && ( + + )} +
+
+ )} + {videos.length > 0 && (

연결된 영상 ({videos.length})

diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 808f231..8f02384 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -42,3 +42,8 @@ html, body, #__next { .gm-style .gm-style-iw-d { overflow: auto !important; } + +/* Safe area for iOS bottom nav */ +.safe-area-bottom { + padding-bottom: env(safe-area-inset-bottom, 0px); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 89c3aee..721a91b 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { GoogleLogin } from "@react-oauth/google"; +import LoginMenu from "@/components/LoginMenu"; import { api } from "@/lib/api"; import type { Restaurant, Channel, Review } from "@/lib/api"; import { useAuth } from "@/lib/auth-context"; @@ -140,6 +141,7 @@ export default function Home() { const [cuisineFilter, setCuisineFilter] = useState(""); const [priceFilter, setPriceFilter] = useState(""); const [viewMode, setViewMode] = useState<"map" | "list">("list"); + const [mobileTab, setMobileTab] = useState<"home" | "list" | "nearby" | "favorites" | "profile">("home"); const [showMobileFilters, setShowMobileFilters] = useState(false); const [mapBounds, setMapBounds] = useState(null); const [boundsFilterOn, setBoundsFilterOn] = useState(false); @@ -151,6 +153,7 @@ export default function Home() { const [showMyReviews, setShowMyReviews] = useState(false); 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 geoApplied = useRef(false); const regionTree = useMemo(() => buildRegionTree(restaurants), [restaurants]); @@ -169,6 +172,8 @@ export default function Home() { }, [regionTree, countryFilter, cityFilter]); const filteredRestaurants = useMemo(() => { + const dist = (r: Restaurant) => + (r.latitude - userLoc.lat) ** 2 + (r.longitude - userLoc.lng) ** 2; return restaurants.filter((r) => { if (channelFilter && !(r.channels || []).includes(channelFilter)) return false; if (cuisineFilter && !matchCuisineFilter(r.cuisine_type, cuisineFilter)) return false; @@ -184,12 +189,23 @@ export default function Home() { if (r.longitude < mapBounds.west || r.longitude > mapBounds.east) return false; } return true; + }).sort((a, b) => { + const da = dist(a), db = dist(b); + if (da !== db) return da - db; + return (b.rating || 0) - (a.rating || 0); }); - }, [restaurants, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds]); + }, [restaurants, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]); - // Set desktop default to map mode on mount + // Set desktop default to map mode on mount + get user location useEffect(() => { if (window.innerWidth >= 768) setViewMode("map"); + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + (pos) => setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude }), + () => {}, + { timeout: 5000 }, + ); + } }, []); // Load channels + record visit on mount @@ -220,12 +236,9 @@ export default function Home() { if (match) { setCountryFilter(match.country); setCityFilter(match.city); - const matched = restaurants.filter((r) => { - const p = parseRegion(r.region); - return p && p.country === match.country && p.city === match.city; - }); - setRegionFlyTo(computeFlyTo(matched)); } + const mobile = window.innerWidth < 768; + setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: mobile ? 13 : 16 }); }, () => { /* user denied or error — do nothing */ }, { timeout: 5000 }, @@ -262,6 +275,21 @@ export default function Home() { setMapBounds(bounds); }, []); + const handleMyLocation = useCallback(() => { + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + (pos) => { + setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude }); + setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 16 }); + }, + () => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 16 }), + { timeout: 5000 }, + ); + } else { + setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 16 }); + } + }, []); + const handleCountryChange = useCallback((country: string) => { setCountryFilter(country); setCityFilter(""); @@ -333,6 +361,75 @@ export default function Home() { .finally(() => setLoading(false)); }, []); + const handleMobileTab = useCallback(async (tab: "home" | "list" | "nearby" | "favorites" | "profile") => { + // 홈 탭 재클릭 = 리셋 + if (tab === "home" && mobileTab === "home") { + handleReset(); + return; + } + + setMobileTab(tab); + setShowDetail(false); + setShowMobileFilters(false); + setSelected(null); + + if (tab === "nearby") { + setBoundsFilterOn(true); + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + (pos) => setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 13 }), + () => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 13 }), + { timeout: 5000 }, + ); + } else { + setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 13 }); + } + // 내주변에서 돌아올 때를 위해 favorites/reviews 해제 + if (showFavorites || showMyReviews) { + setShowFavorites(false); + setShowMyReviews(false); + setMyReviews([]); + api.getRestaurants({ limit: 500, channel: channelFilter || undefined }).then(setRestaurants); + } + return; + } + + setBoundsFilterOn(false); + + if (tab === "favorites") { + if (!user) return; + setShowMyReviews(false); + setMyReviews([]); + try { + const favs = await api.getMyFavorites(); + setRestaurants(favs); + setShowFavorites(true); + } catch { /* ignore */ } + } else if (tab === "profile") { + if (!user) return; + setShowFavorites(false); + try { + const reviews = await api.getMyReviews(); + setMyReviews(reviews); + setShowMyReviews(true); + } catch { /* ignore */ } + // 프로필에서는 식당 목록을 원래대로 복원 + if (showFavorites) { + api.getRestaurants({ limit: 500, channel: channelFilter || undefined }).then(setRestaurants); + } + } else { + // 홈 / 식당 목록 - 항상 전체 식당 목록으로 복원 + const needReload = showFavorites || showMyReviews; + setShowFavorites(false); + setShowMyReviews(false); + setMyReviews([]); + if (needReload) { + const data = await api.getRestaurants({ limit: 500, channel: channelFilter || undefined }); + setRestaurants(data); + } + } + }, [user, showFavorites, showMyReviews, channelFilter, mobileTab, handleReset]); + const handleToggleFavorites = async () => { if (showFavorites) { setShowFavorites(false); @@ -436,6 +533,13 @@ export default function Home() {
+