Compare commits
2 Commits
v0.1.2
...
2a0ee1d2cc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a0ee1d2cc | ||
|
|
0f985d52a9 |
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,14 @@ public class ChannelController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, String> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
channelService.update(id, body.get("description"), body.get("tags"));
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{channelId}")
|
||||
public Map<String, Object> delete(@PathVariable String channelId) {
|
||||
AuthUtil.requireAdmin();
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.microsoft.playwright.*;
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.CacheService;
|
||||
import com.tasteby.service.GeocodingService;
|
||||
import com.tasteby.service.RestaurantService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -31,12 +32,14 @@ public class RestaurantController {
|
||||
private static final Logger log = LoggerFactory.getLogger(RestaurantController.class);
|
||||
|
||||
private final RestaurantService restaurantService;
|
||||
private final GeocodingService geocodingService;
|
||||
private final CacheService cache;
|
||||
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.geocodingService = geocodingService;
|
||||
this.cache = cache;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
@@ -82,11 +85,43 @@ public class RestaurantController {
|
||||
AuthUtil.requireAdmin();
|
||||
var r = restaurantService.findById(id);
|
||||
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);
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
var updated = restaurantService.findById(id);
|
||||
return Map.of("ok", true, "restaurant", updated);
|
||||
}
|
||||
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public Map<String, Object> delete(@PathVariable String id) {
|
||||
AuthUtil.requireAdmin();
|
||||
|
||||
@@ -252,6 +252,34 @@ public class VideoController {
|
||||
if (body.containsKey(key)) restFields.put(key, body.get(key));
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -50,13 +50,20 @@ public class VideoSseController {
|
||||
}
|
||||
|
||||
@PostMapping("/bulk-transcript")
|
||||
public SseEmitter bulkTranscript() {
|
||||
public SseEmitter bulkTranscript(@RequestBody(required = false) Map<String, Object> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
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(() -> {
|
||||
try {
|
||||
var videos = videoService.findVideosWithoutTranscript();
|
||||
var videos = selectedIds != null && !selectedIds.isEmpty()
|
||||
? videoService.findVideosByIds(selectedIds)
|
||||
: videoService.findVideosWithoutTranscript();
|
||||
int total = videos.size();
|
||||
emit(emitter, Map.of("type", "start", "total", total));
|
||||
|
||||
@@ -69,6 +76,8 @@ public class VideoSseController {
|
||||
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);
|
||||
@@ -76,18 +85,48 @@ public class VideoSseController {
|
||||
String title = (String) v.get("title");
|
||||
String id = (String) v.get("id");
|
||||
|
||||
emit(emitter, Map.of("type", "processing", "index", i, "title", title));
|
||||
emit(emitter, Map.of("type", "processing", "index", i, "title", title, "method", "browser"));
|
||||
|
||||
try {
|
||||
// Playwright browser first (reuse page)
|
||||
var result = youTubeService.getTranscriptWithPage(session.page(), videoId);
|
||||
|
||||
// Fallback: thoroldvix API
|
||||
if (result == null) {
|
||||
log.warn("[BULK-TRANSCRIPT] Browser failed for {}, trying API", videoId);
|
||||
result = youTubeService.getTranscript(videoId, "auto");
|
||||
if (result != null) {
|
||||
videoService.updateTranscript(id, result.text());
|
||||
success++;
|
||||
emit(emitter, Map.of("type", "done", "index", i,
|
||||
"title", title, "source", result.source(),
|
||||
"length", result.text().length()));
|
||||
} else {
|
||||
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++;
|
||||
@@ -96,22 +135,17 @@ public class VideoSseController {
|
||||
"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++;
|
||||
log.error("[BULK-TRANSCRIPT] Error for {}: {}", videoId, e.getMessage());
|
||||
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()));
|
||||
}
|
||||
|
||||
// 봇 판정 방지 랜덤 딜레이 (5~15초)
|
||||
if (i < total - 1) {
|
||||
int delay = ThreadLocalRandom.current().nextInt(5000, 15001);
|
||||
log.info("[BULK-TRANSCRIPT] Waiting {}ms before next...", delay);
|
||||
session.page().waitForTimeout(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,13 +160,20 @@ public class VideoSseController {
|
||||
}
|
||||
|
||||
@PostMapping("/bulk-extract")
|
||||
public SseEmitter bulkExtract() {
|
||||
public SseEmitter bulkExtract(@RequestBody(required = false) Map<String, Object> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
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(() -> {
|
||||
try {
|
||||
var rows = videoService.findVideosForBulkExtract();
|
||||
var rows = selectedIds != null && !selectedIds.isEmpty()
|
||||
? videoService.findVideosForExtractByIds(selectedIds)
|
||||
: videoService.findVideosForBulkExtract();
|
||||
|
||||
int total = rows.size();
|
||||
int totalRestaurants = 0;
|
||||
|
||||
@@ -14,6 +14,8 @@ public class Channel {
|
||||
private String channelId;
|
||||
private String channelName;
|
||||
private String titleFilter;
|
||||
private String description;
|
||||
private String tags;
|
||||
private int videoCount;
|
||||
private String lastVideoAt;
|
||||
}
|
||||
|
||||
@@ -21,4 +21,8 @@ public interface ChannelMapper {
|
||||
int deactivateById(@Param("id") String id);
|
||||
|
||||
Channel findByChannelId(@Param("channelId") String channelId);
|
||||
|
||||
void updateDescriptionTags(@Param("id") String id,
|
||||
@Param("description") String description,
|
||||
@Param("tags") String tags);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,10 @@ public interface VideoMapper {
|
||||
|
||||
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,
|
||||
@Param("restaurantId") String restaurantId,
|
||||
@Param("foodsJson") String foodsJson,
|
||||
|
||||
@@ -38,4 +38,8 @@ public class ChannelService {
|
||||
public Channel findByChannelId(String channelId) {
|
||||
return mapper.findByChannelId(channelId);
|
||||
}
|
||||
|
||||
public void update(String id, String description, String tags) {
|
||||
mapper.updateDescriptionTags(id, description, tags);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
try {
|
||||
String response = webClient.get()
|
||||
|
||||
@@ -111,6 +111,22 @@ public class VideoService {
|
||||
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,
|
||||
String foodsJson, String evaluation, String guestsJson) {
|
||||
mapper.updateVideoRestaurantFields(videoId, restaurantId, foodsJson, evaluation, guestsJson);
|
||||
|
||||
@@ -50,10 +50,77 @@ public class YouTubeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch videos from a YouTube channel, page by page.
|
||||
* Returns all pages merged into one list.
|
||||
* Fetch videos from a YouTube channel using the uploads playlist (UC→UU).
|
||||
* 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) {
|
||||
// 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<>();
|
||||
String nextPage = null;
|
||||
|
||||
@@ -98,7 +165,7 @@ public class YouTubeService {
|
||||
|
||||
nextPage = data.has("nextPageToken") ? data.path("nextPageToken").asText() : null;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to parse YouTube API response", e);
|
||||
log.error("Failed to parse YouTube Search API response", e);
|
||||
break;
|
||||
}
|
||||
} while (nextPage != null);
|
||||
@@ -108,9 +175,16 @@ public class YouTubeService {
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
String ids = String.join(",", videos.stream().map(v -> (String) v.get("video_id")).toList());
|
||||
Map<String, Integer> durations = new HashMap<>();
|
||||
List<String> allIds = videos.stream().map(v -> (String) v.get("video_id")).toList();
|
||||
|
||||
for (int i = 0; i < allIds.size(); i += 50) {
|
||||
List<String> batch = allIds.subList(i, Math.min(i + 50, allIds.size()));
|
||||
String ids = String.join(",", batch);
|
||||
try {
|
||||
String response = webClient.get()
|
||||
.uri(uriBuilder -> uriBuilder.path("/videos")
|
||||
.queryParam("key", apiKey)
|
||||
@@ -121,22 +195,21 @@ public class YouTubeService {
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofSeconds(30));
|
||||
|
||||
try {
|
||||
JsonNode data = mapper.readTree(response);
|
||||
Map<String, Integer> durations = new HashMap<>();
|
||||
for (JsonNode item : data.path("items")) {
|
||||
String duration = item.path("contentDetails").path("duration").asText();
|
||||
durations.put(item.path("id").asText(), parseDuration(duration));
|
||||
}
|
||||
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;
|
||||
log.warn("Failed to fetch video durations for batch starting at {}", i, e);
|
||||
}
|
||||
}
|
||||
|
||||
return videos.stream()
|
||||
.filter(v -> durations.getOrDefault(v.get("video_id"), 61) > 60)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private int parseDuration(String dur) {
|
||||
Matcher m = DURATION_PATTERN.matcher(dur != null ? dur : "");
|
||||
if (!m.matches()) return 0;
|
||||
@@ -217,7 +290,7 @@ public class YouTubeService {
|
||||
return getTranscriptApi(videoId, mode);
|
||||
}
|
||||
|
||||
private TranscriptResult getTranscriptApi(String videoId, String mode) {
|
||||
public TranscriptResult getTranscriptApi(String videoId, String mode) {
|
||||
TranscriptList transcriptList;
|
||||
try {
|
||||
transcriptList = transcriptApi.listTranscripts(videoId);
|
||||
@@ -314,11 +387,11 @@ public class YouTubeService {
|
||||
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);
|
||||
page.waitForTimeout(3000);
|
||||
|
||||
skipAds(page);
|
||||
|
||||
page.waitForTimeout(2000);
|
||||
page.waitForTimeout(1000);
|
||||
log.info("[TRANSCRIPT] Page loaded, looking for transcript button");
|
||||
|
||||
// Click "더보기" (expand description)
|
||||
@@ -372,14 +445,14 @@ public class YouTubeService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Wait for transcript segments to appear (max ~40s)
|
||||
page.waitForTimeout(3000);
|
||||
for (int attempt = 0; attempt < 12; attempt++) {
|
||||
page.waitForTimeout(3000);
|
||||
// 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) * 3 + 3, segCount);
|
||||
log.info("[TRANSCRIPT] Wait {}s: {} segments", (attempt + 1) * 1.5 + 2, segCount);
|
||||
if (segCount > 0) break;
|
||||
}
|
||||
|
||||
@@ -434,13 +507,23 @@ public class YouTubeService {
|
||||
}
|
||||
|
||||
private void skipAds(Page page) {
|
||||
for (int i = 0; i < 12; i++) {
|
||||
for (int i = 0; i < 30; i++) {
|
||||
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');
|
||||
if (skipBtn) { skipBtn.click(); return 'skipped'; }
|
||||
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');
|
||||
if (adBadge && adBadge.textContent) return 'badge';
|
||||
return 'none';
|
||||
@@ -450,10 +533,10 @@ public class YouTubeService {
|
||||
if ("none".equals(status)) break;
|
||||
log.info("[TRANSCRIPT] Ad detected: {}, waiting...", status);
|
||||
if ("skipped".equals(status)) {
|
||||
page.waitForTimeout(2000);
|
||||
page.waitForTimeout(1000);
|
||||
break;
|
||||
}
|
||||
page.waitForTimeout(5000);
|
||||
page.waitForTimeout(1000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@
|
||||
<result property="channelId" column="channel_id"/>
|
||||
<result property="channelName" column="channel_name"/>
|
||||
<result property="titleFilter" column="title_filter"/>
|
||||
<result property="description" column="description"/>
|
||||
<result property="tags" column="tags"/>
|
||||
<result property="videoCount" column="video_count"/>
|
||||
<result property="lastVideoAt" column="last_video_at"/>
|
||||
</resultMap>
|
||||
|
||||
<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,
|
||||
(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
|
||||
FROM channels c
|
||||
@@ -35,6 +37,11 @@
|
||||
WHERE id = #{id} AND is_active = 1
|
||||
</update>
|
||||
|
||||
<update id="updateDescriptionTags">
|
||||
UPDATE channels SET description = #{description}, tags = #{tags}
|
||||
WHERE id = #{id}
|
||||
</update>
|
||||
|
||||
<select id="findByChannelId" resultMap="channelResultMap">
|
||||
SELECT id, channel_id, channel_name, title_filter
|
||||
FROM channels
|
||||
|
||||
@@ -143,6 +143,18 @@
|
||||
<if test="fields.containsKey('longitude')">
|
||||
longitude = #{fields.longitude},
|
||||
</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,
|
||||
</trim>
|
||||
WHERE id = #{id}
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
<result property="longitude" column="longitude"/>
|
||||
<result property="cuisineType" column="cuisine_type"/>
|
||||
<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="tablingUrl" column="tabling_url"/>
|
||||
<result property="catchtableUrl" column="catchtable_url"/>
|
||||
<result property="businessStatus" column="business_status"/>
|
||||
<result property="rating" column="rating"/>
|
||||
<result property="ratingCount" column="rating_count"/>
|
||||
@@ -19,7 +23,8 @@
|
||||
|
||||
<select id="keywordSearch" resultMap="restaurantMap">
|
||||
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
|
||||
r.cuisine_type, r.price_range, r.google_place_id,
|
||||
r.cuisine_type, r.price_range, r.phone, r.website, r.google_place_id,
|
||||
r.tabling_url, r.catchtable_url,
|
||||
r.business_status, r.rating, r.rating_count
|
||||
FROM restaurants r
|
||||
JOIN video_restaurants vr ON vr.restaurant_id = r.id
|
||||
|
||||
@@ -221,10 +221,30 @@
|
||||
SELECT id, video_id, title, url
|
||||
FROM videos
|
||||
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
|
||||
</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 video_restaurants
|
||||
SET foods_mentioned = #{foodsJson,jdbcType=CLOB},
|
||||
|
||||
@@ -7,6 +7,33 @@ import { useAuth } from "@/lib/auth-context";
|
||||
|
||||
type Tab = "channels" | "videos" | "restaurants" | "users" | "daemon";
|
||||
|
||||
function CacheFlushButton() {
|
||||
const [flushing, setFlushing] = useState(false);
|
||||
|
||||
const handleFlush = async () => {
|
||||
if (!confirm("Redis 캐시를 초기화하시겠습니까?")) return;
|
||||
setFlushing(true);
|
||||
try {
|
||||
await api.flushCache();
|
||||
alert("캐시가 초기화되었습니다.");
|
||||
} catch (e) {
|
||||
alert("캐시 초기화 실패: " + (e instanceof Error ? e.message : e));
|
||||
} finally {
|
||||
setFlushing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleFlush}
|
||||
disabled={flushing}
|
||||
className="px-3 py-1.5 text-xs bg-red-50 text-red-600 border border-red-200 rounded-lg hover:bg-red-100 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{flushing ? "초기화 중..." : "🗑 캐시 초기화"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const [tab, setTab] = useState<Tab>("channels");
|
||||
const { user, isLoading } = useAuth();
|
||||
@@ -38,10 +65,13 @@ export default function AdminPage() {
|
||||
<span className="px-2 py-0.5 bg-yellow-100 text-yellow-700 rounded text-xs font-medium">읽기 전용</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isAdmin && <CacheFlushButton />}
|
||||
<a href="/" className="text-sm text-blue-600 hover:underline">
|
||||
← 메인으로
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="mt-3 flex gap-1">
|
||||
{(["channels", "videos", "restaurants", "users", "daemon"] as Tab[]).map((t) => (
|
||||
<button
|
||||
@@ -101,6 +131,20 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
}
|
||||
};
|
||||
|
||||
const [editingChannel, setEditingChannel] = useState<string | null>(null);
|
||||
const [editDesc, setEditDesc] = useState("");
|
||||
const [editTags, setEditTags] = useState("");
|
||||
|
||||
const handleSaveChannel = async (id: string) => {
|
||||
try {
|
||||
await api.updateChannel(id, { description: editDesc, tags: editTags });
|
||||
setEditingChannel(null);
|
||||
load();
|
||||
} catch {
|
||||
alert("채널 수정 실패");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (channelId: string, channelName: string) => {
|
||||
if (!confirm(`"${channelName}" 채널을 삭제하시겠습니까?`)) return;
|
||||
try {
|
||||
@@ -164,8 +208,9 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<th className="text-left px-4 py-3">채널 이름</th>
|
||||
<th className="text-left px-4 py-3">Channel ID</th>
|
||||
<th className="text-left px-4 py-3">제목 필터</th>
|
||||
<th className="text-left px-4 py-3">설명</th>
|
||||
<th className="text-left px-4 py-3">태그</th>
|
||||
<th className="text-right px-4 py-3">영상 수</th>
|
||||
<th className="text-left px-4 py-3">마지막 스캔</th>
|
||||
{isAdmin && <th className="text-left px-4 py-3">액션</th>}
|
||||
<th className="text-left px-4 py-3">스캔 결과</th>
|
||||
</tr>
|
||||
@@ -186,6 +231,32 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<span className="text-gray-400 text-xs">전체</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs">
|
||||
{editingChannel === ch.id ? (
|
||||
<input value={editDesc} onChange={(e) => setEditDesc(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-xs w-32 bg-white text-gray-900" placeholder="설명" />
|
||||
) : (
|
||||
<span className="text-gray-600 cursor-pointer" onClick={() => {
|
||||
if (!isAdmin) return;
|
||||
setEditingChannel(ch.id); setEditDesc(ch.description || ""); setEditTags(ch.tags || "");
|
||||
}}>{ch.description || <span className="text-gray-400">-</span>}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs">
|
||||
{editingChannel === ch.id ? (
|
||||
<div className="flex gap-1">
|
||||
<input value={editTags} onChange={(e) => setEditTags(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-xs w-40 bg-white text-gray-900" placeholder="태그 (쉼표 구분)" />
|
||||
<button onClick={() => handleSaveChannel(ch.id)} className="text-blue-600 text-xs hover:underline">저장</button>
|
||||
<button onClick={() => setEditingChannel(null)} className="text-gray-400 text-xs hover:underline">취소</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-500 cursor-pointer" onClick={() => {
|
||||
if (!isAdmin) return;
|
||||
setEditingChannel(ch.id); setEditDesc(ch.description || ""); setEditTags(ch.tags || "");
|
||||
}}>{ch.tags ? ch.tags.split(",").map(t => t.trim()).join(", ") : <span className="text-gray-400">-</span>}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-medium">
|
||||
{ch.video_count > 0 ? (
|
||||
<span className="px-2 py-0.5 bg-green-50 text-green-700 rounded text-xs">{ch.video_count}개</span>
|
||||
@@ -193,9 +264,6 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<span className="text-gray-400 text-xs">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-500">
|
||||
{ch.last_scanned_at ? ch.last_scanned_at.slice(0, 16).replace("T", " ") : "-"}
|
||||
</td>
|
||||
{isAdmin && <td className="px-4 py-3 flex gap-3">
|
||||
<button
|
||||
onClick={() => handleScan(ch.channel_id)}
|
||||
@@ -393,11 +461,16 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
}
|
||||
};
|
||||
|
||||
const startBulkStream = async (mode: "transcript" | "extract") => {
|
||||
const startBulkStream = async (mode: "transcript" | "extract", ids?: string[]) => {
|
||||
const isTranscript = mode === "transcript";
|
||||
const setRunning = isTranscript ? setBulkTranscripting : setBulkExtracting;
|
||||
const hasSelection = ids && ids.length > 0;
|
||||
|
||||
try {
|
||||
let count: number;
|
||||
if (hasSelection) {
|
||||
count = ids.length;
|
||||
} else {
|
||||
const pending = isTranscript
|
||||
? await api.getBulkTranscriptPending()
|
||||
: await api.getBulkExtractPending();
|
||||
@@ -405,23 +478,29 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
alert(isTranscript ? "자막 없는 영상이 없습니다" : "추출 대기 중인 영상이 없습니다");
|
||||
return;
|
||||
}
|
||||
count = pending.count;
|
||||
}
|
||||
const msg = isTranscript
|
||||
? `자막 없는 영상 ${pending.count}개의 트랜스크립트를 수집하시겠습니까?\n(영상 당 5~15초 랜덤 딜레이)`
|
||||
: `LLM 추출이 안된 영상 ${pending.count}개를 벌크 처리하시겠습니까?\n(영상 당 3~8초 랜덤 딜레이)`;
|
||||
? `${hasSelection ? "선택한 " : "자막 없는 "}영상 ${count}개의 트랜스크립트를 수집하시겠습니까?`
|
||||
: `${hasSelection ? "선택한 " : "LLM 추출이 안된 "}영상 ${count}개를 벌크 처리하시겠습니까?`;
|
||||
if (!confirm(msg)) return;
|
||||
|
||||
setRunning(true);
|
||||
setBulkProgress({
|
||||
label: isTranscript ? "벌크 자막 수집" : "벌크 LLM 추출",
|
||||
total: pending.count, current: 0, currentTitle: "", results: [],
|
||||
total: count, current: 0, currentTitle: "", results: [],
|
||||
});
|
||||
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
const endpoint = isTranscript ? "/api/videos/bulk-transcript" : "/api/videos/bulk-extract";
|
||||
const token = typeof window !== "undefined" ? localStorage.getItem("tasteby_token") : null;
|
||||
const headers: Record<string, string> = {};
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
const resp = await fetch(`${apiBase}${endpoint}`, { method: "POST", headers });
|
||||
const resp = await fetch(`${apiBase}${endpoint}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: hasSelection ? JSON.stringify({ ids }) : undefined,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
alert(`벌크 요청 실패: ${resp.status} ${resp.statusText}`);
|
||||
setRunning(false);
|
||||
@@ -757,6 +836,20 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
)}
|
||||
{isAdmin && selected.size > 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => startBulkStream("transcript", Array.from(selected))}
|
||||
disabled={bulkTranscripting || bulkExtracting}
|
||||
className="bg-orange-500 text-white px-4 py-2 rounded text-sm hover:bg-orange-600 disabled:opacity-50"
|
||||
>
|
||||
선택 자막 수집 ({selected.size})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => startBulkStream("extract", Array.from(selected))}
|
||||
disabled={bulkExtracting || bulkTranscripting}
|
||||
className="bg-purple-500 text-white px-4 py-2 rounded text-sm hover:bg-purple-600 disabled:opacity-50"
|
||||
>
|
||||
선택 LLM 추출 ({selected.size})
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBulkSkip}
|
||||
className="bg-gray-500 text-white px-4 py-2 rounded text-sm hover:bg-gray-600"
|
||||
|
||||
@@ -68,7 +68,7 @@ function buildRegionTree(restaurants: Restaurant[]) {
|
||||
const tree = new Map<string, Map<string, Set<string>>>();
|
||||
for (const r of restaurants) {
|
||||
const p = parseRegion(r.region);
|
||||
if (!p || !p.country) continue;
|
||||
if (!p || !p.country || p.country === "나라") continue;
|
||||
if (!tree.has(p.country)) tree.set(p.country, new Map());
|
||||
const cityMap = tree.get(p.country)!;
|
||||
if (p.city) {
|
||||
@@ -109,7 +109,7 @@ function findRegionFromCoords(
|
||||
const groups = new Map<string, { country: string; city: string; lats: number[]; lngs: number[] }>();
|
||||
for (const r of restaurants) {
|
||||
const p = parseRegion(r.region);
|
||||
if (!p || !p.country || !p.city) continue;
|
||||
if (!p || !p.country || p.country === "나라" || !p.city) continue;
|
||||
const key = `${p.country}|${p.city}`;
|
||||
if (!groups.has(key)) groups.set(key, { country: p.country, city: p.city, lats: [], lngs: [] });
|
||||
const g = groups.get(key)!;
|
||||
@@ -154,6 +154,8 @@ export default function Home() {
|
||||
const [myReviews, setMyReviews] = useState<(Review & { restaurant_id: string; restaurant_name: string | null })[]>([]);
|
||||
const [visits, setVisits] = useState<{ today: number; total: number } | null>(null);
|
||||
const [userLoc, setUserLoc] = useState<{ lat: number; lng: number }>({ lat: 37.498, lng: 127.0276 });
|
||||
const [isSearchResult, setIsSearchResult] = useState(false);
|
||||
const [resetCount, setResetCount] = useState(0);
|
||||
const geoApplied = useRef(false);
|
||||
|
||||
const regionTree = useMemo(() => buildRegionTree(restaurants), [restaurants]);
|
||||
@@ -174,6 +176,13 @@ export default function Home() {
|
||||
const filteredRestaurants = useMemo(() => {
|
||||
const dist = (r: Restaurant) =>
|
||||
(r.latitude - userLoc.lat) ** 2 + (r.longitude - userLoc.lng) ** 2;
|
||||
if (isSearchResult) {
|
||||
return [...restaurants].sort((a, b) => {
|
||||
const da = dist(a), db = dist(b);
|
||||
if (da !== db) return da - db;
|
||||
return (b.rating || 0) - (a.rating || 0);
|
||||
});
|
||||
}
|
||||
return restaurants.filter((r) => {
|
||||
if (channelFilter && !(r.channels || []).includes(channelFilter)) return false;
|
||||
if (cuisineFilter && !matchCuisineFilter(r.cuisine_type, cuisineFilter)) return false;
|
||||
@@ -194,7 +203,7 @@ export default function Home() {
|
||||
if (da !== db) return da - db;
|
||||
return (b.rating || 0) - (a.rating || 0);
|
||||
});
|
||||
}, [restaurants, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]);
|
||||
}, [restaurants, isSearchResult, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]);
|
||||
|
||||
// Set desktop default to map mode on mount + get user location
|
||||
useEffect(() => {
|
||||
@@ -217,6 +226,7 @@ export default function Home() {
|
||||
// Load restaurants on mount and when channel filter changes
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setIsSearchResult(false);
|
||||
api
|
||||
.getRestaurants({ limit: 500, channel: channelFilter || undefined })
|
||||
.then(setRestaurants)
|
||||
@@ -253,6 +263,18 @@ export default function Home() {
|
||||
setRestaurants(results);
|
||||
setSelected(null);
|
||||
setShowDetail(false);
|
||||
setIsSearchResult(true);
|
||||
// 검색 시 필터 초기화
|
||||
setChannelFilter("");
|
||||
setCuisineFilter("");
|
||||
setPriceFilter("");
|
||||
setCountryFilter("");
|
||||
setCityFilter("");
|
||||
setDistrictFilter("");
|
||||
setBoundsFilterOn(false);
|
||||
// 검색 결과에 맞게 지도 이동
|
||||
const flyTo = computeFlyTo(results);
|
||||
if (flyTo) setRegionFlyTo(flyTo);
|
||||
} catch (e) {
|
||||
console.error("Search failed:", e);
|
||||
} finally {
|
||||
@@ -350,6 +372,8 @@ export default function Home() {
|
||||
setBoundsFilterOn(false);
|
||||
setShowFavorites(false);
|
||||
setShowMyReviews(false);
|
||||
setIsSearchResult(false);
|
||||
setResetCount((c) => c + 1);
|
||||
api
|
||||
.getRestaurants({ limit: 500 })
|
||||
.then((data) => {
|
||||
@@ -531,7 +555,7 @@ export default function Home() {
|
||||
{/* Row 1: Search + dropdown filters */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-96 shrink-0">
|
||||
<SearchBar onSearch={handleSearch} isLoading={loading} />
|
||||
<SearchBar key={resetCount} onSearch={handleSearch} isLoading={loading} />
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
@@ -540,22 +564,6 @@ export default function Home() {
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||
</button>
|
||||
<select
|
||||
value={channelFilter}
|
||||
onChange={(e) => {
|
||||
setChannelFilter(e.target.value);
|
||||
setSelected(null);
|
||||
setShowDetail(false);
|
||||
}}
|
||||
className="border dark:border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">📺 채널</option>
|
||||
{channels.map((ch) => (
|
||||
<option key={ch.id} value={ch.channel_name}>
|
||||
📺 {ch.channel_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={cuisineFilter}
|
||||
onChange={(e) => setCuisineFilter(e.target.value)}
|
||||
@@ -681,6 +689,37 @@ export default function Home() {
|
||||
{filteredRestaurants.length}개
|
||||
</span>
|
||||
</div>
|
||||
{/* Row 3: Channel cards (toggle filter) - max 4 visible, scroll for rest */}
|
||||
{!isSearchResult && channels.length > 0 && (
|
||||
<div className="overflow-x-auto scrollbar-hide" style={{ maxWidth: `${4 * 200 + 3 * 8}px` }}>
|
||||
<div className="flex gap-2">
|
||||
{channels.map((ch) => (
|
||||
<button
|
||||
key={ch.id}
|
||||
onClick={() => {
|
||||
setChannelFilter(channelFilter === ch.channel_name ? "" : ch.channel_name);
|
||||
setSelected(null);
|
||||
setShowDetail(false);
|
||||
}}
|
||||
className={`shrink-0 flex items-center gap-2 rounded-lg px-3 py-1.5 border transition-all text-left ${
|
||||
channelFilter === ch.channel_name
|
||||
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700"
|
||||
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-orange-200 dark:hover:border-orange-800"
|
||||
}`}
|
||||
style={{ width: "200px" }}
|
||||
>
|
||||
<svg className="w-4 h-4 shrink-0 text-red-500" viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-xs font-semibold truncate ${
|
||||
channelFilter === ch.channel_name ? "text-orange-600 dark:text-orange-400" : "dark:text-gray-200"
|
||||
}`}>{ch.channel_name}</p>
|
||||
{ch.description && <p className="text-[10px] text-gray-400 dark:text-gray-500 truncate">{ch.description}</p>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User area */}
|
||||
@@ -717,7 +756,44 @@ export default function Home() {
|
||||
{/* ── Header row 2 (mobile only): search + toolbar ── */}
|
||||
<div className={`md:hidden px-4 pb-3 space-y-2 ${mobileTab === "favorites" || mobileTab === "profile" ? "hidden" : ""}`}>
|
||||
{/* Row 1: Search */}
|
||||
<SearchBar onSearch={handleSearch} isLoading={loading} />
|
||||
<SearchBar key={resetCount} onSearch={handleSearch} isLoading={loading} />
|
||||
{/* Channel cards - toggle filter */}
|
||||
{mobileTab === "home" && !isSearchResult && channels.length > 0 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 -mx-1 px-1 scrollbar-hide">
|
||||
{channels.map((ch) => (
|
||||
<button
|
||||
key={ch.id}
|
||||
onClick={() => {
|
||||
setChannelFilter(channelFilter === ch.channel_name ? "" : ch.channel_name);
|
||||
setSelected(null);
|
||||
setShowDetail(false);
|
||||
}}
|
||||
className={`shrink-0 rounded-xl px-3 py-2 text-left border transition-all ${
|
||||
channelFilter === ch.channel_name
|
||||
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700"
|
||||
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
|
||||
}`}
|
||||
style={{ minWidth: "140px", maxWidth: "170px" }}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-3.5 h-3.5 shrink-0 text-red-500" viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
|
||||
<p className={`text-xs font-semibold truncate ${
|
||||
channelFilter === ch.channel_name ? "text-orange-600 dark:text-orange-400" : "dark:text-gray-200"
|
||||
}`}>{ch.channel_name}</p>
|
||||
</div>
|
||||
{ch.description && <p className="text-[10px] text-gray-500 dark:text-gray-400 truncate mt-0.5">{ch.description}</p>}
|
||||
{ch.tags && (
|
||||
<div className="flex gap-1 mt-1 overflow-hidden">
|
||||
{ch.tags.split(",").slice(0, 2).map((t) => (
|
||||
<span key={t} className="text-[9px] px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded-full whitespace-nowrap">{t.trim()}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 2: Toolbar */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -745,22 +821,6 @@ export default function Home() {
|
||||
<div className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-md rounded-xl p-3.5 space-y-3 border border-white/50 dark:border-gray-700/50 shadow-sm">
|
||||
{/* Dropdown filters */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<select
|
||||
value={channelFilter}
|
||||
onChange={(e) => {
|
||||
setChannelFilter(e.target.value);
|
||||
setSelected(null);
|
||||
setShowDetail(false);
|
||||
}}
|
||||
className="border dark:border-gray-700 rounded-lg px-2.5 py-1.5 text-xs text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800"
|
||||
>
|
||||
<option value="">📺 채널</option>
|
||||
{channels.map((ch) => (
|
||||
<option key={ch.id} value={ch.channel_name}>
|
||||
📺 {ch.channel_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={cuisineFilter}
|
||||
onChange={(e) => setCuisineFilter(e.target.value)}
|
||||
|
||||
@@ -123,7 +123,7 @@ export default function RestaurantDetail({
|
||||
</p>
|
||||
)}
|
||||
{restaurant.google_place_id && (
|
||||
<p>
|
||||
<p className="flex gap-3">
|
||||
<a
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(restaurant.name)}`}
|
||||
target="_blank"
|
||||
@@ -132,6 +132,14 @@ export default function RestaurantDetail({
|
||||
>
|
||||
Google Maps에서 보기
|
||||
</a>
|
||||
<a
|
||||
href={`https://map.naver.com/v5/search/${encodeURIComponent(restaurant.name)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-green-600 dark:text-green-400 hover:underline text-xs"
|
||||
>
|
||||
네이버 지도에서 보기
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -70,6 +70,8 @@ export interface Channel {
|
||||
channel_id: string;
|
||||
channel_name: string;
|
||||
title_filter: string | null;
|
||||
description: string | null;
|
||||
tags: string | null;
|
||||
video_count: number;
|
||||
last_scanned_at: string | null;
|
||||
}
|
||||
@@ -350,6 +352,13 @@ export const api = {
|
||||
);
|
||||
},
|
||||
|
||||
updateChannel(id: string, data: { description?: string; tags?: string }) {
|
||||
return fetchApi<{ ok: boolean }>(`/api/channels/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
deleteChannel(channelId: string) {
|
||||
return fetchApi<{ ok: boolean }>(`/api/channels/${channelId}`, {
|
||||
method: "DELETE",
|
||||
@@ -497,6 +506,12 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
flushCache() {
|
||||
return fetchApi<{ ok: boolean }>("/api/admin/cache-flush", {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
|
||||
runDaemonProcess(limit: number = 10) {
|
||||
return fetchApi<{ ok: boolean; restaurants_extracted: number }>(
|
||||
`/api/daemon/run/process?limit=${limit}`,
|
||||
|
||||
Reference in New Issue
Block a user