Playwright 제거 → DuckDuckGo HTML 검색 전환 + UI 미세 조정

- 테이블링/캐치테이블 검색: Google+Playwright → DuckDuckGo HTML 파싱 (브라우저 불필요)
- 검색 딜레이 5~15초 → 2~5초로 단축
- 프론트엔드: 정보 텍스트 계층 개선 (폰트 크기/색상 조정)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
joungmin
2026-03-12 19:28:49 +09:00
parent 75e0066dbe
commit 4b1f7c13b7
4 changed files with 165 additions and 207 deletions

View File

@@ -2,7 +2,6 @@ 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;
@@ -15,15 +14,19 @@ 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 org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.net.URI;
import java.net.URLDecoder;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
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; import java.util.concurrent.ThreadLocalRandom;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@RestController @RestController
@RequestMapping("/api/restaurants") @RequestMapping("/api/restaurants")
@@ -139,12 +142,8 @@ public class RestaurantController {
var r = restaurantService.findById(id); var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
try (Playwright pw = Playwright.create()) { try {
try (Browser browser = launchBrowser(pw)) { return searchTabling(r.getName());
BrowserContext ctx = newContext(browser);
Page page = newPage(ctx);
return searchTabling(page, r.getName());
}
} catch (Exception e) { } catch (Exception e) {
log.error("[TABLING] Search failed for '{}': {}", r.getName(), e.getMessage()); log.error("[TABLING] Search failed for '{}': {}", r.getName(), e.getMessage());
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage()); throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage());
@@ -183,51 +182,44 @@ public class RestaurantController {
int linked = 0; int linked = 0;
int notFound = 0; int notFound = 0;
try (Playwright pw = Playwright.create()) { for (int i = 0; i < total; i++) {
try (Browser browser = launchBrowser(pw)) { var r = restaurants.get(i);
BrowserContext ctx = newContext(browser); emit(emitter, Map.of("type", "processing", "current", i + 1,
Page page = newPage(ctx); "total", total, "name", r.getName()));
for (int i = 0; i < total; i++) { try {
var r = restaurants.get(i); var results = searchTabling(r.getName());
emit(emitter, Map.of("type", "processing", "current", i + 1, if (!results.isEmpty()) {
"total", total, "name", r.getName())); String url = String.valueOf(results.get(0).get("url"));
String title = String.valueOf(results.get(0).get("title"));
try { if (isNameSimilar(r.getName(), title)) {
var results = searchTabling(page, r.getName()); restaurantService.update(r.getId(), Map.of("tabling_url", url));
if (!results.isEmpty()) { linked++;
String url = String.valueOf(results.get(0).get("url")); emit(emitter, Map.of("type", "done", "current", i + 1,
String title = String.valueOf(results.get(0).get("title")); "name", r.getName(), "url", url, "title", title));
if (isNameSimilar(r.getName(), title)) { } else {
restaurantService.update(r.getId(), Map.of("tabling_url", url)); restaurantService.update(r.getId(), Map.of("tabling_url", "NONE"));
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++; notFound++;
emit(emitter, Map.of("type", "error", "current", i + 1, log.info("[TABLING] Name mismatch: '{}' vs '{}', skipping", r.getName(), title);
"name", r.getName(), "message", e.getMessage())); emit(emitter, Map.of("type", "notfound", "current", i + 1,
"name", r.getName(), "reason", "이름 불일치: " + title));
} }
} else {
// Google 봇 판정 방지 랜덤 딜레이 (5~15초) restaurantService.update(r.getId(), Map.of("tabling_url", "NONE"));
int delay = ThreadLocalRandom.current().nextInt(5000, 15001); notFound++;
log.info("[TABLING] Waiting {}ms before next search...", delay); emit(emitter, Map.of("type", "notfound", "current", i + 1,
page.waitForTimeout(delay); "name", r.getName()));
} }
} catch (Exception e) {
notFound++;
emit(emitter, Map.of("type", "error", "current", i + 1,
"name", r.getName(), "message", e.getMessage()));
} }
// 랜덤 딜레이 (2~5초)
int delay = ThreadLocalRandom.current().nextInt(2000, 5001);
log.info("[TABLING] Waiting {}ms before next search...", delay);
Thread.sleep(delay);
} }
cache.flush(); cache.flush();
@@ -277,12 +269,8 @@ 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); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
try (Playwright pw = Playwright.create()) { try {
try (Browser browser = launchBrowser(pw)) { return searchCatchtable(r.getName());
BrowserContext ctx = newContext(browser);
Page page = newPage(ctx);
return searchCatchtable(page, r.getName());
}
} catch (Exception e) { } catch (Exception e) {
log.error("[CATCHTABLE] Search failed for '{}': {}", r.getName(), e.getMessage()); log.error("[CATCHTABLE] Search failed for '{}': {}", r.getName(), e.getMessage());
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage()); throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage());
@@ -321,50 +309,43 @@ public class RestaurantController {
int linked = 0; int linked = 0;
int notFound = 0; int notFound = 0;
try (Playwright pw = Playwright.create()) { for (int i = 0; i < total; i++) {
try (Browser browser = launchBrowser(pw)) { var r = restaurants.get(i);
BrowserContext ctx = newContext(browser); emit(emitter, Map.of("type", "processing", "current", i + 1,
Page page = newPage(ctx); "total", total, "name", r.getName()));
for (int i = 0; i < total; i++) { try {
var r = restaurants.get(i); var results = searchCatchtable(r.getName());
emit(emitter, Map.of("type", "processing", "current", i + 1, if (!results.isEmpty()) {
"total", total, "name", r.getName())); String url = String.valueOf(results.get(0).get("url"));
String title = String.valueOf(results.get(0).get("title"));
try { if (isNameSimilar(r.getName(), title)) {
var results = searchCatchtable(page, r.getName()); restaurantService.update(r.getId(), Map.of("catchtable_url", url));
if (!results.isEmpty()) { linked++;
String url = String.valueOf(results.get(0).get("url")); emit(emitter, Map.of("type", "done", "current", i + 1,
String title = String.valueOf(results.get(0).get("title")); "name", r.getName(), "url", url, "title", title));
if (isNameSimilar(r.getName(), title)) { } else {
restaurantService.update(r.getId(), Map.of("catchtable_url", url)); restaurantService.update(r.getId(), Map.of("catchtable_url", "NONE"));
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++; notFound++;
emit(emitter, Map.of("type", "error", "current", i + 1, log.info("[CATCHTABLE] Name mismatch: '{}' vs '{}', skipping", r.getName(), title);
"name", r.getName(), "message", e.getMessage())); emit(emitter, Map.of("type", "notfound", "current", i + 1,
"name", r.getName(), "reason", "이름 불일치: " + title));
} }
} else {
int delay = ThreadLocalRandom.current().nextInt(5000, 15001); restaurantService.update(r.getId(), Map.of("catchtable_url", "NONE"));
log.info("[CATCHTABLE] Waiting {}ms before next search...", delay); notFound++;
page.waitForTimeout(delay); 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(2000, 5001);
log.info("[CATCHTABLE] Waiting {}ms before next search...", delay);
Thread.sleep(delay);
} }
cache.flush(); cache.flush();
@@ -407,119 +388,96 @@ public class RestaurantController {
return result; return result;
} }
// ─── Playwright helpers ────────────────────────────────────────────── // ─── DuckDuckGo HTML search helpers ─────────────────────────────────
private Browser launchBrowser(Playwright pw) { private static final HttpClient httpClient = HttpClient.newBuilder()
return pw.chromium().launch(new BrowserType.LaunchOptions() .followRedirects(HttpClient.Redirect.NORMAL)
.setHeadless(false) .build();
.setArgs(List.of("--disable-blink-features=AutomationControlled")));
}
private BrowserContext newContext(Browser browser) { private static final Pattern DDG_RESULT_PATTERN = Pattern.compile(
return browser.newContext(new Browser.NewContextOptions() "<a[^>]+class=\"result__a\"[^>]+href=\"([^\"]+)\"[^>]*>(.*?)</a>",
.setLocale("ko-KR").setViewportSize(1280, 900)); Pattern.DOTALL
} );
private Page newPage(BrowserContext ctx) { /**
Page page = ctx.newPage(); * DuckDuckGo HTML 검색을 통해 특정 사이트의 URL을 찾는다.
page.addInitScript("Object.defineProperty(navigator, 'webdriver', {get: () => false})"); * html.duckduckgo.com은 서버사이드 렌더링이라 봇 판정 없이 HTTP 요청만으로 결과를 파싱할 수 있다.
return page; */
} private List<Map<String, Object>> searchDuckDuckGo(String query, String... urlPatterns) throws Exception {
String encoded = URLEncoder.encode(query, StandardCharsets.UTF_8);
String searchUrl = "https://html.duckduckgo.com/html/?q=" + encoded;
log.info("[DDG] Searching: {}", query);
@SuppressWarnings("unchecked") HttpRequest request = HttpRequest.newBuilder()
private List<Map<String, Object>> searchTabling(Page page, String restaurantName) { .uri(URI.create(searchUrl))
String query = "site:tabling.co.kr " + restaurantName; .header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
log.info("[TABLING] Searching: {}", query); .header("Accept", "text/html,application/xhtml+xml")
.header("Accept-Language", "ko-KR,ko;q=0.9")
.GET()
.build();
String searchUrl = "https://www.google.com/search?q=" + HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
URLEncoder.encode(query, StandardCharsets.UTF_8); String html = response.body();
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<>(); List<Map<String, Object>> results = new ArrayList<>();
if (linksObj instanceof List<?> list) { Set<String> seen = new HashSet<>();
for (var item : list) { Matcher matcher = DDG_RESULT_PATTERN.matcher(html);
if (item instanceof Map<?, ?> map) {
results.add(Map.of( while (matcher.find() && results.size() < 5) {
"title", String.valueOf(map.get("title")), String href = matcher.group(1);
"url", String.valueOf(map.get("url")) String title = matcher.group(2).replaceAll("<[^>]+>", "").trim();
));
// DDG 링크에서 실제 URL 추출 (uddg 파라미터)
String actualUrl = extractDdgUrl(href);
if (actualUrl == null) continue;
boolean matches = false;
for (String pattern : urlPatterns) {
if (actualUrl.contains(pattern)) {
matches = true;
break;
} }
} }
if (matches && !seen.contains(actualUrl)) {
seen.add(actualUrl);
results.add(Map.of("title", title, "url", actualUrl));
}
} }
log.info("[TABLING] Found {} results for '{}'", results.size(), restaurantName);
log.info("[DDG] Found {} results for '{}'", results.size(), query);
return results; return results;
} }
@SuppressWarnings("unchecked") /** DDG 리다이렉트 URL에서 실제 URL 추출 */
private List<Map<String, Object>> searchCatchtable(Page page, String restaurantName) { private String extractDdgUrl(String ddgHref) {
String query = "site:app.catchtable.co.kr " + restaurantName; try {
log.info("[CATCHTABLE] Searching: {}", query); // //duckduckgo.com/l/?uddg=ENCODED_URL&rut=...
if (ddgHref.contains("uddg=")) {
String searchUrl = "https://www.google.com/search?q=" + String uddgParam = ddgHref.substring(ddgHref.indexOf("uddg=") + 5);
URLEncoder.encode(query, StandardCharsets.UTF_8); int ampIdx = uddgParam.indexOf('&');
page.navigate(searchUrl); if (ampIdx > 0) uddgParam = uddgParam.substring(0, ampIdx);
page.waitForTimeout(3000); return URLDecoder.decode(uddgParam, StandardCharsets.UTF_8);
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"))
));
}
} }
// 직접 URL인 경우
if (ddgHref.startsWith("http")) return ddgHref;
} catch (Exception e) {
log.debug("[DDG] Failed to extract URL from: {}", ddgHref);
} }
log.info("[CATCHTABLE] Found {} results for '{}'", results.size(), restaurantName); return null;
return results; }
private List<Map<String, Object>> searchTabling(String restaurantName) throws Exception {
return searchDuckDuckGo(
"site:tabling.co.kr " + restaurantName,
"tabling.co.kr/restaurant/", "tabling.co.kr/place/"
);
}
private List<Map<String, Object>> searchCatchtable(String restaurantName) throws Exception {
return searchDuckDuckGo(
"site:app.catchtable.co.kr " + restaurantName,
"catchtable.co.kr/dining/", "catchtable.co.kr/shop/"
);
} }
/** /**

View File

@@ -315,16 +315,16 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh
</p> </p>
)} )}
{infoTarget.cuisine_type && ( {infoTarget.cuisine_type && (
<p className="text-sm text-gray-600">{infoTarget.cuisine_type}</p> <p className="text-xs text-gray-500">{infoTarget.cuisine_type}</p>
)} )}
{infoTarget.address && ( {infoTarget.address && (
<p className="text-xs text-gray-500 mt-1">{infoTarget.address}</p> <p className="text-[11px] text-gray-400 mt-1">{infoTarget.address}</p>
)} )}
{infoTarget.price_range && ( {infoTarget.price_range && (
<p className="text-xs text-gray-500">{infoTarget.price_range}</p> <p className="text-[11px] text-gray-400">{infoTarget.price_range}</p>
)} )}
{infoTarget.phone && ( {infoTarget.phone && (
<p className="text-xs text-gray-500">{infoTarget.phone}</p> <p className="text-[11px] text-gray-400">{infoTarget.phone}</p>
)} )}
<button <button
onClick={() => onSelectRestaurant?.(infoTarget)} onClick={() => onSelectRestaurant?.(infoTarget)}

View File

@@ -52,7 +52,7 @@ export default function RestaurantDetail({
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="text-lg font-bold dark:text-gray-100">{restaurant.name}</h2> <h2 className="text-xl font-bold dark:text-gray-100">{restaurant.name}</h2>
{getToken() && ( {getToken() && (
<button <button
onClick={handleToggleFavorite} onClick={handleToggleFavorite}
@@ -95,30 +95,30 @@ export default function RestaurantDetail({
</div> </div>
)} )}
<div className="space-y-1 text-sm dark:text-gray-300"> <div className="space-y-1 text-xs text-gray-500 dark:text-gray-400">
{restaurant.cuisine_type && ( {restaurant.cuisine_type && (
<p> <p>
<span className="text-gray-500 dark:text-gray-400">:</span> {restaurant.cuisine_type} <span className="text-gray-400 dark:text-gray-500"></span> <span className="text-gray-600 dark:text-gray-300">{restaurant.cuisine_type}</span>
</p> </p>
)} )}
{restaurant.address && ( {restaurant.address && (
<p> <p>
<span className="text-gray-500 dark:text-gray-400">:</span> {restaurant.address} <span className="text-gray-400 dark:text-gray-500"></span> <span className="text-gray-600 dark:text-gray-300">{restaurant.address}</span>
</p> </p>
)} )}
{restaurant.region && ( {restaurant.region && (
<p> <p>
<span className="text-gray-500 dark:text-gray-400">:</span> {restaurant.region} <span className="text-gray-400 dark:text-gray-500"></span> <span className="text-gray-600 dark:text-gray-300">{restaurant.region}</span>
</p> </p>
)} )}
{restaurant.price_range && ( {restaurant.price_range && (
<p> <p>
<span className="text-gray-500 dark:text-gray-400">:</span> {restaurant.price_range} <span className="text-gray-400 dark:text-gray-500"></span> <span className="text-gray-600 dark:text-gray-300">{restaurant.price_range}</span>
</p> </p>
)} )}
{restaurant.phone && ( {restaurant.phone && (
<p> <p>
<span className="text-gray-500 dark:text-gray-400">:</span>{" "} <span className="text-gray-400 dark:text-gray-500"></span>{" "}
<a href={`tel:${restaurant.phone}`} className="text-brand-600 dark:text-brand-400 hover:underline"> <a href={`tel:${restaurant.phone}`} className="text-brand-600 dark:text-brand-400 hover:underline">
{restaurant.phone} {restaurant.phone}
</a> </a>

View File

@@ -45,7 +45,7 @@ export default function RestaurantList({
}`} }`}
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<h4 className="font-semibold text-sm text-gray-900 dark:text-gray-100"> <h4 className="font-bold text-[15px] text-gray-900 dark:text-gray-100">
<Icon name={getCuisineIcon(r.cuisine_type)} size={16} className="mr-0.5 text-brand-600" /> <Icon name={getCuisineIcon(r.cuisine_type)} size={16} className="mr-0.5 text-brand-600" />
{r.name} {r.name}
</h4> </h4>
@@ -64,7 +64,7 @@ export default function RestaurantList({
)} )}
</div> </div>
{r.region && ( {r.region && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-500 truncate">{r.region}</p> <p className="mt-1 text-[11px] text-gray-400 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">