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:
@@ -2,7 +2,6 @@ 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;
|
||||
@@ -15,15 +14,19 @@ import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
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.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/restaurants")
|
||||
@@ -139,12 +142,8 @@ public class RestaurantController {
|
||||
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());
|
||||
}
|
||||
try {
|
||||
return searchTabling(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());
|
||||
@@ -183,51 +182,44 @@ public class RestaurantController {
|
||||
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()));
|
||||
|
||||
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) {
|
||||
try {
|
||||
var results = searchTabling(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++;
|
||||
emit(emitter, Map.of("type", "error", "current", i + 1,
|
||||
"name", r.getName(), "message", e.getMessage()));
|
||||
log.info("[TABLING] Name mismatch: '{}' vs '{}', skipping", r.getName(), title);
|
||||
emit(emitter, Map.of("type", "notfound", "current", i + 1,
|
||||
"name", r.getName(), "reason", "이름 불일치: " + title));
|
||||
}
|
||||
|
||||
// Google 봇 판정 방지 랜덤 딜레이 (5~15초)
|
||||
int delay = ThreadLocalRandom.current().nextInt(5000, 15001);
|
||||
log.info("[TABLING] Waiting {}ms before next search...", delay);
|
||||
page.waitForTimeout(delay);
|
||||
} 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()));
|
||||
}
|
||||
|
||||
// 랜덤 딜레이 (2~5초)
|
||||
int delay = ThreadLocalRandom.current().nextInt(2000, 5001);
|
||||
log.info("[TABLING] Waiting {}ms before next search...", delay);
|
||||
Thread.sleep(delay);
|
||||
}
|
||||
|
||||
cache.flush();
|
||||
@@ -277,12 +269,8 @@ public class RestaurantController {
|
||||
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());
|
||||
}
|
||||
try {
|
||||
return searchCatchtable(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());
|
||||
@@ -321,50 +309,43 @@ public class RestaurantController {
|
||||
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()));
|
||||
|
||||
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) {
|
||||
try {
|
||||
var results = searchCatchtable(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++;
|
||||
emit(emitter, Map.of("type", "error", "current", i + 1,
|
||||
"name", r.getName(), "message", e.getMessage()));
|
||||
log.info("[CATCHTABLE] Name mismatch: '{}' vs '{}', skipping", r.getName(), title);
|
||||
emit(emitter, Map.of("type", "notfound", "current", i + 1,
|
||||
"name", r.getName(), "reason", "이름 불일치: " + title));
|
||||
}
|
||||
|
||||
int delay = ThreadLocalRandom.current().nextInt(5000, 15001);
|
||||
log.info("[CATCHTABLE] Waiting {}ms before next search...", delay);
|
||||
page.waitForTimeout(delay);
|
||||
} 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(2000, 5001);
|
||||
log.info("[CATCHTABLE] Waiting {}ms before next search...", delay);
|
||||
Thread.sleep(delay);
|
||||
}
|
||||
|
||||
cache.flush();
|
||||
@@ -407,119 +388,96 @@ public class RestaurantController {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Playwright helpers ──────────────────────────────────────────────
|
||||
// ─── DuckDuckGo HTML search helpers ─────────────────────────────────
|
||||
|
||||
private Browser launchBrowser(Playwright pw) {
|
||||
return pw.chromium().launch(new BrowserType.LaunchOptions()
|
||||
.setHeadless(false)
|
||||
.setArgs(List.of("--disable-blink-features=AutomationControlled")));
|
||||
}
|
||||
private static final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
|
||||
private BrowserContext newContext(Browser browser) {
|
||||
return browser.newContext(new Browser.NewContextOptions()
|
||||
.setLocale("ko-KR").setViewportSize(1280, 900));
|
||||
}
|
||||
private static final Pattern DDG_RESULT_PATTERN = Pattern.compile(
|
||||
"<a[^>]+class=\"result__a\"[^>]+href=\"([^\"]+)\"[^>]*>(.*?)</a>",
|
||||
Pattern.DOTALL
|
||||
);
|
||||
|
||||
private Page newPage(BrowserContext ctx) {
|
||||
Page page = ctx.newPage();
|
||||
page.addInitScript("Object.defineProperty(navigator, 'webdriver', {get: () => false})");
|
||||
return page;
|
||||
}
|
||||
/**
|
||||
* DuckDuckGo HTML 검색을 통해 특정 사이트의 URL을 찾는다.
|
||||
* html.duckduckgo.com은 서버사이드 렌더링이라 봇 판정 없이 HTTP 요청만으로 결과를 파싱할 수 있다.
|
||||
*/
|
||||
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")
|
||||
private List<Map<String, Object>> searchTabling(Page page, String restaurantName) {
|
||||
String query = "site:tabling.co.kr " + restaurantName;
|
||||
log.info("[TABLING] Searching: {}", query);
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(searchUrl))
|
||||
.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")
|
||||
.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=" +
|
||||
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);
|
||||
}
|
||||
""");
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
String html = response.body();
|
||||
|
||||
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"))
|
||||
));
|
||||
Set<String> seen = new HashSet<>();
|
||||
Matcher matcher = DDG_RESULT_PATTERN.matcher(html);
|
||||
|
||||
while (matcher.find() && results.size() < 5) {
|
||||
String href = matcher.group(1);
|
||||
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;
|
||||
}
|
||||
|
||||
@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"))
|
||||
));
|
||||
}
|
||||
/** DDG 리다이렉트 URL에서 실제 URL 추출 */
|
||||
private String extractDdgUrl(String ddgHref) {
|
||||
try {
|
||||
// //duckduckgo.com/l/?uddg=ENCODED_URL&rut=...
|
||||
if (ddgHref.contains("uddg=")) {
|
||||
String uddgParam = ddgHref.substring(ddgHref.indexOf("uddg=") + 5);
|
||||
int ampIdx = uddgParam.indexOf('&');
|
||||
if (ampIdx > 0) uddgParam = uddgParam.substring(0, ampIdx);
|
||||
return URLDecoder.decode(uddgParam, StandardCharsets.UTF_8);
|
||||
}
|
||||
// 직접 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 results;
|
||||
return null;
|
||||
}
|
||||
|
||||
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/"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -315,16 +315,16 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh
|
||||
</p>
|
||||
)}
|
||||
{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 && (
|
||||
<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 && (
|
||||
<p className="text-xs text-gray-500">{infoTarget.price_range}</p>
|
||||
<p className="text-[11px] text-gray-400">{infoTarget.price_range}</p>
|
||||
)}
|
||||
{infoTarget.phone && (
|
||||
<p className="text-xs text-gray-500">{infoTarget.phone}</p>
|
||||
<p className="text-[11px] text-gray-400">{infoTarget.phone}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onSelectRestaurant?.(infoTarget)}
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function RestaurantDetail({
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<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() && (
|
||||
<button
|
||||
onClick={handleToggleFavorite}
|
||||
@@ -95,30 +95,30 @@ export default function RestaurantDetail({
|
||||
</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 && (
|
||||
<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>
|
||||
)}
|
||||
{restaurant.address && (
|
||||
<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>
|
||||
)}
|
||||
{restaurant.region && (
|
||||
<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>
|
||||
)}
|
||||
{restaurant.price_range && (
|
||||
<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>
|
||||
)}
|
||||
{restaurant.phone && (
|
||||
<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">
|
||||
{restaurant.phone}
|
||||
</a>
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function RestaurantList({
|
||||
}`}
|
||||
>
|
||||
<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" />
|
||||
{r.name}
|
||||
</h4>
|
||||
@@ -64,7 +64,7 @@ export default function RestaurantList({
|
||||
)}
|
||||
</div>
|
||||
{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 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
|
||||
Reference in New Issue
Block a user