- 모바일 하단 네비게이션(홈/식당목록/내주변/찜/내정보) 추가 - 로그인 버튼을 모달 방식으로 변경 (소셜 로그인 확장 가능) - 내위치 기반 정렬 및 영역 필터, 지도 내위치 버튼 추가 - 채널 필터 시 해당 채널만 마커/범례 표시 - 캐치테이블 검색/연동 (단건/벌크), NONE 저장 패턴 - 벌크 트랜스크립트 SSE (Playwright 브라우저 재사용) - 테이블링/캐치테이블 버튼 UI 차별화 - Google Maps 링크 모바일 호환, 초기화 버튼, 검색 라벨 개선 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
465 lines
20 KiB
Java
465 lines
20 KiB
Java
package com.tasteby.controller;
|
|
|
|
import com.fasterxml.jackson.core.type.TypeReference;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import com.microsoft.playwright.*;
|
|
import com.tasteby.domain.Restaurant;
|
|
import com.tasteby.security.AuthUtil;
|
|
import com.tasteby.service.CacheService;
|
|
import com.tasteby.service.RestaurantService;
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
import org.springframework.http.HttpStatus;
|
|
import org.springframework.web.bind.annotation.*;
|
|
import org.springframework.web.server.ResponseStatusException;
|
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
|
|
|
import java.net.URLEncoder;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.util.ArrayList;
|
|
import java.util.HashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.concurrent.ExecutorService;
|
|
import java.util.concurrent.Executors;
|
|
import java.util.concurrent.ThreadLocalRandom;
|
|
|
|
@RestController
|
|
@RequestMapping("/api/restaurants")
|
|
public class RestaurantController {
|
|
|
|
private static final Logger log = LoggerFactory.getLogger(RestaurantController.class);
|
|
|
|
private final RestaurantService restaurantService;
|
|
private final CacheService cache;
|
|
private final ObjectMapper objectMapper;
|
|
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
|
|
|
public RestaurantController(RestaurantService restaurantService, CacheService cache, ObjectMapper objectMapper) {
|
|
this.restaurantService = restaurantService;
|
|
this.cache = cache;
|
|
this.objectMapper = objectMapper;
|
|
}
|
|
|
|
@GetMapping
|
|
public List<Restaurant> list(
|
|
@RequestParam(defaultValue = "100") int limit,
|
|
@RequestParam(defaultValue = "0") int offset,
|
|
@RequestParam(required = false) String cuisine,
|
|
@RequestParam(required = false) String region,
|
|
@RequestParam(required = false) String channel) {
|
|
if (limit > 500) limit = 500;
|
|
String key = cache.makeKey("restaurants", "l=" + limit, "o=" + offset,
|
|
"c=" + cuisine, "r=" + region, "ch=" + channel);
|
|
String cached = cache.getRaw(key);
|
|
if (cached != null) {
|
|
try {
|
|
return objectMapper.readValue(cached, new TypeReference<List<Restaurant>>() {});
|
|
} catch (Exception ignored) {}
|
|
}
|
|
var result = restaurantService.findAll(limit, offset, cuisine, region, channel);
|
|
cache.set(key, result);
|
|
return result;
|
|
}
|
|
|
|
@GetMapping("/{id}")
|
|
public Restaurant get(@PathVariable String id) {
|
|
String key = cache.makeKey("restaurant", id);
|
|
String cached = cache.getRaw(key);
|
|
if (cached != null) {
|
|
try {
|
|
return objectMapper.readValue(cached, Restaurant.class);
|
|
} catch (Exception ignored) {}
|
|
}
|
|
var r = restaurantService.findById(id);
|
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
|
cache.set(key, r);
|
|
return r;
|
|
}
|
|
|
|
@PutMapping("/{id}")
|
|
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, Object> body) {
|
|
AuthUtil.requireAdmin();
|
|
var r = restaurantService.findById(id);
|
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
|
restaurantService.update(id, body);
|
|
cache.flush();
|
|
return Map.of("ok", true);
|
|
}
|
|
|
|
@DeleteMapping("/{id}")
|
|
public Map<String, Object> delete(@PathVariable String id) {
|
|
AuthUtil.requireAdmin();
|
|
var r = restaurantService.findById(id);
|
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
|
restaurantService.delete(id);
|
|
cache.flush();
|
|
return Map.of("ok", true);
|
|
}
|
|
|
|
/** 단건 테이블링 URL 검색 */
|
|
@GetMapping("/{id}/tabling-search")
|
|
public List<Map<String, Object>> tablingSearch(@PathVariable String id) {
|
|
AuthUtil.requireAdmin();
|
|
var r = restaurantService.findById(id);
|
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
|
|
|
try (Playwright pw = Playwright.create()) {
|
|
try (Browser browser = launchBrowser(pw)) {
|
|
BrowserContext ctx = newContext(browser);
|
|
Page page = newPage(ctx);
|
|
return searchTabling(page, r.getName());
|
|
}
|
|
} catch (Exception e) {
|
|
log.error("[TABLING] Search failed for '{}': {}", r.getName(), e.getMessage());
|
|
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage());
|
|
}
|
|
}
|
|
|
|
/** 테이블링 미연결 식당 목록 */
|
|
@GetMapping("/tabling-pending")
|
|
public Map<String, Object> tablingPending() {
|
|
AuthUtil.requireAdmin();
|
|
var list = restaurantService.findWithoutTabling();
|
|
var summary = list.stream()
|
|
.map(r -> Map.of("id", (Object) r.getId(), "name", (Object) r.getName()))
|
|
.toList();
|
|
return Map.of("count", list.size(), "restaurants", summary);
|
|
}
|
|
|
|
/** 벌크 테이블링 검색 (SSE) */
|
|
@PostMapping("/bulk-tabling")
|
|
public SseEmitter bulkTabling() {
|
|
AuthUtil.requireAdmin();
|
|
SseEmitter emitter = new SseEmitter(600_000L);
|
|
|
|
executor.execute(() -> {
|
|
try {
|
|
var restaurants = restaurantService.findWithoutTabling();
|
|
int total = restaurants.size();
|
|
emit(emitter, Map.of("type", "start", "total", total));
|
|
|
|
if (total == 0) {
|
|
emit(emitter, Map.of("type", "complete", "total", 0, "linked", 0, "notFound", 0));
|
|
emitter.complete();
|
|
return;
|
|
}
|
|
|
|
int linked = 0;
|
|
int notFound = 0;
|
|
|
|
try (Playwright pw = Playwright.create()) {
|
|
try (Browser browser = launchBrowser(pw)) {
|
|
BrowserContext ctx = newContext(browser);
|
|
Page page = newPage(ctx);
|
|
|
|
for (int i = 0; i < total; i++) {
|
|
var r = restaurants.get(i);
|
|
emit(emitter, Map.of("type", "processing", "current", i + 1,
|
|
"total", total, "name", r.getName()));
|
|
|
|
try {
|
|
var results = searchTabling(page, r.getName());
|
|
if (!results.isEmpty()) {
|
|
String url = String.valueOf(results.get(0).get("url"));
|
|
String title = String.valueOf(results.get(0).get("title"));
|
|
restaurantService.update(r.getId(), Map.of("tabling_url", url));
|
|
linked++;
|
|
emit(emitter, Map.of("type", "done", "current", i + 1,
|
|
"name", r.getName(), "url", url, "title", title));
|
|
} else {
|
|
restaurantService.update(r.getId(), Map.of("tabling_url", "NONE"));
|
|
notFound++;
|
|
emit(emitter, Map.of("type", "notfound", "current", i + 1,
|
|
"name", r.getName()));
|
|
}
|
|
} catch (Exception e) {
|
|
notFound++;
|
|
emit(emitter, Map.of("type", "error", "current", i + 1,
|
|
"name", r.getName(), "message", e.getMessage()));
|
|
}
|
|
|
|
// Google 봇 판정 방지 랜덤 딜레이 (5~15초)
|
|
int delay = ThreadLocalRandom.current().nextInt(5000, 15001);
|
|
log.info("[TABLING] Waiting {}ms before next search...", delay);
|
|
page.waitForTimeout(delay);
|
|
}
|
|
}
|
|
}
|
|
|
|
cache.flush();
|
|
emit(emitter, Map.of("type", "complete", "total", total, "linked", linked, "notFound", notFound));
|
|
emitter.complete();
|
|
} catch (Exception e) {
|
|
log.error("[TABLING] Bulk search error", e);
|
|
emitter.completeWithError(e);
|
|
}
|
|
});
|
|
|
|
return emitter;
|
|
}
|
|
|
|
/** 테이블링 URL 저장 */
|
|
@PutMapping("/{id}/tabling-url")
|
|
public Map<String, Object> setTablingUrl(@PathVariable String id, @RequestBody Map<String, String> body) {
|
|
AuthUtil.requireAdmin();
|
|
var r = restaurantService.findById(id);
|
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
|
String url = body.get("tabling_url");
|
|
restaurantService.update(id, Map.of("tabling_url", url != null ? url : ""));
|
|
cache.flush();
|
|
return Map.of("ok", true);
|
|
}
|
|
|
|
/** 단건 캐치테이블 URL 검색 */
|
|
@GetMapping("/{id}/catchtable-search")
|
|
public List<Map<String, Object>> catchtableSearch(@PathVariable String id) {
|
|
AuthUtil.requireAdmin();
|
|
var r = restaurantService.findById(id);
|
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
|
try (Playwright pw = Playwright.create()) {
|
|
try (Browser browser = launchBrowser(pw)) {
|
|
BrowserContext ctx = newContext(browser);
|
|
Page page = newPage(ctx);
|
|
return searchCatchtable(page, r.getName());
|
|
}
|
|
} catch (Exception e) {
|
|
log.error("[CATCHTABLE] Search failed for '{}': {}", r.getName(), e.getMessage());
|
|
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage());
|
|
}
|
|
}
|
|
|
|
/** 캐치테이블 미연결 식당 목록 */
|
|
@GetMapping("/catchtable-pending")
|
|
public Map<String, Object> catchtablePending() {
|
|
AuthUtil.requireAdmin();
|
|
var list = restaurantService.findWithoutCatchtable();
|
|
var summary = list.stream()
|
|
.map(r -> Map.of("id", (Object) r.getId(), "name", (Object) r.getName()))
|
|
.toList();
|
|
return Map.of("count", list.size(), "restaurants", summary);
|
|
}
|
|
|
|
/** 벌크 캐치테이블 검색 (SSE) */
|
|
@PostMapping("/bulk-catchtable")
|
|
public SseEmitter bulkCatchtable() {
|
|
AuthUtil.requireAdmin();
|
|
SseEmitter emitter = new SseEmitter(600_000L);
|
|
|
|
executor.execute(() -> {
|
|
try {
|
|
var restaurants = restaurantService.findWithoutCatchtable();
|
|
int total = restaurants.size();
|
|
emit(emitter, Map.of("type", "start", "total", total));
|
|
|
|
if (total == 0) {
|
|
emit(emitter, Map.of("type", "complete", "total", 0, "linked", 0, "notFound", 0));
|
|
emitter.complete();
|
|
return;
|
|
}
|
|
|
|
int linked = 0;
|
|
int notFound = 0;
|
|
|
|
try (Playwright pw = Playwright.create()) {
|
|
try (Browser browser = launchBrowser(pw)) {
|
|
BrowserContext ctx = newContext(browser);
|
|
Page page = newPage(ctx);
|
|
|
|
for (int i = 0; i < total; i++) {
|
|
var r = restaurants.get(i);
|
|
emit(emitter, Map.of("type", "processing", "current", i + 1,
|
|
"total", total, "name", r.getName()));
|
|
|
|
try {
|
|
var results = searchCatchtable(page, r.getName());
|
|
if (!results.isEmpty()) {
|
|
String url = String.valueOf(results.get(0).get("url"));
|
|
String title = String.valueOf(results.get(0).get("title"));
|
|
restaurantService.update(r.getId(), Map.of("catchtable_url", url));
|
|
linked++;
|
|
emit(emitter, Map.of("type", "done", "current", i + 1,
|
|
"name", r.getName(), "url", url, "title", title));
|
|
} else {
|
|
restaurantService.update(r.getId(), Map.of("catchtable_url", "NONE"));
|
|
notFound++;
|
|
emit(emitter, Map.of("type", "notfound", "current", i + 1,
|
|
"name", r.getName()));
|
|
}
|
|
} catch (Exception e) {
|
|
notFound++;
|
|
emit(emitter, Map.of("type", "error", "current", i + 1,
|
|
"name", r.getName(), "message", e.getMessage()));
|
|
}
|
|
|
|
int delay = ThreadLocalRandom.current().nextInt(5000, 15001);
|
|
log.info("[CATCHTABLE] Waiting {}ms before next search...", delay);
|
|
page.waitForTimeout(delay);
|
|
}
|
|
}
|
|
}
|
|
|
|
cache.flush();
|
|
emit(emitter, Map.of("type", "complete", "total", total, "linked", linked, "notFound", notFound));
|
|
emitter.complete();
|
|
} catch (Exception e) {
|
|
log.error("[CATCHTABLE] Bulk search error", e);
|
|
emitter.completeWithError(e);
|
|
}
|
|
});
|
|
|
|
return emitter;
|
|
}
|
|
|
|
/** 캐치테이블 URL 저장 */
|
|
@PutMapping("/{id}/catchtable-url")
|
|
public Map<String, Object> setCatchtableUrl(@PathVariable String id, @RequestBody Map<String, String> body) {
|
|
AuthUtil.requireAdmin();
|
|
var r = restaurantService.findById(id);
|
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
|
String url = body.get("catchtable_url");
|
|
restaurantService.update(id, Map.of("catchtable_url", url != null ? url : ""));
|
|
cache.flush();
|
|
return Map.of("ok", true);
|
|
}
|
|
|
|
@GetMapping("/{id}/videos")
|
|
public List<Map<String, Object>> videos(@PathVariable String id) {
|
|
String key = cache.makeKey("restaurant_videos", id);
|
|
String cached = cache.getRaw(key);
|
|
if (cached != null) {
|
|
try {
|
|
return objectMapper.readValue(cached, new TypeReference<List<Map<String, Object>>>() {});
|
|
} catch (Exception ignored) {}
|
|
}
|
|
var r = restaurantService.findById(id);
|
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
|
var result = restaurantService.findVideoLinks(id);
|
|
cache.set(key, result);
|
|
return result;
|
|
}
|
|
|
|
// ─── Playwright helpers ──────────────────────────────────────────────
|
|
|
|
private Browser launchBrowser(Playwright pw) {
|
|
return pw.chromium().launch(new BrowserType.LaunchOptions()
|
|
.setHeadless(false)
|
|
.setArgs(List.of("--disable-blink-features=AutomationControlled")));
|
|
}
|
|
|
|
private BrowserContext newContext(Browser browser) {
|
|
return browser.newContext(new Browser.NewContextOptions()
|
|
.setLocale("ko-KR").setViewportSize(1280, 900));
|
|
}
|
|
|
|
private Page newPage(BrowserContext ctx) {
|
|
Page page = ctx.newPage();
|
|
page.addInitScript("Object.defineProperty(navigator, 'webdriver', {get: () => false})");
|
|
return page;
|
|
}
|
|
|
|
@SuppressWarnings("unchecked")
|
|
private List<Map<String, Object>> searchTabling(Page page, String restaurantName) {
|
|
String query = "site:tabling.co.kr " + restaurantName;
|
|
log.info("[TABLING] Searching: {}", query);
|
|
|
|
String searchUrl = "https://www.google.com/search?q=" +
|
|
URLEncoder.encode(query, StandardCharsets.UTF_8);
|
|
page.navigate(searchUrl);
|
|
page.waitForTimeout(3000);
|
|
|
|
Object linksObj = page.evaluate("""
|
|
() => {
|
|
const results = [];
|
|
const links = document.querySelectorAll('a[href]');
|
|
for (const a of links) {
|
|
const href = a.href;
|
|
if (href.includes('tabling.co.kr/restaurant/') || href.includes('tabling.co.kr/place/')) {
|
|
const title = a.closest('[data-header-feature]')?.querySelector('h3')?.textContent
|
|
|| a.querySelector('h3')?.textContent
|
|
|| a.textContent?.trim()?.substring(0, 80)
|
|
|| '';
|
|
results.push({ title, url: href });
|
|
}
|
|
}
|
|
const seen = new Set();
|
|
return results.filter(r => {
|
|
if (seen.has(r.url)) return false;
|
|
seen.add(r.url);
|
|
return true;
|
|
}).slice(0, 5);
|
|
}
|
|
""");
|
|
|
|
List<Map<String, Object>> results = new ArrayList<>();
|
|
if (linksObj instanceof List<?> list) {
|
|
for (var item : list) {
|
|
if (item instanceof Map<?, ?> map) {
|
|
results.add(Map.of(
|
|
"title", String.valueOf(map.get("title")),
|
|
"url", String.valueOf(map.get("url"))
|
|
));
|
|
}
|
|
}
|
|
}
|
|
log.info("[TABLING] Found {} results for '{}'", results.size(), restaurantName);
|
|
return results;
|
|
}
|
|
|
|
@SuppressWarnings("unchecked")
|
|
private List<Map<String, Object>> searchCatchtable(Page page, String restaurantName) {
|
|
String query = "site:app.catchtable.co.kr " + restaurantName;
|
|
log.info("[CATCHTABLE] Searching: {}", query);
|
|
|
|
String searchUrl = "https://www.google.com/search?q=" +
|
|
URLEncoder.encode(query, StandardCharsets.UTF_8);
|
|
page.navigate(searchUrl);
|
|
page.waitForTimeout(3000);
|
|
|
|
Object linksObj = page.evaluate("""
|
|
() => {
|
|
const results = [];
|
|
const links = document.querySelectorAll('a[href]');
|
|
for (const a of links) {
|
|
const href = a.href;
|
|
if (href.includes('catchtable.co.kr/') && (href.includes('/dining/') || href.includes('/shop/'))) {
|
|
const title = a.closest('[data-header-feature]')?.querySelector('h3')?.textContent
|
|
|| a.querySelector('h3')?.textContent
|
|
|| a.textContent?.trim()?.substring(0, 80)
|
|
|| '';
|
|
results.push({ title, url: href });
|
|
}
|
|
}
|
|
const seen = new Set();
|
|
return results.filter(r => {
|
|
if (seen.has(r.url)) return false;
|
|
seen.add(r.url);
|
|
return true;
|
|
}).slice(0, 5);
|
|
}
|
|
""");
|
|
|
|
List<Map<String, Object>> results = new ArrayList<>();
|
|
if (linksObj instanceof List<?> list) {
|
|
for (var item : list) {
|
|
if (item instanceof Map<?, ?> map) {
|
|
results.add(Map.of(
|
|
"title", String.valueOf(map.get("title")),
|
|
"url", String.valueOf(map.get("url"))
|
|
));
|
|
}
|
|
}
|
|
}
|
|
log.info("[CATCHTABLE] Found {} results for '{}'", results.size(), restaurantName);
|
|
return results;
|
|
}
|
|
|
|
private void emit(SseEmitter emitter, Map<String, Object> data) {
|
|
try {
|
|
emitter.send(SseEmitter.event().data(objectMapper.writeValueAsString(data)));
|
|
} catch (Exception e) {
|
|
log.debug("SSE emit error: {}", e.getMessage());
|
|
}
|
|
}
|
|
}
|