UI/UX 개선: 모바일 네비게이션, 로그인 모달, 지도 기능, 캐치테이블 연동
- 모바일 하단 네비게이션(홈/식당목록/내주변/찜/내정보) 추가 - 로그인 버튼을 모달 방식으로 변경 (소셜 로그인 확장 가능) - 내위치 기반 정렬 및 영역 필터, 지도 내위치 버튼 추가 - 채널 필터 시 해당 채널만 마커/범례 표시 - 캐치테이블 검색/연동 (단건/벌크), NONE 저장 패턴 - 벌크 트랜스크립트 SSE (Playwright 브라우저 재사용) - 테이블링/캐치테이블 버튼 UI 차별화 - Google Maps 링크 모바일 호환, 초기화 버튼, 검색 라벨 개선 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, Object> 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<String, Object> delete(@PathVariable String channelId) {
|
||||
AuthUtil.requireAdmin();
|
||||
|
||||
@@ -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<Map<String, Object>> 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<String, Object> 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<String, Object> setTablingUrl(@PathVariable String id, @RequestBody Map<String, String> 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<Map<String, Object>> 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<String, Object> 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<String, Object> setCatchtableUrl(@PathVariable String id, @RequestBody Map<String, String> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> data) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().data(objectMapper.writeValueAsString(data)));
|
||||
} catch (Exception e) {
|
||||
log.debug("SSE emit error: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, Object> uploadTranscript(@PathVariable String id,
|
||||
@RequestBody Map<String, String> 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<String, Object> getExtractPrompt() {
|
||||
return Map.of("prompt", extractorService.getPrompt());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -55,6 +55,10 @@ public interface RestaurantMapper {
|
||||
|
||||
void updateFoodsMentioned(@Param("id") String id, @Param("foods") String foods);
|
||||
|
||||
List<Restaurant> findWithoutTabling();
|
||||
|
||||
List<Restaurant> findWithoutCatchtable();
|
||||
|
||||
List<Map<String, Object>> findForRemapCuisine();
|
||||
|
||||
List<Map<String, Object>> findForRemapFoods();
|
||||
|
||||
@@ -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<List<Double>> embedBatch(List<String> 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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,6 +26,14 @@ public class RestaurantService {
|
||||
return restaurants;
|
||||
}
|
||||
|
||||
public List<Restaurant> findWithoutTabling() {
|
||||
return mapper.findWithoutTabling();
|
||||
}
|
||||
|
||||
public List<Restaurant> findWithoutCatchtable() {
|
||||
return mapper.findWithoutCatchtable();
|
||||
}
|
||||
|
||||
public Restaurant findById(String id) {
|
||||
Restaurant restaurant = mapper.findById(id);
|
||||
if (restaurant == null) return null;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:}
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
<result property="phone" column="phone"/>
|
||||
<result property="website" column="website"/>
|
||||
<result property="googlePlaceId" column="google_place_id"/>
|
||||
<result property="tablingUrl" column="tabling_url"/>
|
||||
<result property="catchtableUrl" column="catchtable_url"/>
|
||||
<result property="businessStatus" column="business_status"/>
|
||||
<result property="rating" column="rating"/>
|
||||
<result property="ratingCount" column="rating_count"/>
|
||||
@@ -26,7 +28,7 @@
|
||||
|
||||
<select id="findAll" resultMap="restaurantMap">
|
||||
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
|
||||
r.cuisine_type, r.price_range, r.google_place_id,
|
||||
r.cuisine_type, r.price_range, r.google_place_id, r.tabling_url, r.catchtable_url,
|
||||
r.business_status, r.rating, r.rating_count, r.updated_at
|
||||
FROM restaurants r
|
||||
<if test="channel != null and channel != ''">
|
||||
@@ -54,7 +56,7 @@
|
||||
<select id="findById" resultMap="restaurantMap">
|
||||
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}
|
||||
</select>
|
||||
@@ -129,6 +131,12 @@
|
||||
<if test="fields.containsKey('website')">
|
||||
website = #{fields.website},
|
||||
</if>
|
||||
<if test="fields.containsKey('tabling_url')">
|
||||
tabling_url = #{fields.tabling_url},
|
||||
</if>
|
||||
<if test="fields.containsKey('catchtable_url')">
|
||||
catchtable_url = #{fields.catchtable_url},
|
||||
</if>
|
||||
<if test="fields.containsKey('latitude')">
|
||||
latitude = #{fields.latitude},
|
||||
</if>
|
||||
@@ -201,6 +209,24 @@
|
||||
</foreach>
|
||||
</select>
|
||||
|
||||
<select id="findWithoutTabling" resultMap="restaurantMap">
|
||||
SELECT r.id, r.name, r.address, r.region
|
||||
FROM restaurants r
|
||||
WHERE r.tabling_url IS NULL
|
||||
AND r.latitude IS NOT NULL
|
||||
AND EXISTS (SELECT 1 FROM video_restaurants vr WHERE vr.restaurant_id = r.id)
|
||||
ORDER BY r.name
|
||||
</select>
|
||||
|
||||
<select id="findWithoutCatchtable" resultMap="restaurantMap">
|
||||
SELECT r.id, r.name, r.address, r.region
|
||||
FROM restaurants r
|
||||
WHERE r.catchtable_url IS NULL
|
||||
AND r.latitude IS NOT NULL
|
||||
AND EXISTS (SELECT 1 FROM video_restaurants vr WHERE vr.restaurant_id = r.id)
|
||||
ORDER BY r.name
|
||||
</select>
|
||||
|
||||
<!-- ===== Remap operations ===== -->
|
||||
|
||||
<update id="updateCuisineType">
|
||||
|
||||
@@ -186,7 +186,8 @@
|
||||
|
||||
<insert id="insertVideo">
|
||||
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"'))
|
||||
</insert>
|
||||
|
||||
<select id="getExistingVideoIds" resultType="string">
|
||||
@@ -194,7 +195,7 @@
|
||||
</select>
|
||||
|
||||
<select id="getLatestVideoDate" resultType="string">
|
||||
SELECT TO_CHAR(MAX(published_at), 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
SELECT TO_CHAR(MAX(published_at), 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS latest_date
|
||||
FROM videos WHERE channel_id = #{channelId}
|
||||
</select>
|
||||
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
<setting name="mapUnderscoreToCamelCase" value="true"/>
|
||||
<setting name="callSettersOnNulls" value="true"/>
|
||||
<setting name="returnInstanceForEmptyRow" value="true"/>
|
||||
<setting name="jdbcTypeForNull" value="VARCHAR"/>
|
||||
</settings>
|
||||
</configuration>
|
||||
|
||||
Reference in New Issue
Block a user