Compare commits
10 Commits
v0.1.1
...
88c1b4243e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88c1b4243e | ||
|
|
824c171158 | ||
|
|
4f8b4f435e | ||
|
|
50018c17fa | ||
|
|
ec8330a978 | ||
|
|
e85e135c8b | ||
|
|
2a0ee1d2cc | ||
|
|
0f985d52a9 | ||
|
|
cdee37e341 | ||
|
|
58c0f972e2 |
4
.gitignore
vendored
@@ -13,3 +13,7 @@ backend-java/.gradle/
|
|||||||
|
|
||||||
# K8s secrets (never commit)
|
# K8s secrets (never commit)
|
||||||
k8s/secrets.yaml
|
k8s/secrets.yaml
|
||||||
|
|
||||||
|
# OS / misc
|
||||||
|
.DS_Store
|
||||||
|
backend/cookies.txt
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
package com.tasteby.config;
|
package com.tasteby.config;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
public class DataSourceConfig {
|
public class DataSourceConfig {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(DataSourceConfig.class);
|
||||||
|
|
||||||
@Value("${app.oracle.wallet-path:}")
|
@Value("${app.oracle.wallet-path:}")
|
||||||
private String walletPath;
|
private String walletPath;
|
||||||
|
|
||||||
|
private final DataSource dataSource;
|
||||||
|
|
||||||
|
public DataSourceConfig(DataSource dataSource) {
|
||||||
|
this.dataSource = dataSource;
|
||||||
|
}
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void configureWallet() {
|
public void configureWallet() {
|
||||||
if (walletPath != null && !walletPath.isBlank()) {
|
if (walletPath != null && !walletPath.isBlank()) {
|
||||||
@@ -18,4 +31,23 @@ public class DataSourceConfig {
|
|||||||
System.setProperty("oracle.net.wallet_location", walletPath);
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.tasteby.controller;
|
||||||
|
|
||||||
|
import com.tasteby.security.AuthUtil;
|
||||||
|
import com.tasteby.service.CacheService;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin")
|
||||||
|
public class AdminCacheController {
|
||||||
|
|
||||||
|
private final CacheService cacheService;
|
||||||
|
|
||||||
|
public AdminCacheController(CacheService cacheService) {
|
||||||
|
this.cacheService = cacheService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/cache-flush")
|
||||||
|
public Map<String, Object> flushCache() {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
|
cacheService.flush();
|
||||||
|
return Map.of("ok", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import com.tasteby.domain.Channel;
|
|||||||
import com.tasteby.security.AuthUtil;
|
import com.tasteby.security.AuthUtil;
|
||||||
import com.tasteby.service.CacheService;
|
import com.tasteby.service.CacheService;
|
||||||
import com.tasteby.service.ChannelService;
|
import com.tasteby.service.ChannelService;
|
||||||
|
import com.tasteby.service.YouTubeService;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
@@ -18,11 +19,14 @@ import java.util.Map;
|
|||||||
public class ChannelController {
|
public class ChannelController {
|
||||||
|
|
||||||
private final ChannelService channelService;
|
private final ChannelService channelService;
|
||||||
|
private final YouTubeService youtubeService;
|
||||||
private final CacheService cache;
|
private final CacheService cache;
|
||||||
private final ObjectMapper objectMapper;
|
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.channelService = channelService;
|
||||||
|
this.youtubeService = youtubeService;
|
||||||
this.cache = cache;
|
this.cache = cache;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
@@ -60,6 +64,27 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, Object> body) {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
|
Integer sortOrder = body.get("sort_order") != null ? ((Number) body.get("sort_order")).intValue() : null;
|
||||||
|
channelService.update(id, (String) body.get("description"), (String) body.get("tags"), sortOrder);
|
||||||
|
cache.flush();
|
||||||
|
return Map.of("ok", true);
|
||||||
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{channelId}")
|
@DeleteMapping("/{channelId}")
|
||||||
public Map<String, Object> delete(@PathVariable String channelId) {
|
public Map<String, Object> delete(@PathVariable String channelId) {
|
||||||
AuthUtil.requireAdmin();
|
AuthUtil.requireAdmin();
|
||||||
|
|||||||
@@ -2,27 +2,44 @@ package com.tasteby.controller;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.microsoft.playwright.*;
|
||||||
import com.tasteby.domain.Restaurant;
|
import com.tasteby.domain.Restaurant;
|
||||||
import com.tasteby.security.AuthUtil;
|
import com.tasteby.security.AuthUtil;
|
||||||
import com.tasteby.service.CacheService;
|
import com.tasteby.service.CacheService;
|
||||||
|
import com.tasteby.service.GeocodingService;
|
||||||
import com.tasteby.service.RestaurantService;
|
import com.tasteby.service.RestaurantService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
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.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/restaurants")
|
@RequestMapping("/api/restaurants")
|
||||||
public class RestaurantController {
|
public class RestaurantController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(RestaurantController.class);
|
||||||
|
|
||||||
private final RestaurantService restaurantService;
|
private final RestaurantService restaurantService;
|
||||||
|
private final GeocodingService geocodingService;
|
||||||
private final CacheService cache;
|
private final CacheService cache;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||||
|
|
||||||
public RestaurantController(RestaurantService restaurantService, CacheService cache, ObjectMapper objectMapper) {
|
public RestaurantController(RestaurantService restaurantService, GeocodingService geocodingService, CacheService cache, ObjectMapper objectMapper) {
|
||||||
this.restaurantService = restaurantService;
|
this.restaurantService = restaurantService;
|
||||||
|
this.geocodingService = geocodingService;
|
||||||
this.cache = cache;
|
this.cache = cache;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
@@ -68,11 +85,43 @@ public class RestaurantController {
|
|||||||
AuthUtil.requireAdmin();
|
AuthUtil.requireAdmin();
|
||||||
var r = restaurantService.findById(id);
|
var r = restaurantService.findById(id);
|
||||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||||
|
|
||||||
|
// Re-geocode if name or address changed
|
||||||
|
String newName = (String) body.get("name");
|
||||||
|
String newAddress = (String) body.get("address");
|
||||||
|
boolean nameChanged = newName != null && !newName.equals(r.getName());
|
||||||
|
boolean addressChanged = newAddress != null && !newAddress.equals(r.getAddress());
|
||||||
|
if (nameChanged || addressChanged) {
|
||||||
|
String geoName = newName != null ? newName : r.getName();
|
||||||
|
String geoAddr = newAddress != null ? newAddress : r.getAddress();
|
||||||
|
var geo = geocodingService.geocodeRestaurant(geoName, geoAddr);
|
||||||
|
if (geo != null) {
|
||||||
|
body.put("latitude", geo.get("latitude"));
|
||||||
|
body.put("longitude", geo.get("longitude"));
|
||||||
|
body.put("google_place_id", geo.get("google_place_id"));
|
||||||
|
if (geo.containsKey("formatted_address")) {
|
||||||
|
body.put("address", geo.get("formatted_address"));
|
||||||
|
}
|
||||||
|
if (geo.containsKey("rating")) body.put("rating", geo.get("rating"));
|
||||||
|
if (geo.containsKey("rating_count")) body.put("rating_count", geo.get("rating_count"));
|
||||||
|
if (geo.containsKey("phone")) body.put("phone", geo.get("phone"));
|
||||||
|
if (geo.containsKey("business_status")) body.put("business_status", geo.get("business_status"));
|
||||||
|
|
||||||
|
// formatted_address에서 region 파싱 (예: "대한민국 서울특별시 강남구 ..." → "한국|서울|강남구")
|
||||||
|
String addr = (String) geo.get("formatted_address");
|
||||||
|
if (addr != null) {
|
||||||
|
body.put("region", GeocodingService.parseRegionFromAddress(addr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
restaurantService.update(id, body);
|
restaurantService.update(id, body);
|
||||||
cache.flush();
|
cache.flush();
|
||||||
return Map.of("ok", true);
|
var updated = restaurantService.findById(id);
|
||||||
|
return Map.of("ok", true, "restaurant", updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
public Map<String, Object> delete(@PathVariable String id) {
|
public Map<String, Object> delete(@PathVariable String id) {
|
||||||
AuthUtil.requireAdmin();
|
AuthUtil.requireAdmin();
|
||||||
@@ -83,6 +132,265 @@ public class RestaurantController {
|
|||||||
return Map.of("ok", true);
|
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"));
|
||||||
|
if (isNameSimilar(r.getName(), 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++;
|
||||||
|
log.info("[TABLING] Name mismatch: '{}' vs '{}', skipping", r.getName(), title);
|
||||||
|
emit(emitter, Map.of("type", "notfound", "current", i + 1,
|
||||||
|
"name", r.getName(), "reason", "이름 불일치: " + 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 테이블링/캐치테이블 매핑 초기화 */
|
||||||
|
@DeleteMapping("/reset-tabling")
|
||||||
|
public Map<String, Object> resetTabling() {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
|
restaurantService.resetTablingUrls();
|
||||||
|
cache.flush();
|
||||||
|
return Map.of("ok", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/reset-catchtable")
|
||||||
|
public Map<String, Object> resetCatchtable() {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
|
restaurantService.resetCatchtableUrls();
|
||||||
|
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"));
|
||||||
|
if (isNameSimilar(r.getName(), 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++;
|
||||||
|
log.info("[CATCHTABLE] Name mismatch: '{}' vs '{}', skipping", r.getName(), title);
|
||||||
|
emit(emitter, Map.of("type", "notfound", "current", i + 1,
|
||||||
|
"name", r.getName(), "reason", "이름 불일치: " + 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")
|
@GetMapping("/{id}/videos")
|
||||||
public List<Map<String, Object>> videos(@PathVariable String id) {
|
public List<Map<String, Object>> videos(@PathVariable String id) {
|
||||||
String key = cache.makeKey("restaurant_videos", id);
|
String key = cache.makeKey("restaurant_videos", id);
|
||||||
@@ -98,4 +406,152 @@ public class RestaurantController {
|
|||||||
cache.set(key, result);
|
cache.set(key, result);
|
||||||
return 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 식당 이름과 검색 결과 제목의 유사도 검사.
|
||||||
|
* 한쪽 이름이 다른쪽에 포함되거나, 공통 글자 비율이 40% 이상이면 유사하다고 판단.
|
||||||
|
*/
|
||||||
|
private boolean isNameSimilar(String restaurantName, String resultTitle) {
|
||||||
|
String a = normalize(restaurantName);
|
||||||
|
String b = normalize(resultTitle);
|
||||||
|
if (a.isEmpty() || b.isEmpty()) return false;
|
||||||
|
|
||||||
|
// 포함 관계 체크
|
||||||
|
if (a.contains(b) || b.contains(a)) return true;
|
||||||
|
|
||||||
|
// 공통 문자 비율 (Jaccard-like)
|
||||||
|
var setA = a.chars().boxed().collect(java.util.stream.Collectors.toSet());
|
||||||
|
var setB = b.chars().boxed().collect(java.util.stream.Collectors.toSet());
|
||||||
|
long common = setA.stream().filter(setB::contains).count();
|
||||||
|
double ratio = (double) common / Math.max(setA.size(), setB.size());
|
||||||
|
return ratio >= 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalize(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
return s.replaceAll("[\\s·\\-_()()\\[\\]【】]", "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
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")
|
@GetMapping("/extract/prompt")
|
||||||
public Map<String, Object> getExtractPrompt() {
|
public Map<String, Object> getExtractPrompt() {
|
||||||
return Map.of("prompt", extractorService.getPrompt());
|
return Map.of("prompt", extractorService.getPrompt());
|
||||||
@@ -234,6 +252,34 @@ public class VideoController {
|
|||||||
if (body.containsKey(key)) restFields.put(key, body.get(key));
|
if (body.containsKey(key)) restFields.put(key, body.get(key));
|
||||||
}
|
}
|
||||||
if (!restFields.isEmpty()) {
|
if (!restFields.isEmpty()) {
|
||||||
|
// Re-geocode if name or address changed
|
||||||
|
var existing = restaurantService.findById(restaurantId);
|
||||||
|
String newName = (String) restFields.get("name");
|
||||||
|
String newAddr = (String) restFields.get("address");
|
||||||
|
boolean nameChanged = newName != null && existing != null && !newName.equals(existing.getName());
|
||||||
|
boolean addrChanged = newAddr != null && existing != null && !newAddr.equals(existing.getAddress());
|
||||||
|
if (nameChanged || addrChanged) {
|
||||||
|
String geoName = newName != null ? newName : existing.getName();
|
||||||
|
String geoAddr = newAddr != null ? newAddr : existing.getAddress();
|
||||||
|
var geo = geocodingService.geocodeRestaurant(geoName, geoAddr);
|
||||||
|
if (geo != null) {
|
||||||
|
restFields.put("latitude", geo.get("latitude"));
|
||||||
|
restFields.put("longitude", geo.get("longitude"));
|
||||||
|
restFields.put("google_place_id", geo.get("google_place_id"));
|
||||||
|
if (geo.containsKey("formatted_address")) {
|
||||||
|
restFields.put("address", geo.get("formatted_address"));
|
||||||
|
}
|
||||||
|
if (geo.containsKey("rating")) restFields.put("rating", geo.get("rating"));
|
||||||
|
if (geo.containsKey("rating_count")) restFields.put("rating_count", geo.get("rating_count"));
|
||||||
|
if (geo.containsKey("phone")) restFields.put("phone", geo.get("phone"));
|
||||||
|
if (geo.containsKey("business_status")) restFields.put("business_status", geo.get("business_status"));
|
||||||
|
// Parse region from address
|
||||||
|
String addr = (String) geo.get("formatted_address");
|
||||||
|
if (addr != null) {
|
||||||
|
restFields.put("region", GeocodingService.parseRegionFromAddress(addr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
restaurantService.update(restaurantId, restFields);
|
restaurantService.update(restaurantId, restFields);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
|||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SSE streaming endpoints for bulk operations.
|
* SSE streaming endpoints for bulk operations.
|
||||||
@@ -26,6 +27,7 @@ public class VideoSseController {
|
|||||||
private final VideoService videoService;
|
private final VideoService videoService;
|
||||||
private final RestaurantService restaurantService;
|
private final RestaurantService restaurantService;
|
||||||
private final PipelineService pipelineService;
|
private final PipelineService pipelineService;
|
||||||
|
private final YouTubeService youTubeService;
|
||||||
private final OciGenAiService genAi;
|
private final OciGenAiService genAi;
|
||||||
private final CacheService cache;
|
private final CacheService cache;
|
||||||
private final ObjectMapper mapper;
|
private final ObjectMapper mapper;
|
||||||
@@ -34,27 +36,120 @@ public class VideoSseController {
|
|||||||
public VideoSseController(VideoService videoService,
|
public VideoSseController(VideoService videoService,
|
||||||
RestaurantService restaurantService,
|
RestaurantService restaurantService,
|
||||||
PipelineService pipelineService,
|
PipelineService pipelineService,
|
||||||
|
YouTubeService youTubeService,
|
||||||
OciGenAiService genAi,
|
OciGenAiService genAi,
|
||||||
CacheService cache,
|
CacheService cache,
|
||||||
ObjectMapper mapper) {
|
ObjectMapper mapper) {
|
||||||
this.videoService = videoService;
|
this.videoService = videoService;
|
||||||
this.restaurantService = restaurantService;
|
this.restaurantService = restaurantService;
|
||||||
this.pipelineService = pipelineService;
|
this.pipelineService = pipelineService;
|
||||||
|
this.youTubeService = youTubeService;
|
||||||
this.genAi = genAi;
|
this.genAi = genAi;
|
||||||
this.cache = cache;
|
this.cache = cache;
|
||||||
this.mapper = mapper;
|
this.mapper = mapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/bulk-transcript")
|
@PostMapping("/bulk-transcript")
|
||||||
public SseEmitter bulkTranscript() {
|
public SseEmitter bulkTranscript(@RequestBody(required = false) Map<String, Object> body) {
|
||||||
AuthUtil.requireAdmin();
|
AuthUtil.requireAdmin();
|
||||||
SseEmitter emitter = new SseEmitter(600_000L); // 10 min timeout
|
SseEmitter emitter = new SseEmitter(1_800_000L); // 30 min timeout
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<String> selectedIds = body != null && body.containsKey("ids")
|
||||||
|
? ((List<?>) body.get("ids")).stream().map(Object::toString).toList()
|
||||||
|
: null;
|
||||||
|
|
||||||
executor.execute(() -> {
|
executor.execute(() -> {
|
||||||
try {
|
try {
|
||||||
// TODO: Implement when transcript extraction is available in Java
|
var videos = selectedIds != null && !selectedIds.isEmpty()
|
||||||
emit(emitter, Map.of("type", "start", "total", 0));
|
? videoService.findVideosByIds(selectedIds)
|
||||||
emit(emitter, Map.of("type", "complete", "total", 0, "success", 0));
|
: 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;
|
||||||
|
|
||||||
|
// Pass 1: 브라우저 우선 (봇 탐지 회피)
|
||||||
|
var apiNeeded = new ArrayList<Integer>();
|
||||||
|
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, "method", "browser"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
var result = youTubeService.getTranscriptWithPage(session.page(), videoId);
|
||||||
|
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 {
|
||||||
|
apiNeeded.add(i);
|
||||||
|
emit(emitter, Map.of("type", "skip", "index", i,
|
||||||
|
"title", title, "message", "브라우저 실패, API로 재시도 예정"));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
apiNeeded.add(i);
|
||||||
|
log.warn("[BULK-TRANSCRIPT] Browser failed for {}: {}", videoId, e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 봇 판정 방지 랜덤 딜레이 (3~8초)
|
||||||
|
if (i < total - 1) {
|
||||||
|
int delay = ThreadLocalRandom.current().nextInt(3000, 8001);
|
||||||
|
log.info("[BULK-TRANSCRIPT] Waiting {}ms before next...", delay);
|
||||||
|
session.page().waitForTimeout(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: 브라우저 실패분만 API로 재시도
|
||||||
|
if (!apiNeeded.isEmpty()) {
|
||||||
|
emit(emitter, Map.of("type", "api_pass", "count", apiNeeded.size()));
|
||||||
|
for (int i : apiNeeded) {
|
||||||
|
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, "method", "api"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
var result = youTubeService.getTranscriptApi(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++;
|
||||||
|
videoService.updateStatus(id, "no_transcript");
|
||||||
|
emit(emitter, Map.of("type", "error", "index", i,
|
||||||
|
"title", title, "message", "자막을 찾을 수 없음"));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
failed++;
|
||||||
|
videoService.updateStatus(id, "no_transcript");
|
||||||
|
log.error("[BULK-TRANSCRIPT] API error for {}: {}", videoId, e.getMessage());
|
||||||
|
emit(emitter, Map.of("type", "error", "index", i,
|
||||||
|
"title", title, "message", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(emitter, Map.of("type", "complete", "total", total, "success", success, "failed", failed));
|
||||||
emitter.complete();
|
emitter.complete();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Bulk transcript error", e);
|
log.error("Bulk transcript error", e);
|
||||||
@@ -65,13 +160,20 @@ public class VideoSseController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/bulk-extract")
|
@PostMapping("/bulk-extract")
|
||||||
public SseEmitter bulkExtract() {
|
public SseEmitter bulkExtract(@RequestBody(required = false) Map<String, Object> body) {
|
||||||
AuthUtil.requireAdmin();
|
AuthUtil.requireAdmin();
|
||||||
SseEmitter emitter = new SseEmitter(600_000L);
|
SseEmitter emitter = new SseEmitter(600_000L);
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<String> selectedIds = body != null && body.containsKey("ids")
|
||||||
|
? ((List<?>) body.get("ids")).stream().map(Object::toString).toList()
|
||||||
|
: null;
|
||||||
|
|
||||||
executor.execute(() -> {
|
executor.execute(() -> {
|
||||||
try {
|
try {
|
||||||
var rows = videoService.findVideosForBulkExtract();
|
var rows = selectedIds != null && !selectedIds.isEmpty()
|
||||||
|
? videoService.findVideosForExtractByIds(selectedIds)
|
||||||
|
: videoService.findVideosForBulkExtract();
|
||||||
|
|
||||||
int total = rows.size();
|
int total = rows.size();
|
||||||
int totalRestaurants = 0;
|
int totalRestaurants = 0;
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ public class Channel {
|
|||||||
private String channelId;
|
private String channelId;
|
||||||
private String channelName;
|
private String channelName;
|
||||||
private String titleFilter;
|
private String titleFilter;
|
||||||
|
private String description;
|
||||||
|
private String tags;
|
||||||
|
private Integer sortOrder;
|
||||||
private int videoCount;
|
private int videoCount;
|
||||||
private String lastVideoAt;
|
private String lastVideoAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ public class Restaurant {
|
|||||||
private String phone;
|
private String phone;
|
||||||
private String website;
|
private String website;
|
||||||
private String googlePlaceId;
|
private String googlePlaceId;
|
||||||
|
private String tablingUrl;
|
||||||
|
private String catchtableUrl;
|
||||||
private String businessStatus;
|
private String businessStatus;
|
||||||
private Double rating;
|
private Double rating;
|
||||||
private Integer ratingCount;
|
private Integer ratingCount;
|
||||||
|
|||||||
@@ -21,4 +21,9 @@ public interface ChannelMapper {
|
|||||||
int deactivateById(@Param("id") String id);
|
int deactivateById(@Param("id") String id);
|
||||||
|
|
||||||
Channel findByChannelId(@Param("channelId") String channelId);
|
Channel findByChannelId(@Param("channelId") String channelId);
|
||||||
|
|
||||||
|
void updateChannel(@Param("id") String id,
|
||||||
|
@Param("description") String description,
|
||||||
|
@Param("tags") String tags,
|
||||||
|
@Param("sortOrder") Integer sortOrder);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ public interface RestaurantMapper {
|
|||||||
|
|
||||||
void updateFoodsMentioned(@Param("id") String id, @Param("foods") String foods);
|
void updateFoodsMentioned(@Param("id") String id, @Param("foods") String foods);
|
||||||
|
|
||||||
|
List<Restaurant> findWithoutTabling();
|
||||||
|
|
||||||
|
List<Restaurant> findWithoutCatchtable();
|
||||||
|
|
||||||
|
void resetTablingUrls();
|
||||||
|
|
||||||
|
void resetCatchtableUrls();
|
||||||
|
|
||||||
List<Map<String, Object>> findForRemapCuisine();
|
List<Map<String, Object>> findForRemapCuisine();
|
||||||
|
|
||||||
List<Map<String, Object>> findForRemapFoods();
|
List<Map<String, Object>> findForRemapFoods();
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ public interface VideoMapper {
|
|||||||
|
|
||||||
List<Map<String, Object>> findVideosWithoutTranscript();
|
List<Map<String, Object>> findVideosWithoutTranscript();
|
||||||
|
|
||||||
|
List<Map<String, Object>> findVideosByIds(@Param("ids") List<String> ids);
|
||||||
|
|
||||||
|
List<Map<String, Object>> findVideosForExtractByIds(@Param("ids") List<String> ids);
|
||||||
|
|
||||||
void updateVideoRestaurantFields(@Param("videoId") String videoId,
|
void updateVideoRestaurantFields(@Param("videoId") String videoId,
|
||||||
@Param("restaurantId") String restaurantId,
|
@Param("restaurantId") String restaurantId,
|
||||||
@Param("foodsJson") String foodsJson,
|
@Param("foodsJson") String foodsJson,
|
||||||
|
|||||||
@@ -38,4 +38,8 @@ public class ChannelService {
|
|||||||
public Channel findByChannelId(String channelId) {
|
public Channel findByChannelId(String channelId) {
|
||||||
return mapper.findByChannelId(channelId);
|
return mapper.findByChannelId(channelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void update(String id, String description, String tags, Integer sortOrder) {
|
||||||
|
mapper.updateChannel(id, description, tags, sortOrder);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,34 @@ public class GeocodingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Korean address into region format "나라|시/도|구/군".
|
||||||
|
* Example: "대한민국 서울특별시 강남구 역삼동 123" → "한국|서울|강남구"
|
||||||
|
*/
|
||||||
|
public static String parseRegionFromAddress(String address) {
|
||||||
|
if (address == null || address.isBlank()) return null;
|
||||||
|
String[] parts = address.split("\\s+");
|
||||||
|
String country = "";
|
||||||
|
String city = "";
|
||||||
|
String district = "";
|
||||||
|
|
||||||
|
for (String p : parts) {
|
||||||
|
if (p.equals("대한민국") || p.equals("South Korea")) {
|
||||||
|
country = "한국";
|
||||||
|
} else if (p.endsWith("특별시") || p.endsWith("광역시") || p.endsWith("특별자치시")) {
|
||||||
|
city = p.replace("특별시", "").replace("광역시", "").replace("특별자치시", "");
|
||||||
|
} else if (p.endsWith("도") && !p.endsWith("동") && p.length() <= 5) {
|
||||||
|
city = p;
|
||||||
|
} else if (p.endsWith("구") || p.endsWith("군") || (p.endsWith("시") && !city.isEmpty())) {
|
||||||
|
if (district.isEmpty()) district = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (country.isEmpty() && !city.isEmpty()) country = "한국";
|
||||||
|
if (country.isEmpty()) return null;
|
||||||
|
return country + "|" + city + "|" + district;
|
||||||
|
}
|
||||||
|
|
||||||
private Map<String, Object> geocode(String query) {
|
private Map<String, Object> geocode(String query) {
|
||||||
try {
|
try {
|
||||||
String response = webClient.get()
|
String response = webClient.get()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import org.springframework.beans.factory.annotation.Value;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -45,6 +46,8 @@ public class OciGenAiService {
|
|||||||
|
|
||||||
private final ObjectMapper mapper;
|
private final ObjectMapper mapper;
|
||||||
private ConfigFileAuthenticationDetailsProvider authProvider;
|
private ConfigFileAuthenticationDetailsProvider authProvider;
|
||||||
|
private GenerativeAiInferenceClient chatClient;
|
||||||
|
private GenerativeAiInferenceClient embedClient;
|
||||||
|
|
||||||
public OciGenAiService(ObjectMapper mapper) {
|
public OciGenAiService(ObjectMapper mapper) {
|
||||||
this.mapper = mapper;
|
this.mapper = mapper;
|
||||||
@@ -55,45 +58,50 @@ public class OciGenAiService {
|
|||||||
try {
|
try {
|
||||||
ConfigFileReader.ConfigFile configFile = ConfigFileReader.parseDefault();
|
ConfigFileReader.ConfigFile configFile = ConfigFileReader.parseDefault();
|
||||||
authProvider = new ConfigFileAuthenticationDetailsProvider(configFile);
|
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) {
|
} catch (Exception e) {
|
||||||
log.warn("OCI config not found, GenAI features disabled: {}", e.getMessage());
|
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).
|
* Call OCI GenAI LLM (Chat).
|
||||||
*/
|
*/
|
||||||
public String chat(String prompt, int maxTokens) {
|
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()
|
var textContent = TextContent.builder().text(prompt).build();
|
||||||
.endpoint(chatEndpoint)
|
var userMessage = UserMessage.builder().content(List.of(textContent)).build();
|
||||||
.build(authProvider)) {
|
|
||||||
|
|
||||||
var textContent = TextContent.builder().text(prompt).build();
|
var chatRequest = GenericChatRequest.builder()
|
||||||
var userMessage = UserMessage.builder().content(List.of(textContent)).build();
|
.messages(List.of(userMessage))
|
||||||
|
.maxTokens(maxTokens)
|
||||||
|
.temperature(0.0)
|
||||||
|
.build();
|
||||||
|
|
||||||
var chatRequest = GenericChatRequest.builder()
|
var chatDetails = ChatDetails.builder()
|
||||||
.messages(List.of(userMessage))
|
.compartmentId(compartmentId)
|
||||||
.maxTokens(maxTokens)
|
.servingMode(OnDemandServingMode.builder().modelId(chatModelId).build())
|
||||||
.temperature(0.0)
|
.chatRequest(chatRequest)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
var chatDetails = ChatDetails.builder()
|
ChatResponse response = chatClient.chat(
|
||||||
.compartmentId(compartmentId)
|
ChatRequest.builder().chatDetails(chatDetails).build());
|
||||||
.servingMode(OnDemandServingMode.builder().modelId(chatModelId).build())
|
|
||||||
.chatRequest(chatRequest)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
ChatResponse response = client.chat(
|
var chatResult = (GenericChatResponse) response.getChatResult().getChatResponse();
|
||||||
ChatRequest.builder().chatDetails(chatDetails).build());
|
var choice = chatResult.getChoices().get(0);
|
||||||
|
var content = ((TextContent) choice.getMessage().getContent().get(0)).getText();
|
||||||
var chatResult = (GenericChatResponse) response.getChatResult().getChatResponse();
|
return content.trim();
|
||||||
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) {
|
private List<List<Double>> embedBatch(List<String> texts) {
|
||||||
try (var client = GenerativeAiInferenceClient.builder()
|
if (embedClient == null) throw new IllegalStateException("OCI GenAI not configured");
|
||||||
.endpoint(embedEndpoint)
|
|
||||||
.build(authProvider)) {
|
|
||||||
|
|
||||||
var embedDetails = EmbedTextDetails.builder()
|
var embedDetails = EmbedTextDetails.builder()
|
||||||
.inputs(texts)
|
.inputs(texts)
|
||||||
.servingMode(OnDemandServingMode.builder().modelId(embedModelId).build())
|
.servingMode(OnDemandServingMode.builder().modelId(embedModelId).build())
|
||||||
.compartmentId(compartmentId)
|
.compartmentId(compartmentId)
|
||||||
.inputType(EmbedTextDetails.InputType.SearchDocument)
|
.inputType(EmbedTextDetails.InputType.SearchDocument)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
EmbedTextResponse response = client.embedText(
|
EmbedTextResponse response = embedClient.embedText(
|
||||||
EmbedTextRequest.builder().embedTextDetails(embedDetails).build());
|
EmbedTextRequest.builder().embedTextDetails(embedDetails).build());
|
||||||
|
|
||||||
return response.getEmbedTextResult().getEmbeddings()
|
return response.getEmbedTextResult().getEmbeddings()
|
||||||
.stream()
|
.stream()
|
||||||
.map(emb -> emb.stream().map(Number::doubleValue).toList())
|
.map(emb -> emb.stream().map(Number::doubleValue).toList())
|
||||||
.toList();
|
.toList();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,6 +26,24 @@ public class RestaurantService {
|
|||||||
return restaurants;
|
return restaurants;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Restaurant> findWithoutTabling() {
|
||||||
|
return mapper.findWithoutTabling();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Restaurant> findWithoutCatchtable() {
|
||||||
|
return mapper.findWithoutCatchtable();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void resetTablingUrls() {
|
||||||
|
mapper.resetTablingUrls();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void resetCatchtableUrls() {
|
||||||
|
mapper.resetCatchtableUrls();
|
||||||
|
}
|
||||||
|
|
||||||
public Restaurant findById(String id) {
|
public Restaurant findById(String id) {
|
||||||
Restaurant restaurant = mapper.findById(id);
|
Restaurant restaurant = mapper.findById(id);
|
||||||
if (restaurant == null) return null;
|
if (restaurant == null) return null;
|
||||||
|
|||||||
@@ -111,6 +111,22 @@ public class VideoService {
|
|||||||
return rows.stream().map(JsonUtil::lowerKeys).toList();
|
return rows.stream().map(JsonUtil::lowerKeys).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> findVideosByIds(List<String> ids) {
|
||||||
|
var rows = mapper.findVideosByIds(ids);
|
||||||
|
return rows.stream().map(JsonUtil::lowerKeys).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> findVideosForExtractByIds(List<String> ids) {
|
||||||
|
var rows = mapper.findVideosForExtractByIds(ids);
|
||||||
|
return rows.stream().map(row -> {
|
||||||
|
var r = JsonUtil.lowerKeys(row);
|
||||||
|
Object transcript = r.get("transcript_text");
|
||||||
|
r.put("transcript", JsonUtil.readClob(transcript));
|
||||||
|
r.remove("transcript_text");
|
||||||
|
return r;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
public void updateVideoRestaurantFields(String videoId, String restaurantId,
|
public void updateVideoRestaurantFields(String videoId, String restaurantId,
|
||||||
String foodsJson, String evaluation, String guestsJson) {
|
String foodsJson, String evaluation, String guestsJson) {
|
||||||
mapper.updateVideoRestaurantFields(videoId, restaurantId, foodsJson, evaluation, guestsJson);
|
mapper.updateVideoRestaurantFields(videoId, restaurantId, foodsJson, evaluation, guestsJson);
|
||||||
|
|||||||
@@ -50,10 +50,77 @@ public class YouTubeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch videos from a YouTube channel, page by page.
|
* Fetch videos from a YouTube channel using the uploads playlist (UC→UU).
|
||||||
* Returns all pages merged into one list.
|
* This returns ALL videos unlike the Search API which caps results.
|
||||||
|
* Falls back to Search API if playlist approach fails.
|
||||||
*/
|
*/
|
||||||
public List<Map<String, Object>> fetchChannelVideos(String channelId, String publishedAfter, boolean excludeShorts) {
|
public List<Map<String, Object>> fetchChannelVideos(String channelId, String publishedAfter, boolean excludeShorts) {
|
||||||
|
// Convert channel ID UC... → uploads playlist UU...
|
||||||
|
String uploadsPlaylistId = "UU" + channelId.substring(2);
|
||||||
|
List<Map<String, Object>> allVideos = new ArrayList<>();
|
||||||
|
String nextPage = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
String pageToken = nextPage;
|
||||||
|
String response = webClient.get()
|
||||||
|
.uri(uriBuilder -> {
|
||||||
|
var b = uriBuilder.path("/playlistItems")
|
||||||
|
.queryParam("key", apiKey)
|
||||||
|
.queryParam("playlistId", uploadsPlaylistId)
|
||||||
|
.queryParam("part", "snippet")
|
||||||
|
.queryParam("maxResults", 50);
|
||||||
|
if (pageToken != null) b.queryParam("pageToken", pageToken);
|
||||||
|
return b.build();
|
||||||
|
})
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(String.class)
|
||||||
|
.block(Duration.ofSeconds(30));
|
||||||
|
|
||||||
|
JsonNode data = mapper.readTree(response);
|
||||||
|
List<Map<String, Object>> pageVideos = new ArrayList<>();
|
||||||
|
|
||||||
|
for (JsonNode item : data.path("items")) {
|
||||||
|
JsonNode snippet = item.path("snippet");
|
||||||
|
String vid = snippet.path("resourceId").path("videoId").asText();
|
||||||
|
String publishedAt = snippet.path("publishedAt").asText();
|
||||||
|
|
||||||
|
// publishedAfter 필터: 이미 스캔한 영상 이후만
|
||||||
|
if (publishedAfter != null && publishedAt.compareTo(publishedAfter) <= 0) {
|
||||||
|
// 업로드 재생목록은 최신순이므로 이전 날짜 만나면 중단
|
||||||
|
nextPage = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
pageVideos.add(Map.of(
|
||||||
|
"video_id", vid,
|
||||||
|
"title", snippet.path("title").asText(),
|
||||||
|
"published_at", publishedAt,
|
||||||
|
"url", "https://www.youtube.com/watch?v=" + vid
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (excludeShorts && !pageVideos.isEmpty()) {
|
||||||
|
pageVideos = filterShorts(pageVideos);
|
||||||
|
}
|
||||||
|
allVideos.addAll(pageVideos);
|
||||||
|
|
||||||
|
if (nextPage != null || data.has("nextPageToken")) {
|
||||||
|
nextPage = data.has("nextPageToken") ? data.path("nextPageToken").asText() : null;
|
||||||
|
}
|
||||||
|
} while (nextPage != null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("PlaylistItems API failed for {}, falling back to Search API", channelId, e);
|
||||||
|
return fetchChannelVideosViaSearch(channelId, publishedAfter, excludeShorts);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allVideos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback: fetch via Search API (may not return all videos).
|
||||||
|
*/
|
||||||
|
private List<Map<String, Object>> fetchChannelVideosViaSearch(String channelId, String publishedAfter, boolean excludeShorts) {
|
||||||
List<Map<String, Object>> allVideos = new ArrayList<>();
|
List<Map<String, Object>> allVideos = new ArrayList<>();
|
||||||
String nextPage = null;
|
String nextPage = null;
|
||||||
|
|
||||||
@@ -98,7 +165,7 @@ public class YouTubeService {
|
|||||||
|
|
||||||
nextPage = data.has("nextPageToken") ? data.path("nextPageToken").asText() : null;
|
nextPage = data.has("nextPageToken") ? data.path("nextPageToken").asText() : null;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Failed to parse YouTube API response", e);
|
log.error("Failed to parse YouTube Search API response", e);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} while (nextPage != null);
|
} while (nextPage != null);
|
||||||
@@ -108,33 +175,39 @@ public class YouTubeService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter out YouTube Shorts (<=60s duration).
|
* Filter out YouTube Shorts (<=60s duration).
|
||||||
|
* YouTube /videos API accepts max 50 IDs per request, so we batch.
|
||||||
*/
|
*/
|
||||||
private List<Map<String, Object>> filterShorts(List<Map<String, Object>> videos) {
|
private List<Map<String, Object>> filterShorts(List<Map<String, Object>> videos) {
|
||||||
String ids = String.join(",", videos.stream().map(v -> (String) v.get("video_id")).toList());
|
Map<String, Integer> durations = new HashMap<>();
|
||||||
String response = webClient.get()
|
List<String> allIds = videos.stream().map(v -> (String) v.get("video_id")).toList();
|
||||||
.uri(uriBuilder -> uriBuilder.path("/videos")
|
|
||||||
.queryParam("key", apiKey)
|
|
||||||
.queryParam("id", ids)
|
|
||||||
.queryParam("part", "contentDetails")
|
|
||||||
.build())
|
|
||||||
.retrieve()
|
|
||||||
.bodyToMono(String.class)
|
|
||||||
.block(Duration.ofSeconds(30));
|
|
||||||
|
|
||||||
try {
|
for (int i = 0; i < allIds.size(); i += 50) {
|
||||||
JsonNode data = mapper.readTree(response);
|
List<String> batch = allIds.subList(i, Math.min(i + 50, allIds.size()));
|
||||||
Map<String, Integer> durations = new HashMap<>();
|
String ids = String.join(",", batch);
|
||||||
for (JsonNode item : data.path("items")) {
|
try {
|
||||||
String duration = item.path("contentDetails").path("duration").asText();
|
String response = webClient.get()
|
||||||
durations.put(item.path("id").asText(), parseDuration(duration));
|
.uri(uriBuilder -> uriBuilder.path("/videos")
|
||||||
|
.queryParam("key", apiKey)
|
||||||
|
.queryParam("id", ids)
|
||||||
|
.queryParam("part", "contentDetails")
|
||||||
|
.build())
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(String.class)
|
||||||
|
.block(Duration.ofSeconds(30));
|
||||||
|
|
||||||
|
JsonNode data = mapper.readTree(response);
|
||||||
|
for (JsonNode item : data.path("items")) {
|
||||||
|
String duration = item.path("contentDetails").path("duration").asText();
|
||||||
|
durations.put(item.path("id").asText(), parseDuration(duration));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to fetch video durations for batch starting at {}", i, e);
|
||||||
}
|
}
|
||||||
return videos.stream()
|
|
||||||
.filter(v -> durations.getOrDefault(v.get("video_id"), 0) > 60)
|
|
||||||
.toList();
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Failed to filter shorts", e);
|
|
||||||
return videos;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return videos.stream()
|
||||||
|
.filter(v -> durations.getOrDefault(v.get("video_id"), 61) > 60)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private int parseDuration(String dur) {
|
private int parseDuration(String dur) {
|
||||||
@@ -208,16 +281,16 @@ public class YouTubeService {
|
|||||||
public TranscriptResult getTranscript(String videoId, String mode) {
|
public TranscriptResult getTranscript(String videoId, String mode) {
|
||||||
if (mode == null) mode = "auto";
|
if (mode == null) mode = "auto";
|
||||||
|
|
||||||
// 1) Fast path: youtube-transcript-api
|
// 1) Playwright headed browser (봇 판정 회피)
|
||||||
TranscriptResult apiResult = getTranscriptApi(videoId, mode);
|
TranscriptResult browserResult = getTranscriptBrowser(videoId);
|
||||||
if (apiResult != null) return apiResult;
|
if (browserResult != null) return browserResult;
|
||||||
|
|
||||||
// 2) Fallback: Playwright browser
|
// 2) Fallback: youtube-transcript-api
|
||||||
log.warn("API failed for {}, trying Playwright browser", videoId);
|
log.warn("Browser failed for {}, trying API", videoId);
|
||||||
return getTranscriptBrowser(videoId);
|
return getTranscriptApi(videoId, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
private TranscriptResult getTranscriptApi(String videoId, String mode) {
|
public TranscriptResult getTranscriptApi(String videoId, String mode) {
|
||||||
TranscriptList transcriptList;
|
TranscriptList transcriptList;
|
||||||
try {
|
try {
|
||||||
transcriptList = transcriptApi.listTranscripts(videoId);
|
transcriptList = transcriptApi.listTranscripts(videoId);
|
||||||
@@ -262,163 +335,195 @@ 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")
|
@SuppressWarnings("unchecked")
|
||||||
private TranscriptResult getTranscriptBrowser(String videoId) {
|
private TranscriptResult getTranscriptBrowser(String videoId) {
|
||||||
try (Playwright pw = Playwright.create()) {
|
try (BrowserSession session = createBrowserSession()) {
|
||||||
BrowserType.LaunchOptions launchOpts = new BrowserType.LaunchOptions()
|
return fetchTranscriptFromPage(session.page(), videoId);
|
||||||
.setHeadless(false)
|
|
||||||
.setArgs(List.of("--disable-blink-features=AutomationControlled"));
|
|
||||||
|
|
||||||
try (Browser browser = pw.chromium().launch(launchOpts)) {
|
|
||||||
Browser.NewContextOptions ctxOpts = new Browser.NewContextOptions()
|
|
||||||
.setLocale("ko-KR")
|
|
||||||
.setViewportSize(1280, 900);
|
|
||||||
|
|
||||||
BrowserContext ctx = browser.newContext(ctxOpts);
|
|
||||||
|
|
||||||
// Load YouTube cookies if available
|
|
||||||
loadCookies(ctx);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for transcript segments to appear (max ~40s)
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
log.warn("[TRANSCRIPT] No segments found via browser for {}", videoId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[TRANSCRIPT] Playwright failed for {}: {}", videoId, e.getMessage());
|
log.error("[TRANSCRIPT] Playwright failed for {}: {}", videoId, e.getMessage());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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(3000);
|
||||||
|
|
||||||
|
skipAds(page);
|
||||||
|
|
||||||
|
page.waitForTimeout(1000);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for transcript segments to appear (max ~15s)
|
||||||
|
page.waitForTimeout(2000);
|
||||||
|
for (int attempt = 0; attempt < 10; attempt++) {
|
||||||
|
page.waitForTimeout(1500);
|
||||||
|
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) * 1.5 + 2, segCount);
|
||||||
|
if (segCount > 0) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
log.warn("[TRANSCRIPT] No segments found via browser for {}", videoId);
|
||||||
|
return null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[TRANSCRIPT] Page fetch failed for {}: {}", videoId, e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void skipAds(Page page) {
|
private void skipAds(Page page) {
|
||||||
for (int i = 0; i < 12; i++) {
|
for (int i = 0; i < 30; i++) {
|
||||||
Object adStatus = page.evaluate("""
|
Object adStatus = page.evaluate("""
|
||||||
() => {
|
() => {
|
||||||
const skipBtn = document.querySelector('.ytp-skip-ad-button, .ytp-ad-skip-button, .ytp-ad-skip-button-modern, button.ytp-ad-skip-button-modern');
|
const skipBtn = document.querySelector('.ytp-skip-ad-button, .ytp-ad-skip-button, .ytp-ad-skip-button-modern, button.ytp-ad-skip-button-modern');
|
||||||
if (skipBtn) { skipBtn.click(); return 'skipped'; }
|
if (skipBtn) { skipBtn.click(); return 'skipped'; }
|
||||||
const adOverlay = document.querySelector('.ytp-ad-player-overlay, .ad-showing');
|
const adOverlay = document.querySelector('.ytp-ad-player-overlay, .ad-showing');
|
||||||
if (adOverlay) return 'playing';
|
if (adOverlay) {
|
||||||
|
// 광고 중: 뮤트 + 끝으로 이동 시도
|
||||||
|
const video = document.querySelector('video');
|
||||||
|
if (video) {
|
||||||
|
video.muted = true;
|
||||||
|
if (video.duration && isFinite(video.duration)) {
|
||||||
|
video.currentTime = video.duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'playing';
|
||||||
|
}
|
||||||
const adBadge = document.querySelector('.ytp-ad-text');
|
const adBadge = document.querySelector('.ytp-ad-text');
|
||||||
if (adBadge && adBadge.textContent) return 'badge';
|
if (adBadge && adBadge.textContent) return 'badge';
|
||||||
return 'none';
|
return 'none';
|
||||||
@@ -428,10 +533,10 @@ public class YouTubeService {
|
|||||||
if ("none".equals(status)) break;
|
if ("none".equals(status)) break;
|
||||||
log.info("[TRANSCRIPT] Ad detected: {}, waiting...", status);
|
log.info("[TRANSCRIPT] Ad detected: {}, waiting...", status);
|
||||||
if ("skipped".equals(status)) {
|
if ("skipped".equals(status)) {
|
||||||
page.waitForTimeout(2000);
|
page.waitForTimeout(1000);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
page.waitForTimeout(5000);
|
page.waitForTimeout(1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ app:
|
|||||||
expiration-days: 7
|
expiration-days: 7
|
||||||
|
|
||||||
cors:
|
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:
|
oracle:
|
||||||
wallet-path: ${ORACLE_WALLET:}
|
wallet-path: ${ORACLE_WALLET:}
|
||||||
|
|||||||
@@ -7,17 +7,20 @@
|
|||||||
<result property="channelId" column="channel_id"/>
|
<result property="channelId" column="channel_id"/>
|
||||||
<result property="channelName" column="channel_name"/>
|
<result property="channelName" column="channel_name"/>
|
||||||
<result property="titleFilter" column="title_filter"/>
|
<result property="titleFilter" column="title_filter"/>
|
||||||
|
<result property="description" column="description"/>
|
||||||
|
<result property="tags" column="tags"/>
|
||||||
|
<result property="sortOrder" column="sort_order"/>
|
||||||
<result property="videoCount" column="video_count"/>
|
<result property="videoCount" column="video_count"/>
|
||||||
<result property="lastVideoAt" column="last_video_at"/>
|
<result property="lastVideoAt" column="last_video_at"/>
|
||||||
</resultMap>
|
</resultMap>
|
||||||
|
|
||||||
<select id="findAllActive" resultMap="channelResultMap">
|
<select id="findAllActive" resultMap="channelResultMap">
|
||||||
SELECT c.id, c.channel_id, c.channel_name, c.title_filter,
|
SELECT c.id, c.channel_id, c.channel_name, c.title_filter, c.description, c.tags, c.sort_order,
|
||||||
(SELECT COUNT(*) FROM videos v WHERE v.channel_id = c.id) AS video_count,
|
(SELECT COUNT(*) FROM videos v WHERE v.channel_id = c.id) AS video_count,
|
||||||
(SELECT MAX(v.published_at) FROM videos v WHERE v.channel_id = c.id) AS last_video_at
|
(SELECT MAX(v.published_at) FROM videos v WHERE v.channel_id = c.id) AS last_video_at
|
||||||
FROM channels c
|
FROM channels c
|
||||||
WHERE c.is_active = 1
|
WHERE c.is_active = 1
|
||||||
ORDER BY c.channel_name
|
ORDER BY c.sort_order, c.channel_name
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<insert id="insert">
|
<insert id="insert">
|
||||||
@@ -35,6 +38,11 @@
|
|||||||
WHERE id = #{id} AND is_active = 1
|
WHERE id = #{id} AND is_active = 1
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
|
<update id="updateChannel">
|
||||||
|
UPDATE channels SET description = #{description}, tags = #{tags}, sort_order = #{sortOrder}
|
||||||
|
WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
<select id="findByChannelId" resultMap="channelResultMap">
|
<select id="findByChannelId" resultMap="channelResultMap">
|
||||||
SELECT id, channel_id, channel_name, title_filter
|
SELECT id, channel_id, channel_name, title_filter
|
||||||
FROM channels
|
FROM channels
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
<result property="phone" column="phone"/>
|
<result property="phone" column="phone"/>
|
||||||
<result property="website" column="website"/>
|
<result property="website" column="website"/>
|
||||||
<result property="googlePlaceId" column="google_place_id"/>
|
<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="businessStatus" column="business_status"/>
|
||||||
<result property="rating" column="rating"/>
|
<result property="rating" column="rating"/>
|
||||||
<result property="ratingCount" column="rating_count"/>
|
<result property="ratingCount" column="rating_count"/>
|
||||||
@@ -26,7 +28,7 @@
|
|||||||
|
|
||||||
<select id="findAll" resultMap="restaurantMap">
|
<select id="findAll" resultMap="restaurantMap">
|
||||||
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
|
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
|
r.business_status, r.rating, r.rating_count, r.updated_at
|
||||||
FROM restaurants r
|
FROM restaurants r
|
||||||
<if test="channel != null and channel != ''">
|
<if test="channel != null and channel != ''">
|
||||||
@@ -54,7 +56,7 @@
|
|||||||
<select id="findById" resultMap="restaurantMap">
|
<select id="findById" resultMap="restaurantMap">
|
||||||
SELECT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
|
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.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
|
FROM restaurants r
|
||||||
WHERE r.id = #{id}
|
WHERE r.id = #{id}
|
||||||
</select>
|
</select>
|
||||||
@@ -129,12 +131,30 @@
|
|||||||
<if test="fields.containsKey('website')">
|
<if test="fields.containsKey('website')">
|
||||||
website = #{fields.website},
|
website = #{fields.website},
|
||||||
</if>
|
</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')">
|
<if test="fields.containsKey('latitude')">
|
||||||
latitude = #{fields.latitude},
|
latitude = #{fields.latitude},
|
||||||
</if>
|
</if>
|
||||||
<if test="fields.containsKey('longitude')">
|
<if test="fields.containsKey('longitude')">
|
||||||
longitude = #{fields.longitude},
|
longitude = #{fields.longitude},
|
||||||
</if>
|
</if>
|
||||||
|
<if test="fields.containsKey('google_place_id')">
|
||||||
|
google_place_id = #{fields.google_place_id},
|
||||||
|
</if>
|
||||||
|
<if test="fields.containsKey('business_status')">
|
||||||
|
business_status = #{fields.business_status},
|
||||||
|
</if>
|
||||||
|
<if test="fields.containsKey('rating')">
|
||||||
|
rating = #{fields.rating},
|
||||||
|
</if>
|
||||||
|
<if test="fields.containsKey('rating_count')">
|
||||||
|
rating_count = #{fields.rating_count},
|
||||||
|
</if>
|
||||||
updated_at = SYSTIMESTAMP,
|
updated_at = SYSTIMESTAMP,
|
||||||
</trim>
|
</trim>
|
||||||
WHERE id = #{id}
|
WHERE id = #{id}
|
||||||
@@ -201,6 +221,32 @@
|
|||||||
</foreach>
|
</foreach>
|
||||||
</select>
|
</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>
|
||||||
|
|
||||||
|
<update id="resetTablingUrls">
|
||||||
|
UPDATE restaurants SET tabling_url = NULL WHERE tabling_url IS NOT NULL
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="resetCatchtableUrls">
|
||||||
|
UPDATE restaurants SET catchtable_url = NULL WHERE catchtable_url IS NOT NULL
|
||||||
|
</update>
|
||||||
|
|
||||||
<!-- ===== Remap operations ===== -->
|
<!-- ===== Remap operations ===== -->
|
||||||
|
|
||||||
<update id="updateCuisineType">
|
<update id="updateCuisineType">
|
||||||
|
|||||||
@@ -11,7 +11,11 @@
|
|||||||
<result property="longitude" column="longitude"/>
|
<result property="longitude" column="longitude"/>
|
||||||
<result property="cuisineType" column="cuisine_type"/>
|
<result property="cuisineType" column="cuisine_type"/>
|
||||||
<result property="priceRange" column="price_range"/>
|
<result property="priceRange" column="price_range"/>
|
||||||
|
<result property="phone" column="phone"/>
|
||||||
|
<result property="website" column="website"/>
|
||||||
<result property="googlePlaceId" column="google_place_id"/>
|
<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="businessStatus" column="business_status"/>
|
||||||
<result property="rating" column="rating"/>
|
<result property="rating" column="rating"/>
|
||||||
<result property="ratingCount" column="rating_count"/>
|
<result property="ratingCount" column="rating_count"/>
|
||||||
@@ -19,7 +23,8 @@
|
|||||||
|
|
||||||
<select id="keywordSearch" resultMap="restaurantMap">
|
<select id="keywordSearch" resultMap="restaurantMap">
|
||||||
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
|
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.phone, r.website, r.google_place_id,
|
||||||
|
r.tabling_url, r.catchtable_url,
|
||||||
r.business_status, r.rating, r.rating_count
|
r.business_status, r.rating, r.rating_count
|
||||||
FROM restaurants r
|
FROM restaurants r
|
||||||
JOIN video_restaurants vr ON vr.restaurant_id = r.id
|
JOIN video_restaurants vr ON vr.restaurant_id = r.id
|
||||||
|
|||||||
@@ -186,7 +186,8 @@
|
|||||||
|
|
||||||
<insert id="insertVideo">
|
<insert id="insertVideo">
|
||||||
INSERT INTO videos (id, channel_id, video_id, title, url, published_at)
|
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>
|
</insert>
|
||||||
|
|
||||||
<select id="getExistingVideoIds" resultType="string">
|
<select id="getExistingVideoIds" resultType="string">
|
||||||
@@ -194,7 +195,7 @@
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="getLatestVideoDate" resultType="string">
|
<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}
|
FROM videos WHERE channel_id = #{channelId}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
@@ -220,10 +221,30 @@
|
|||||||
SELECT id, video_id, title, url
|
SELECT id, video_id, title, url
|
||||||
FROM videos
|
FROM videos
|
||||||
WHERE (transcript_text IS NULL OR dbms_lob.getlength(transcript_text) = 0)
|
WHERE (transcript_text IS NULL OR dbms_lob.getlength(transcript_text) = 0)
|
||||||
AND status != 'skip'
|
AND status NOT IN ('skip', 'no_transcript')
|
||||||
ORDER BY created_at
|
ORDER BY created_at
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<select id="findVideosByIds" resultType="map">
|
||||||
|
SELECT id, video_id, title, url
|
||||||
|
FROM videos
|
||||||
|
WHERE id IN
|
||||||
|
<foreach item="id" collection="ids" open="(" separator="," close=")">
|
||||||
|
#{id}
|
||||||
|
</foreach>
|
||||||
|
ORDER BY created_at
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="findVideosForExtractByIds" resultType="map">
|
||||||
|
SELECT v.id, v.video_id, v.title, v.url, v.transcript_text
|
||||||
|
FROM videos v
|
||||||
|
WHERE v.id IN
|
||||||
|
<foreach item="id" collection="ids" open="(" separator="," close=")">
|
||||||
|
#{id}
|
||||||
|
</foreach>
|
||||||
|
ORDER BY v.published_at DESC
|
||||||
|
</select>
|
||||||
|
|
||||||
<update id="updateVideoRestaurantFields">
|
<update id="updateVideoRestaurantFields">
|
||||||
UPDATE video_restaurants
|
UPDATE video_restaurants
|
||||||
SET foods_mentioned = #{foodsJson,jdbcType=CLOB},
|
SET foods_mentioned = #{foodsJson,jdbcType=CLOB},
|
||||||
|
|||||||
@@ -5,5 +5,6 @@
|
|||||||
<setting name="mapUnderscoreToCamelCase" value="true"/>
|
<setting name="mapUnderscoreToCamelCase" value="true"/>
|
||||||
<setting name="callSettersOnNulls" value="true"/>
|
<setting name="callSettersOnNulls" value="true"/>
|
||||||
<setting name="returnInstanceForEmptyRow" value="true"/>
|
<setting name="returnInstanceForEmptyRow" value="true"/>
|
||||||
|
<setting name="jdbcTypeForNull" value="VARCHAR"/>
|
||||||
</settings>
|
</settings>
|
||||||
</configuration>
|
</configuration>
|
||||||
|
|||||||
16
frontend/dev-restart.sh
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build + restart dev server (standalone mode)
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "▶ Building..."
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
echo "▶ Copying static files to standalone..."
|
||||||
|
cp -r .next/static .next/standalone/.next/static
|
||||||
|
cp -r public .next/standalone/public 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "▶ Restarting PM2..."
|
||||||
|
pm2 restart tasteby-web
|
||||||
|
|
||||||
|
echo "✅ Done — http://localhost:3001"
|
||||||
85
frontend/docs/design-concepts.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Tasteby Design Concept 후보
|
||||||
|
|
||||||
|
> Oracle의 Redwood처럼, Tasteby만의 디자인 언어를 정의하기 위한 컨셉 후보안.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Saffron (사프란) 🟠
|
||||||
|
|
||||||
|
따뜻한 금빛 오렌지. 고급스러운 미식 큐레이션 느낌.
|
||||||
|
|
||||||
|
| 역할 | 색상 | Hex |
|
||||||
|
|------|------|-----|
|
||||||
|
| Primary | 깊은 오렌지 | `#E8720C` |
|
||||||
|
| Primary Light | 밝은 오렌지 | `#F59E3F` |
|
||||||
|
| Primary Dark | 진한 오렌지 | `#C45A00` |
|
||||||
|
| Accent | 골드 | `#F5A623` |
|
||||||
|
| Accent Light | 라이트 골드 | `#FFD080` |
|
||||||
|
| Background | 크림 화이트 | `#FFFAF5` |
|
||||||
|
| Surface | 웜 그레이 | `#F7F3EF` |
|
||||||
|
| Text Primary | 다크 브라운 | `#2C1810` |
|
||||||
|
| Text Secondary | 미디엄 브라운 | `#7A6555` |
|
||||||
|
|
||||||
|
**키워드**: 프리미엄, 미식, 큐레이션, 따뜻함, 신뢰
|
||||||
|
**어울리는 폰트**: Pretendard, Noto Sans KR (깔끔 + 웜톤 배경)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Gochujang (고추장) 🔴
|
||||||
|
|
||||||
|
한국 음식 DNA. 약간 붉은 오렌지 톤으로 대담하고 강렬.
|
||||||
|
|
||||||
|
| 역할 | 색상 | Hex |
|
||||||
|
|------|------|-----|
|
||||||
|
| Primary | 고추장 레드 | `#D94F30` |
|
||||||
|
| Primary Light | 밝은 레드 | `#EF7B5A` |
|
||||||
|
| Primary Dark | 진한 레드 | `#B53518` |
|
||||||
|
| Accent | 따뜻한 오렌지 | `#FF8C42` |
|
||||||
|
| Accent Light | 라이트 피치 | `#FFB88C` |
|
||||||
|
| Background | 소프트 화이트 | `#FFFBF8` |
|
||||||
|
| Surface | 웜 베이지 | `#F5F0EB` |
|
||||||
|
| Text Primary | 차콜 | `#1A1A1A` |
|
||||||
|
| Text Secondary | 다크 그레이 | `#666052` |
|
||||||
|
|
||||||
|
**키워드**: 한국, 활기, 식욕, 대담, 강렬
|
||||||
|
**어울리는 폰트**: Spoqa Han Sans Neo, Pretendard (모던 + 힘있는)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Citrus (시트러스) 🍊
|
||||||
|
|
||||||
|
밝고 상큼한 비비드 오렌지. 현대적이고 친근한 느낌.
|
||||||
|
|
||||||
|
| 역할 | 색상 | Hex |
|
||||||
|
|------|------|-----|
|
||||||
|
| Primary | 비비드 오렌지 | `#FF6B2B` |
|
||||||
|
| Primary Light | 라이트 오렌지 | `#FF9A6C` |
|
||||||
|
| Primary Dark | 딥 오렌지 | `#E04D10` |
|
||||||
|
| Accent | 피치 | `#FFB347` |
|
||||||
|
| Accent Light | 소프트 피치 | `#FFD9A0` |
|
||||||
|
| Background | 퓨어 화이트 | `#FFFFFF` |
|
||||||
|
| Surface | 쿨 그레이 | `#F5F5F7` |
|
||||||
|
| Text Primary | 뉴트럴 블랙 | `#171717` |
|
||||||
|
| Text Secondary | 미디엄 그레이 | `#6B7280` |
|
||||||
|
|
||||||
|
**키워드**: 캐주얼, 트렌디, 활발, 친근, 상큼
|
||||||
|
**어울리는 폰트**: Geist (현재 사용 중), Inter
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 현재 상태 (Before)
|
||||||
|
|
||||||
|
- Tailwind 기본 `orange` 팔레트 사용 (커스텀 없음)
|
||||||
|
- 폰트: Geist (Google Fonts)
|
||||||
|
- 다크모드: `prefers-color-scheme` 기반 자동 전환
|
||||||
|
- 브랜드 컬러 정의 없음 — 컴포넌트마다 `orange-400~700` 개별 적용
|
||||||
|
|
||||||
|
## 적용 계획
|
||||||
|
|
||||||
|
1. 컨셉 선택
|
||||||
|
2. CSS 변수로 디자인 토큰 정의 (`globals.css`)
|
||||||
|
3. Tailwind v4 `@theme` 에 커스텀 컬러 등록
|
||||||
|
4. 컴포넌트별 하드코딩된 orange → 시맨틱 토큰으로 교체
|
||||||
|
5. 다크모드 팔레트 정의
|
||||||
|
6. 폰트 교체 (필요시)
|
||||||
|
7. 로고/아이콘 톤 맞춤
|
||||||
BIN
frontend/public/logo-120h.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
frontend/public/logo-200h.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
frontend/public/logo-80h.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/public/logo-dark-120h.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
frontend/public/logo-dark-80h.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
frontend/public/logo-dark.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
frontend/public/logo.png
Normal file
|
After Width: | Height: | Size: 947 KiB |
@@ -1,23 +1,52 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Force light mode: dark: classes only activate with .dark ancestor */
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #FFFAF5;
|
||||||
--foreground: #171717;
|
--foreground: #171717;
|
||||||
color-scheme: light dark;
|
--surface: #FFFFFF;
|
||||||
|
--brand-50: #FFF8F0;
|
||||||
|
--brand-100: #FFEDD5;
|
||||||
|
--brand-200: #FFD6A5;
|
||||||
|
--brand-300: #FFBC72;
|
||||||
|
--brand-400: #F5A623;
|
||||||
|
--brand-500: #F59E3F;
|
||||||
|
--brand-600: #E8720C;
|
||||||
|
--brand-700: #C45A00;
|
||||||
|
--brand-800: #9A4500;
|
||||||
|
--brand-900: #6B3000;
|
||||||
|
--brand-950: #3D1A00;
|
||||||
|
color-scheme: only light !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist);
|
--color-surface: var(--surface);
|
||||||
|
--color-brand-50: var(--brand-50);
|
||||||
|
--color-brand-100: var(--brand-100);
|
||||||
|
--color-brand-200: var(--brand-200);
|
||||||
|
--color-brand-300: var(--brand-300);
|
||||||
|
--color-brand-400: var(--brand-400);
|
||||||
|
--color-brand-500: var(--brand-500);
|
||||||
|
--color-brand-600: var(--brand-600);
|
||||||
|
--color-brand-700: var(--brand-700);
|
||||||
|
--color-brand-800: var(--brand-800);
|
||||||
|
--color-brand-900: var(--brand-900);
|
||||||
|
--color-brand-950: var(--brand-950);
|
||||||
|
--font-sans: var(--font-pretendard), var(--font-geist), system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
/* Dark mode CSS vars (disabled — activate by adding .dark class to <html>) */
|
||||||
:root {
|
/*
|
||||||
--background: #0a0a0a;
|
.dark {
|
||||||
--foreground: #ededed;
|
--background: #12100E;
|
||||||
}
|
--foreground: #ededed;
|
||||||
|
--surface: #1C1916;
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
@@ -42,3 +71,33 @@ html, body, #__next {
|
|||||||
.gm-style .gm-style-iw-d {
|
.gm-style .gm-style-iw-d {
|
||||||
overflow: auto !important;
|
overflow: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar but keep scrolling */
|
||||||
|
@layer utilities {
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none !important;
|
||||||
|
scrollbar-width: none !important;
|
||||||
|
overflow: -moz-scrollbars-none;
|
||||||
|
}
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none !important;
|
||||||
|
width: 0 !important;
|
||||||
|
height: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Material Symbols */
|
||||||
|
.material-symbols-rounded {
|
||||||
|
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: 1;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.material-symbols-rounded.filled {
|
||||||
|
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Safe area for iOS bottom nav */
|
||||||
|
.safe-area-bottom {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist } from "next/font/google";
|
import { Geist } from "next/font/google";
|
||||||
|
import localFont from "next/font/local";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Providers } from "./providers";
|
import { Providers } from "./providers";
|
||||||
|
|
||||||
@@ -8,6 +9,14 @@ const geist = Geist({
|
|||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pretendard = localFont({
|
||||||
|
src: [
|
||||||
|
{ path: "../fonts/PretendardVariable.woff2", style: "normal" },
|
||||||
|
],
|
||||||
|
variable: "--font-pretendard",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Tasteby - YouTube Restaurant Map",
|
title: "Tasteby - YouTube Restaurant Map",
|
||||||
description: "YouTube food channel restaurant map service",
|
description: "YouTube food channel restaurant map service",
|
||||||
@@ -19,8 +28,15 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="ko" className="dark:bg-gray-950" suppressHydrationWarning>
|
<html lang="ko" className="bg-background" style={{ colorScheme: "only light" }} suppressHydrationWarning>
|
||||||
<body className={`${geist.variable} font-sans antialiased`}>
|
<head>
|
||||||
|
<meta name="color-scheme" content="only light" />
|
||||||
|
<meta name="supported-color-schemes" content="light only" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body className={`${pretendard.variable} ${geist.variable} font-sans antialiased`}>
|
||||||
<Providers>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export default function BottomSheet({ open, onClose, children }: BottomSheetProp
|
|||||||
{/* Sheet */}
|
{/* Sheet */}
|
||||||
<div
|
<div
|
||||||
ref={sheetRef}
|
ref={sheetRef}
|
||||||
className="fixed bottom-0 left-0 right-0 z-50 md:hidden flex flex-col bg-white/85 dark:bg-gray-900/90 backdrop-blur-xl rounded-t-2xl shadow-2xl"
|
className="fixed bottom-0 left-0 right-0 z-50 md:hidden flex flex-col bg-surface/85 backdrop-blur-xl rounded-t-2xl shadow-2xl"
|
||||||
style={{
|
style={{
|
||||||
height: `${height * 100}vh`,
|
height: `${height * 100}vh`,
|
||||||
transition: dragging ? "none" : "height 0.3s cubic-bezier(0.2, 0, 0, 1)",
|
transition: dragging ? "none" : "height 0.3s cubic-bezier(0.2, 0, 0, 1)",
|
||||||
|
|||||||
23
frontend/src/components/Icon.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
interface IconProps {
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
filled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material Symbols Rounded icon wrapper.
|
||||||
|
* Usage: <Icon name="search" size={20} />
|
||||||
|
*/
|
||||||
|
export default function Icon({ name, size = 20, filled, className = "" }: IconProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`material-symbols-rounded ${filled ? "filled" : ""} ${className}`}
|
||||||
|
style={{ fontSize: size }}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
frontend/src/components/LoginMenu.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { GoogleLogin } from "@react-oauth/google";
|
||||||
|
|
||||||
|
interface LoginMenuProps {
|
||||||
|
onGoogleSuccess: (credential: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginMenu({ onGoogleSuccess }: LoginMenuProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="px-3 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-brand-600 dark:hover:text-brand-400 border border-gray-300 dark:border-gray-600 hover:border-brand-400 dark:hover:border-brand-500 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
로그인
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||||
|
style={{ zIndex: 99999 }}
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) setOpen(false); }}
|
||||||
|
>
|
||||||
|
<div className="bg-surface rounded-2xl shadow-2xl p-6 mx-4 w-full max-w-xs space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-base font-semibold dark:text-gray-100">로그인</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-lg leading-none"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500">소셜 계정으로 간편 로그인</p>
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<GoogleLogin
|
||||||
|
onSuccess={(res) => {
|
||||||
|
if (res.credential) {
|
||||||
|
onGoogleSuccess(res.credential);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onError={() => console.error("Google login failed")}
|
||||||
|
size="large"
|
||||||
|
width="260"
|
||||||
|
text="signin_with"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "@vis.gl/react-google-maps";
|
} from "@vis.gl/react-google-maps";
|
||||||
import type { Restaurant } from "@/lib/api";
|
import type { Restaurant } from "@/lib/api";
|
||||||
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
||||||
|
import Icon from "@/components/Icon";
|
||||||
|
|
||||||
const SEOUL_CENTER = { lat: 37.5665, lng: 126.978 };
|
const SEOUL_CENTER = { lat: 37.5665, lng: 126.978 };
|
||||||
const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "";
|
const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "";
|
||||||
@@ -57,33 +58,14 @@ interface MapViewProps {
|
|||||||
onSelectRestaurant?: (r: Restaurant) => void;
|
onSelectRestaurant?: (r: Restaurant) => void;
|
||||||
onBoundsChanged?: (bounds: MapBounds) => void;
|
onBoundsChanged?: (bounds: MapBounds) => void;
|
||||||
flyTo?: FlyTo | null;
|
flyTo?: FlyTo | null;
|
||||||
|
onMyLocation?: () => void;
|
||||||
|
activeChannel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MapContent({ restaurants, selected, onSelectRestaurant, onBoundsChanged, flyTo }: MapViewProps) {
|
function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeChannel }: Omit<MapViewProps, "onMyLocation" | "onBoundsChanged">) {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const [infoTarget, setInfoTarget] = useState<Restaurant | null>(null);
|
const [infoTarget, setInfoTarget] = useState<Restaurant | null>(null);
|
||||||
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
||||||
const boundsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
// Report bounds on idle (debounced)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!map) return;
|
|
||||||
const listener = map.addListener("idle", () => {
|
|
||||||
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
|
|
||||||
boundsTimerRef.current = setTimeout(() => {
|
|
||||||
const b = map.getBounds();
|
|
||||||
if (b && onBoundsChanged) {
|
|
||||||
const ne = b.getNorthEast();
|
|
||||||
const sw = b.getSouthWest();
|
|
||||||
onBoundsChanged({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() });
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
google.maps.event.removeListener(listener);
|
|
||||||
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
|
|
||||||
};
|
|
||||||
}, [map, onBoundsChanged]);
|
|
||||||
|
|
||||||
const handleMarkerClick = useCallback(
|
const handleMarkerClick = useCallback(
|
||||||
(r: Restaurant) => {
|
(r: Restaurant) => {
|
||||||
@@ -113,7 +95,8 @@ function MapContent({ restaurants, selected, onSelectRestaurant, onBoundsChanged
|
|||||||
{restaurants.map((r) => {
|
{restaurants.map((r) => {
|
||||||
const isSelected = selected?.id === r.id;
|
const isSelected = selected?.id === r.id;
|
||||||
const isClosed = r.business_status === "CLOSED_PERMANENTLY";
|
const isClosed = r.business_status === "CLOSED_PERMANENTLY";
|
||||||
const chColor = r.channels?.[0] ? channelColors[r.channels[0]] : CHANNEL_COLORS[0];
|
const chKey = activeChannel && r.channels?.includes(activeChannel) ? activeChannel : r.channels?.[0];
|
||||||
|
const chColor = chKey ? channelColors[chKey] : CHANNEL_COLORS[0];
|
||||||
const c = chColor || CHANNEL_COLORS[0];
|
const c = chColor || CHANNEL_COLORS[0];
|
||||||
return (
|
return (
|
||||||
<AdvancedMarker
|
<AdvancedMarker
|
||||||
@@ -142,7 +125,7 @@ function MapContent({ restaurants, selected, onSelectRestaurant, onBoundsChanged
|
|||||||
textDecoration: isClosed ? "line-through" : "none",
|
textDecoration: isClosed ? "line-through" : "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ marginRight: 3 }}>{getCuisineIcon(r.cuisine_type)}</span>
|
<span className="material-symbols-rounded" style={{ fontSize: 14, marginRight: 3, verticalAlign: "middle", color: "#E8720C" }}>{getCuisineIcon(r.cuisine_type)}</span>
|
||||||
{r.name}
|
{r.name}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -167,7 +150,7 @@ function MapContent({ restaurants, selected, onSelectRestaurant, onBoundsChanged
|
|||||||
>
|
>
|
||||||
<div style={{ backgroundColor: "#ffffff", color: "#171717", colorScheme: "light" }} className="max-w-xs p-1">
|
<div style={{ backgroundColor: "#ffffff", color: "#171717", colorScheme: "light" }} className="max-w-xs p-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="font-bold text-base" style={{ color: "#171717" }}>{getCuisineIcon(infoTarget.cuisine_type)} {infoTarget.name}</h3>
|
<h3 className="font-bold text-base" style={{ color: "#171717" }}><span className="material-symbols-rounded" style={{ fontSize: 18, verticalAlign: "middle", color: "#E8720C", marginRight: 4 }}>{getCuisineIcon(infoTarget.cuisine_type)}</span>{infoTarget.name}</h3>
|
||||||
{infoTarget.business_status === "CLOSED_PERMANENTLY" && (
|
{infoTarget.business_status === "CLOSED_PERMANENTLY" && (
|
||||||
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold">폐업</span>
|
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold">폐업</span>
|
||||||
)}
|
)}
|
||||||
@@ -208,30 +191,55 @@ function MapContent({ restaurants, selected, onSelectRestaurant, onBoundsChanged
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MapView({ restaurants, selected, onSelectRestaurant, onBoundsChanged, flyTo }: MapViewProps) {
|
export default function MapView({ restaurants, selected, onSelectRestaurant, onBoundsChanged, flyTo, onMyLocation, activeChannel }: MapViewProps) {
|
||||||
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
||||||
const channelNames = useMemo(() => Object.keys(channelColors), [channelColors]);
|
const channelNames = useMemo(() => {
|
||||||
|
const names = Object.keys(channelColors);
|
||||||
|
if (activeChannel) return names.filter((n) => n === activeChannel);
|
||||||
|
return names;
|
||||||
|
}, [channelColors, activeChannel]);
|
||||||
|
const boundsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const handleCameraChanged = useCallback((ev: { detail: { bounds: { north: number; south: number; east: number; west: number } } }) => {
|
||||||
|
if (!onBoundsChanged) return;
|
||||||
|
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
|
||||||
|
boundsTimerRef.current = setTimeout(() => {
|
||||||
|
const { north, south, east, west } = ev.detail.bounds;
|
||||||
|
onBoundsChanged({ north, south, east, west });
|
||||||
|
}, 150);
|
||||||
|
}, [onBoundsChanged]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<APIProvider apiKey={API_KEY}>
|
<APIProvider apiKey={API_KEY}>
|
||||||
<Map
|
<Map
|
||||||
defaultCenter={SEOUL_CENTER}
|
defaultCenter={SEOUL_CENTER}
|
||||||
defaultZoom={12}
|
defaultZoom={13}
|
||||||
mapId="tasteby-map"
|
mapId="tasteby-map"
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
colorScheme="LIGHT"
|
colorScheme="LIGHT"
|
||||||
mapTypeControl={false}
|
mapTypeControl={false}
|
||||||
|
fullscreenControl={false}
|
||||||
|
onCameraChanged={handleCameraChanged}
|
||||||
>
|
>
|
||||||
<MapContent
|
<MapContent
|
||||||
restaurants={restaurants}
|
restaurants={restaurants}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
onSelectRestaurant={onSelectRestaurant}
|
onSelectRestaurant={onSelectRestaurant}
|
||||||
onBoundsChanged={onBoundsChanged}
|
|
||||||
flyTo={flyTo}
|
flyTo={flyTo}
|
||||||
|
activeChannel={activeChannel}
|
||||||
/>
|
/>
|
||||||
</Map>
|
</Map>
|
||||||
{channelNames.length > 1 && (
|
{onMyLocation && (
|
||||||
<div className="absolute bottom-2 left-2 bg-white/90 dark:bg-gray-900/90 backdrop-blur-sm rounded-lg shadow px-2.5 py-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] z-10">
|
<button
|
||||||
|
onClick={onMyLocation}
|
||||||
|
className="absolute top-2 right-2 w-9 h-9 bg-surface rounded-lg shadow-md flex items-center justify-center text-gray-600 dark:text-gray-300 hover:text-brand-500 dark:hover:text-brand-400 transition-colors z-10"
|
||||||
|
title="내 위치"
|
||||||
|
>
|
||||||
|
<Icon name="my_location" size={20} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{channelNames.length > 0 && (
|
||||||
|
<div className="absolute bottom-2 left-2 bg-surface/90 backdrop-blur-sm rounded-lg shadow px-2.5 py-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] z-10">
|
||||||
{channelNames.map((ch) => (
|
{channelNames.map((ch) => (
|
||||||
<div key={ch} className="flex items-center gap-1">
|
<div key={ch} className="flex items-center gap-1">
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { api, getToken } from "@/lib/api";
|
|||||||
import type { Restaurant, VideoLink } from "@/lib/api";
|
import type { Restaurant, VideoLink } from "@/lib/api";
|
||||||
import ReviewSection from "@/components/ReviewSection";
|
import ReviewSection from "@/components/ReviewSection";
|
||||||
import { RestaurantDetailSkeleton } from "@/components/Skeleton";
|
import { RestaurantDetailSkeleton } from "@/components/Skeleton";
|
||||||
|
import Icon from "@/components/Icon";
|
||||||
|
|
||||||
interface RestaurantDetailProps {
|
interface RestaurantDetailProps {
|
||||||
restaurant: Restaurant;
|
restaurant: Restaurant;
|
||||||
@@ -60,7 +61,7 @@ export default function RestaurantDetail({
|
|||||||
}`}
|
}`}
|
||||||
title={favorited ? "찜 해제" : "찜하기"}
|
title={favorited ? "찜 해제" : "찜하기"}
|
||||||
>
|
>
|
||||||
{favorited ? "♥" : "♡"}
|
<Icon name="favorite" size={20} filled={favorited} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{restaurant.business_status === "CLOSED_PERMANENTLY" && (
|
{restaurant.business_status === "CLOSED_PERMANENTLY" && (
|
||||||
@@ -78,12 +79,13 @@ export default function RestaurantDetail({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl leading-none"
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl leading-none"
|
||||||
>
|
>
|
||||||
x
|
<Icon name="close" size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{restaurant.rating && (
|
{restaurant.rating && (
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-blue-500 dark:text-blue-400 font-medium text-xs">Google</span>
|
||||||
<span className="text-yellow-500 dark:text-yellow-400">{"★".repeat(Math.round(restaurant.rating))}</span>
|
<span className="text-yellow-500 dark:text-yellow-400">{"★".repeat(Math.round(restaurant.rating))}</span>
|
||||||
<span className="font-medium dark:text-gray-200">{restaurant.rating}</span>
|
<span className="font-medium dark:text-gray-200">{restaurant.rating}</span>
|
||||||
{restaurant.rating_count && (
|
{restaurant.rating_count && (
|
||||||
@@ -116,25 +118,59 @@ export default function RestaurantDetail({
|
|||||||
{restaurant.phone && (
|
{restaurant.phone && (
|
||||||
<p>
|
<p>
|
||||||
<span className="text-gray-500 dark:text-gray-400">전화:</span>{" "}
|
<span className="text-gray-500 dark:text-gray-400">전화:</span>{" "}
|
||||||
<a href={`tel:${restaurant.phone}`} className="text-orange-600 dark:text-orange-400 hover:underline">
|
<a href={`tel:${restaurant.phone}`} className="text-brand-600 dark:text-brand-400 hover:underline">
|
||||||
{restaurant.phone}
|
{restaurant.phone}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{restaurant.google_place_id && (
|
{restaurant.google_place_id && (
|
||||||
<p>
|
<p className="flex gap-3">
|
||||||
<a
|
<a
|
||||||
href={`https://www.google.com/maps/place/?q=place_id:${restaurant.google_place_id}`}
|
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(restaurant.name + (restaurant.address ? " " + restaurant.address : restaurant.region ? " " + restaurant.region.replace(/\|/g, " ") : ""))}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-orange-600 dark:text-orange-400 hover:underline text-xs"
|
className="text-brand-600 dark:text-brand-400 hover:underline text-xs"
|
||||||
>
|
>
|
||||||
Google Maps에서 보기
|
Google Maps에서 보기
|
||||||
</a>
|
</a>
|
||||||
|
{(!restaurant.region || restaurant.region.split("|")[0] === "한국") && (
|
||||||
|
<a
|
||||||
|
href={`https://map.naver.com/v5/search/${encodeURIComponent(restaurant.name)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-green-600 dark:text-green-400 hover:underline text-xs"
|
||||||
|
>
|
||||||
|
네이버 지도에서 보기
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{restaurant.tabling_url && restaurant.tabling_url !== "NONE" && (
|
||||||
|
<a
|
||||||
|
href={restaurant.tabling_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 bg-rose-500 hover:bg-rose-600 text-white rounded-lg text-sm font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
<span>T</span>
|
||||||
|
<span>테이블링에서 줄서기</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{restaurant.catchtable_url && restaurant.catchtable_url !== "NONE" && (
|
||||||
|
<a
|
||||||
|
href={restaurant.catchtable_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 bg-violet-500 hover:bg-violet-600 text-white rounded-lg text-sm font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
<span>C</span>
|
||||||
|
<span>캐치테이블에서 예약하기</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-sm mb-2 dark:text-gray-200">관련 영상</h3>
|
<h3 className="font-semibold text-sm mb-2 dark:text-gray-200">관련 영상</h3>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -161,7 +197,8 @@ export default function RestaurantDetail({
|
|||||||
<div key={v.video_id} className="border dark:border-gray-700 rounded-lg p-3">
|
<div key={v.video_id} className="border dark:border-gray-700 rounded-lg p-3">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
{v.channel_name && (
|
{v.channel_name && (
|
||||||
<span className="inline-block px-1.5 py-0.5 bg-orange-50 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 rounded text-[10px] font-medium">
|
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-400 rounded text-[10px] font-semibold">
|
||||||
|
<Icon name="play_circle" size={11} filled className="text-red-400" />
|
||||||
{v.channel_name}
|
{v.channel_name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -175,8 +212,9 @@ export default function RestaurantDetail({
|
|||||||
href={v.url}
|
href={v.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="block text-sm font-medium text-orange-600 dark:text-orange-400 hover:underline"
|
className="inline-flex items-center gap-1.5 text-sm font-medium text-red-600 dark:text-red-400 hover:underline"
|
||||||
>
|
>
|
||||||
|
<Icon name="play_circle" size={16} filled className="flex-shrink-0" />
|
||||||
{v.title}
|
{v.title}
|
||||||
</a>
|
</a>
|
||||||
{v.foods_mentioned.length > 0 && (
|
{v.foods_mentioned.length > 0 && (
|
||||||
@@ -184,7 +222,7 @@ export default function RestaurantDetail({
|
|||||||
{v.foods_mentioned.map((f, i) => (
|
{v.foods_mentioned.map((f, i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
className="px-2 py-0.5 bg-orange-50 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 rounded text-xs"
|
className="px-2 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400 rounded text-xs"
|
||||||
>
|
>
|
||||||
{f}
|
{f}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { Restaurant } from "@/lib/api";
|
import type { Restaurant } from "@/lib/api";
|
||||||
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
||||||
|
import Icon from "@/components/Icon";
|
||||||
import { RestaurantListSkeleton } from "@/components/Skeleton";
|
import { RestaurantListSkeleton } from "@/components/Skeleton";
|
||||||
|
|
||||||
interface RestaurantListProps {
|
interface RestaurantListProps {
|
||||||
@@ -39,13 +40,13 @@ export default function RestaurantList({
|
|||||||
onClick={() => onSelect(r)}
|
onClick={() => onSelect(r)}
|
||||||
className={`w-full text-left p-3 rounded-xl shadow-sm border transition-all hover:shadow-md hover:-translate-y-0.5 ${
|
className={`w-full text-left p-3 rounded-xl shadow-sm border transition-all hover:shadow-md hover:-translate-y-0.5 ${
|
||||||
selectedId === r.id
|
selectedId === r.id
|
||||||
? "bg-orange-50 dark:bg-orange-900/20 border-orange-300 dark:border-orange-700 shadow-orange-100 dark:shadow-orange-900/10"
|
? "bg-brand-50 dark:bg-brand-900/20 border-brand-300 dark:border-brand-700 shadow-brand-100 dark:shadow-brand-900/10"
|
||||||
: "bg-white dark:bg-gray-900 border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800"
|
: "bg-surface border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<h4 className="font-semibold text-sm dark:text-gray-100">
|
<h4 className="font-semibold text-sm text-gray-900 dark:text-gray-100">
|
||||||
<span className="mr-1">{getCuisineIcon(r.cuisine_type)}</span>
|
<Icon name={getCuisineIcon(r.cuisine_type)} size={16} className="mr-0.5 text-brand-600" />
|
||||||
{r.name}
|
{r.name}
|
||||||
</h4>
|
</h4>
|
||||||
{r.rating && (
|
{r.rating && (
|
||||||
@@ -56,27 +57,27 @@ export default function RestaurantList({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-x-2 gap-y-0.5 mt-1.5 text-xs">
|
<div className="flex flex-wrap gap-x-2 gap-y-0.5 mt-1.5 text-xs">
|
||||||
{r.cuisine_type && (
|
{r.cuisine_type && (
|
||||||
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-600 dark:text-gray-400">{r.cuisine_type}</span>
|
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-700 dark:text-gray-400">{r.cuisine_type}</span>
|
||||||
)}
|
)}
|
||||||
{r.price_range && (
|
{r.price_range && (
|
||||||
<span className="px-1.5 py-0.5 bg-gray-50 dark:bg-gray-800 rounded text-gray-600 dark:text-gray-400">{r.price_range}</span>
|
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-700 dark:text-gray-400">{r.price_range}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{r.region && (
|
{r.region && (
|
||||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500 truncate">{r.region}</p>
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-500 truncate">{r.region}</p>
|
||||||
)}
|
)}
|
||||||
{r.foods_mentioned && r.foods_mentioned.length > 0 && (
|
{r.foods_mentioned && r.foods_mentioned.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||||
{r.foods_mentioned.slice(0, 5).map((f, i) => (
|
{r.foods_mentioned.slice(0, 5).map((f, i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
className="px-1.5 py-0.5 bg-orange-50 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 rounded text-[10px]"
|
className="px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400 rounded text-[10px]"
|
||||||
>
|
>
|
||||||
{f}
|
{f}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{r.foods_mentioned.length > 5 && (
|
{r.foods_mentioned.length > 5 && (
|
||||||
<span className="text-[10px] text-gray-400">+{r.foods_mentioned.length - 5}</span>
|
<span className="text-[10px] text-gray-500">+{r.foods_mentioned.length - 5}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -85,8 +86,9 @@ export default function RestaurantList({
|
|||||||
{r.channels.map((ch) => (
|
{r.channels.map((ch) => (
|
||||||
<span
|
<span
|
||||||
key={ch}
|
key={ch}
|
||||||
className="px-1.5 py-0.5 bg-orange-50 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 rounded-full text-[10px] font-medium"
|
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 rounded-full text-[10px] font-medium"
|
||||||
>
|
>
|
||||||
|
<Icon name="play_circle" size={11} filled className="shrink-0 text-red-400" />
|
||||||
{ch}
|
{ch}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ function ReviewForm({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="px-3 py-1 bg-orange-500 dark:bg-orange-600 text-white text-sm rounded hover:bg-orange-600 dark:hover:bg-orange-500 disabled:opacity-50"
|
className="px-3 py-1 bg-brand-500 dark:bg-brand-600 text-white text-sm rounded hover:bg-brand-600 dark:hover:bg-brand-500 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{submitting ? "저장 중..." : submitLabel}
|
{submitting ? "저장 중..." : submitLabel}
|
||||||
</button>
|
</button>
|
||||||
@@ -225,7 +225,7 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
|
|||||||
{user && !myReview && !showForm && (
|
{user && !myReview && !showForm && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowForm(true)}
|
onClick={() => setShowForm(true)}
|
||||||
className="mb-3 px-3 py-1 bg-orange-500 dark:bg-orange-600 text-white text-sm rounded hover:bg-orange-600 dark:hover:bg-orange-500"
|
className="mb-3 px-3 py-1 bg-brand-500 dark:bg-brand-600 text-white text-sm rounded hover:bg-brand-600 dark:hover:bg-brand-500"
|
||||||
>
|
>
|
||||||
리뷰 작성
|
리뷰 작성
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import Icon from "@/components/Icon";
|
||||||
|
|
||||||
interface SearchBarProps {
|
interface SearchBarProps {
|
||||||
onSearch: (query: string, mode: "keyword" | "semantic" | "hybrid") => void;
|
onSearch: (query: string, mode: "keyword" | "semantic" | "hybrid") => void;
|
||||||
@@ -9,40 +10,31 @@ interface SearchBarProps {
|
|||||||
|
|
||||||
export default function SearchBar({ onSearch, isLoading }: SearchBarProps) {
|
export default function SearchBar({ onSearch, isLoading }: SearchBarProps) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [mode, setMode] = useState<"keyword" | "semantic" | "hybrid">("hybrid");
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (query.trim()) {
|
if (query.trim()) {
|
||||||
onSearch(query.trim(), mode);
|
onSearch(query.trim(), "hybrid");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="flex gap-1.5 items-center">
|
<form onSubmit={handleSubmit} className="relative">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
|
||||||
|
<Icon name="search" size={16} />
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="식당, 지역, 음식..."
|
placeholder="식당, 지역, 음식 검색..."
|
||||||
className="flex-1 min-w-0 px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-400 text-sm bg-white dark:bg-gray-800 dark:text-gray-200 dark:placeholder-gray-500"
|
className="w-full pl-9 pr-3 py-2 bg-gray-100 dark:bg-gray-800 border border-transparent focus:border-brand-400 focus:bg-surface rounded-xl text-sm outline-none transition-all dark:text-gray-200 dark:placeholder-gray-500"
|
||||||
/>
|
/>
|
||||||
<select
|
{isLoading && (
|
||||||
value={mode}
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
onChange={(e) => setMode(e.target.value as typeof mode)}
|
<div className="w-4 h-4 border-2 border-brand-400 border-t-transparent rounded-full animate-spin" />
|
||||||
className="shrink-0 px-2 py-2 border border-gray-300 dark:border-gray-700 rounded-lg text-sm bg-white dark:bg-gray-800 dark:text-gray-300"
|
</div>
|
||||||
>
|
)}
|
||||||
<option value="hybrid">통합</option>
|
|
||||||
<option value="keyword">키워드</option>
|
|
||||||
<option value="semantic">의미</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading || !query.trim()}
|
|
||||||
className="shrink-0 px-3 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 disabled:opacity-50 text-sm"
|
|
||||||
>
|
|
||||||
{isLoading ? "..." : "검색"}
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
frontend/src/fonts/PretendardVariable.woff2
Normal file
@@ -42,6 +42,8 @@ export interface Restaurant {
|
|||||||
cuisine_type: string | null;
|
cuisine_type: string | null;
|
||||||
price_range: string | null;
|
price_range: string | null;
|
||||||
google_place_id: string | null;
|
google_place_id: string | null;
|
||||||
|
tabling_url: string | null;
|
||||||
|
catchtable_url: string | null;
|
||||||
business_status: string | null;
|
business_status: string | null;
|
||||||
rating: number | null;
|
rating: number | null;
|
||||||
rating_count: number | null;
|
rating_count: number | null;
|
||||||
@@ -68,6 +70,9 @@ export interface Channel {
|
|||||||
channel_id: string;
|
channel_id: string;
|
||||||
channel_name: string;
|
channel_name: string;
|
||||||
title_filter: string | null;
|
title_filter: string | null;
|
||||||
|
description: string | null;
|
||||||
|
tags: string | null;
|
||||||
|
sort_order: number | null;
|
||||||
video_count: number;
|
video_count: number;
|
||||||
last_scanned_at: string | null;
|
last_scanned_at: string | null;
|
||||||
}
|
}
|
||||||
@@ -322,6 +327,39 @@ export const api = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
searchTabling(restaurantId: string) {
|
||||||
|
return fetchApi<{ title: string; url: string }[]>(
|
||||||
|
`/api/restaurants/${restaurantId}/tabling-search`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
setTablingUrl(restaurantId: string, tablingUrl: string) {
|
||||||
|
return fetchApi<{ ok: boolean }>(
|
||||||
|
`/api/restaurants/${restaurantId}/tabling-url`,
|
||||||
|
{ method: "PUT", body: JSON.stringify({ tabling_url: tablingUrl }) }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
searchCatchtable(restaurantId: string) {
|
||||||
|
return fetchApi<{ title: string; url: string }[]>(
|
||||||
|
`/api/restaurants/${restaurantId}/catchtable-search`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
setCatchtableUrl(restaurantId: string, catchtableUrl: string) {
|
||||||
|
return fetchApi<{ ok: boolean }>(
|
||||||
|
`/api/restaurants/${restaurantId}/catchtable-url`,
|
||||||
|
{ method: "PUT", body: JSON.stringify({ catchtable_url: catchtableUrl }) }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateChannel(id: string, data: { description?: string; tags?: string; sort_order?: number }) {
|
||||||
|
return fetchApi<{ ok: boolean }>(`/api/channels/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
deleteChannel(channelId: string) {
|
deleteChannel(channelId: string) {
|
||||||
return fetchApi<{ ok: boolean }>(`/api/channels/${channelId}`, {
|
return fetchApi<{ ok: boolean }>(`/api/channels/${channelId}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
@@ -381,6 +419,13 @@ export const api = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
uploadTranscript(videoDbId: string, text: string, source: string = "browser") {
|
||||||
|
return fetchApi<{ ok: boolean; length: number; source: string }>(
|
||||||
|
`/api/videos/${videoDbId}/upload-transcript`,
|
||||||
|
{ method: "POST", body: JSON.stringify({ text, source }) }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
triggerProcessing(limit: number = 5) {
|
triggerProcessing(limit: number = 5) {
|
||||||
return fetchApi<{ restaurants_extracted: number }>(
|
return fetchApi<{ restaurants_extracted: number }>(
|
||||||
`/api/videos/process?limit=${limit}`,
|
`/api/videos/process?limit=${limit}`,
|
||||||
@@ -462,6 +507,12 @@ export const api = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
flushCache() {
|
||||||
|
return fetchApi<{ ok: boolean }>("/api/admin/cache-flush", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
runDaemonProcess(limit: number = 10) {
|
runDaemonProcess(limit: number = 10) {
|
||||||
return fetchApi<{ ok: boolean; restaurants_extracted: number }>(
|
return fetchApi<{ ok: boolean; restaurants_extracted: number }>(
|
||||||
`/api/daemon/run/process?limit=${limit}`,
|
`/api/daemon/run/process?limit=${limit}`,
|
||||||
|
|||||||
@@ -1,39 +1,39 @@
|
|||||||
/**
|
/**
|
||||||
* Cuisine type → icon mapping.
|
* Cuisine type → Material Symbols icon name mapping.
|
||||||
* Works with "대분류|소분류" format (e.g. "한식|국밥/해장국").
|
* Works with "대분류|소분류" format (e.g. "한식|국밥/해장국").
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CUISINE_ICON_MAP: Record<string, string> = {
|
const CUISINE_ICON_MAP: Record<string, string> = {
|
||||||
"한식": "🍚",
|
"한식": "rice_bowl",
|
||||||
"일식": "🍣",
|
"일식": "set_meal",
|
||||||
"중식": "🥟",
|
"중식": "ramen_dining",
|
||||||
"양식": "🍝",
|
"양식": "dinner_dining",
|
||||||
"아시아": "🍜",
|
"아시아": "ramen_dining",
|
||||||
"기타": "🍴",
|
"기타": "flatware",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sub-category overrides for more specific icons
|
// Sub-category overrides for more specific icons
|
||||||
const SUB_ICON_RULES: { keyword: string; icon: string }[] = [
|
const SUB_ICON_RULES: { keyword: string; icon: string }[] = [
|
||||||
{ keyword: "회/횟집", icon: "🐟" },
|
{ keyword: "회/횟집", icon: "set_meal" },
|
||||||
{ keyword: "해산물", icon: "🦐" },
|
{ keyword: "해산물", icon: "set_meal" },
|
||||||
{ keyword: "삼겹살/돼지구이", icon: "🥩" },
|
{ keyword: "삼겹살/돼지구이", icon: "kebab_dining" },
|
||||||
{ keyword: "소고기/한우구이", icon: "🥩" },
|
{ keyword: "소고기/한우구이", icon: "kebab_dining" },
|
||||||
{ keyword: "곱창/막창", icon: "🥩" },
|
{ keyword: "곱창/막창", icon: "kebab_dining" },
|
||||||
{ keyword: "닭/오리구이", icon: "🍗" },
|
{ keyword: "닭/오리구이", icon: "takeout_dining" },
|
||||||
{ keyword: "스테이크", icon: "🥩" },
|
{ keyword: "스테이크", icon: "kebab_dining" },
|
||||||
{ keyword: "햄버거", icon: "🍔" },
|
{ keyword: "햄버거", icon: "lunch_dining" },
|
||||||
{ keyword: "피자", icon: "🍕" },
|
{ keyword: "피자", icon: "local_pizza" },
|
||||||
{ keyword: "카페/디저트", icon: "☕" },
|
{ keyword: "카페/디저트", icon: "coffee" },
|
||||||
{ keyword: "베이커리", icon: "🥐" },
|
{ keyword: "베이커리", icon: "bakery_dining" },
|
||||||
{ keyword: "치킨", icon: "🍗" },
|
{ keyword: "치킨", icon: "takeout_dining" },
|
||||||
{ keyword: "주점/포차", icon: "🍺" },
|
{ keyword: "주점/포차", icon: "local_bar" },
|
||||||
{ keyword: "이자카야", icon: "🍶" },
|
{ keyword: "이자카야", icon: "sake" },
|
||||||
{ keyword: "라멘", icon: "🍜" },
|
{ keyword: "라멘", icon: "ramen_dining" },
|
||||||
{ keyword: "국밥/해장국", icon: "🍲" },
|
{ keyword: "국밥/해장국", icon: "soup_kitchen" },
|
||||||
{ keyword: "분식", icon: "🍜" },
|
{ keyword: "분식", icon: "ramen_dining" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_ICON = "🍴";
|
const DEFAULT_ICON = "flatware";
|
||||||
|
|
||||||
export function getCuisineIcon(cuisineType: string | null | undefined): string {
|
export function getCuisineIcon(cuisineType: string | null | undefined): string {
|
||||||
if (!cuisineType) return DEFAULT_ICON;
|
if (!cuisineType) return DEFAULT_ICON;
|
||||||
|
|||||||
41
nginx.conf
@@ -4,6 +4,47 @@ server {
|
|||||||
return 301 https://www.tasteby.net$request_uri;
|
return 301 https://www.tasteby.net$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name dev.tasteby.net;
|
||||||
|
return 301 https://dev.tasteby.net$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name dev.tasteby.net;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/dev.tasteby.net/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/dev.tasteby.net/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
# Frontend (Next.js)
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend API
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 300;
|
||||||
|
proxy_connect_timeout 75;
|
||||||
|
proxy_send_timeout 300;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
server_name tasteby.net;
|
server_name tasteby.net;
|
||||||
|
|||||||