diff --git a/CHANGELOG.json b/CHANGELOG.json index 5925822..60e8cd3 100644 --- a/CHANGELOG.json +++ b/CHANGELOG.json @@ -1,11 +1,33 @@ { "metadata": { - "lastUpdated": "2026-01-13T15:12:41Z", - "currentVersion": "0.1.2", + "lastUpdated": "2026-01-18T04:58:48Z", + "currentVersion": "0.1.3", "projectType": "python", - "totalReleases": 3 + "totalReleases": 4 }, "releases": [ + { + "version": "0.1.3", + "project_type": "python", + "date": "2026-01-18", + "pr_number": 13, + "raw_summary": "## Summary by CodeRabbit\n\n## 새 기능\n\n* Google 지도와 네이버 지도에서 장소 정보를 검색하고 수집하는 기능 추가\n* 새로운 API 엔드포인트를 통해 두 지도 서비스에서 장소명 검색 지원\n* 검색 결과로부터 주소, 평점, 영업 시간, 전화번호, 이미지 등의 상세 정보 추출", + "parsed_changes": { + "google_지도와_네이버_지도에서_장소_정보를_검색하고_수집하는_기능_추가": { + "title": "Google 지도와 네이버 지도에서 장소 정보를 검색하고 수집하는 기능 추가", + "items": [] + }, + "새로운_api_엔드포인트를_통해_두_지도_서비스에서_장소명_검색_지원": { + "title": "새로운 API 엔드포인트를 통해 두 지도 서비스에서 장소명 검색 지원", + "items": [] + }, + "검색_결과로부터_주소__평점__영업_시간__전화번호__이미지_등의_상세_정보_추출": { + "title": "검색 결과로부터 주소, 평점, 영업 시간, 전화번호, 이미지 등의 상세 정보 추출", + "items": [] + } + }, + "parse_method": "markdown" + }, { "version": "0.1.2", "project_type": "python", diff --git a/CHANGELOG.md b/CHANGELOG.md index 7411132..08572be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,19 @@ # Changelog -**현재 버전:** 0.1.2 -**마지막 업데이트:** 2026-01-13T15:12:41Z +**현재 버전:** 0.1.3 +**마지막 업데이트:** 2026-01-18T04:58:48Z + +--- + +## [0.1.3] - 2026-01-18 + +**PR:** #13 + +**Google 지도와 네이버 지도에서 장소 정보를 검색하고 수집하는 기능 추가** + +**새로운 API 엔드포인트를 통해 두 지도 서비스에서 장소명 검색 지원** + +**검색 결과로부터 주소, 평점, 영업 시간, 전화번호, 이미지 등의 상세 정보 추출** --- diff --git a/README.md b/README.md index dc13973..c61c6ea 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # MapSee-AI -## 최신 버전 : v0.1.1 (2026-01-11) +## 최신 버전 : v0.1.2 (2026-01-13) [전체 버전 기록 보기](CHANGELOG.md) diff --git a/src/apis/test_router.py b/src/apis/test_router.py index 8f7b87d..d7987c8 100644 --- a/src/apis/test_router.py +++ b/src/apis/test_router.py @@ -3,9 +3,11 @@ """ import logging from fastapi import APIRouter -from pydantic import BaseModel +from pydantic import BaseModel, Field from src.services.scraper.scrape_router import route_and_scrape +from src.services.scraper.platforms.naver_map_scraper import NaverMapScraper +from src.services.scraper.platforms.google_map_scraper import GoogleMapScraper logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/test", tags=["테스트 API"]) @@ -15,6 +17,16 @@ class ScrapeRequest(BaseModel): url: str +class NaverMapSearchRequest(BaseModel): + """네이버 지도 검색 요청""" + query: str = Field(..., description="검색어 (예: 늘푸른목장)", min_length=1) + + +class GoogleMapSearchRequest(BaseModel): + """구글 지도 검색 요청""" + query: str = Field(..., description="검색어 (예: 늘푸른목장)", min_length=1) + + @router.post("/scrape", status_code=200) async def scrape_url(request: ScrapeRequest): """ @@ -33,3 +45,61 @@ async def scrape_url(request: ScrapeRequest): async def health_check(): """스크래핑 테스트 API 상태 확인""" return {"status": "ok"} + + +@router.post("/naver-map", status_code=200) +async def scrape_naver_map(request: NaverMapSearchRequest): + """ + 네이버 지도에서 장소 정보 스크래핑 + + 장소명을 검색하여 첫 번째 검색 결과의 상세 정보를 추출합니다. + + - POST /api/test/naver-map + - Body: {"query": "늘푸른목장"} + - 성공: 200 + NaverPlaceInfo + - 실패: 4xx/5xx + 에러 메시지 + + 추출 정보: + - 장소명, 카테고리 + - 별점, 리뷰 수 (방문자/블로그) + - 주소, 지하철 정보 + - 영업 상태, 영업 시간 + - 전화번호, 편의시설 + """ + logger.info(f"네이버 지도 스크래핑 요청: query='{request.query}'") + + scraper = NaverMapScraper() + result = await scraper.search_and_scrape(request.query) + + logger.info(f"스크래핑 완료: {result.name} ({result.place_id})") + return result + + +@router.post("/google-map", status_code=200) +async def scrape_google_map(request: GoogleMapSearchRequest): + """ + 구글 지도에서 장소 정보 스크래핑 + + 장소명을 검색하여 첫 번째 검색 결과의 상세 정보를 추출합니다. + + - POST /api/test/google-map + - Body: {"query": "늘푸른목장"} + - 성공: 200 + GooglePlaceInfo + - 실패: 4xx/5xx + 에러 메시지 + + 추출 정보: + - 장소명, 카테고리 + - 별점, 리뷰 수 + - 가격대, 주소 + - 영업 상태, 요일별 영업 시간 + - 전화번호, Plus Code + - 위도/경도, 웹사이트 URL + - 대표 이미지 URL + """ + logger.info(f"구글 지도 스크래핑 요청: query='{request.query}'") + + scraper = GoogleMapScraper() + result = await scraper.search_and_scrape(request.query) + + logger.info(f"스크래핑 완료: {result.name} ({result.place_id})") + return result diff --git a/src/models/google_place_info.py b/src/models/google_place_info.py new file mode 100644 index 0000000..b6f50ce --- /dev/null +++ b/src/models/google_place_info.py @@ -0,0 +1,81 @@ +"""src.models.google_place_info +구글 지도 장소 정보 스키마 +""" +from pydantic import BaseModel, Field + + +class GooglePlaceInfo(BaseModel): + """ + 구글 지도 장소 상세 정보 + + 구글 지도 검색 결과에서 추출한 장소 정보를 담는 스키마입니다. + """ + # 기본 정보 + place_id: str = Field(..., description="구글 Place ID (URL에서 추출)") + name: str = Field(..., description="장소명") + category: str | None = Field(default=None, description="카테고리 (예: 숯불구이/바베큐전문점)") + + # URL 정보 + google_map_url: str | None = Field(default=None, description="구글 지도 상세 페이지 URL") + + # 위치 정보 + latitude: float | None = Field(default=None, description="위도") + longitude: float | None = Field(default=None, description="경도") + address: str | None = Field(default=None, description="주소") + plus_code: str | None = Field(default=None, description="Plus Code (예: G35M+R3 서울특별시)") + + # 평점/리뷰 + rating: float | None = Field(default=None, description="별점 (0.0 ~ 5.0)") + review_count: int | None = Field(default=None, description="리뷰 수") + price_level: str | None = Field(default=None, description="가격대 (예: ₩₩₩)") + + # 영업 정보 + business_status: str | None = Field(default=None, description="영업 상태 (예: 영업 중)") + business_hours: dict[str, str] | None = Field(default=None, description="요일별 영업 시간") + + # 연락처/링크 + phone_number: str | None = Field(default=None, description="전화번호") + website_url: str | None = Field(default=None, description="웹사이트 URL") + + # 부가 정보 + description: str | None = Field(default=None, description="장소 설명/소개") + amenities: list[str] = Field(default_factory=list, description="편의시설/서비스 옵션") + popular_times: str | None = Field(default=None, description="인기 시간대 정보") + + # 이미지 + image_url: str | None = Field(default=None, description="대표 이미지 URL") + image_urls: list[str] = Field(default_factory=list, description="이미지 URL 목록 (최대 10개)") + + class Config: + json_schema_extra = { + "example": { + "place_id": "0x357ca44d6e36590b:0xc0019dfb09b9a4e2", + "name": "늘푸른목장 잠실본점", + "category": "숯불구이/바베큐전문점", + "google_map_url": "https://www.google.com/maps/place/...", + "latitude": 37.509573, + "longitude": 127.0826454, + "address": "서울특별시 송파구 백제고분로9길 34", + "plus_code": "G35M+R3 서울특별시", + "rating": 4.2, + "review_count": 753, + "price_level": "₩₩₩", + "business_status": "영업 중", + "business_hours": { + "일요일": "24시간 영업", + "월요일": "PM 12:00~AM 12:00", + "화요일": "PM 12:00~AM 12:00", + "수요일": "PM 12:00~AM 12:00", + "목요일": "PM 12:00~AM 12:00", + "금요일": "PM 12:00~AM 12:00", + "토요일": "PM 12:00~AM 12:00" + }, + "phone_number": "02-3431-4520", + "website_url": "https://example.com", + "description": "된장찌개와 냉면으로 완성하는 한상차림", + "amenities": ["매장 내 식사", "테이크아웃", "배달"], + "popular_times": "오후 7시~9시 가장 붐빔", + "image_url": "https://lh5.googleusercontent.com/...", + "image_urls": ["https://...", "https://..."] + } + } diff --git a/src/models/naver_place_info.py b/src/models/naver_place_info.py new file mode 100644 index 0000000..4d63c65 --- /dev/null +++ b/src/models/naver_place_info.py @@ -0,0 +1,87 @@ +"""src.models.naver_place_info +네이버 지도 장소 정보 스키마 +""" +from pydantic import BaseModel, Field + + +class NaverPlaceInfo(BaseModel): + """ + 네이버 지도 장소 상세 정보 + + 네이버 지도 검색 결과에서 추출한 장소 정보를 담는 스키마입니다. + """ + # 기본 정보 + place_id: str = Field(..., description="네이버 Place ID") + name: str = Field(..., description="장소명") + category: str | None = Field(default=None, description="카테고리 (예: 소고기구이)") + + # URL 정보 (라우팅용) + naver_map_url: str | None = Field(default=None, description="네이버 지도 상세 페이지 URL") + + # 위치 정보 + latitude: float | None = Field(default=None, description="위도") + longitude: float | None = Field(default=None, description="경도") + address: str | None = Field(default=None, description="주소") + road_address: str | None = Field(default=None, description="도로명 주소") + subway_info: str | None = Field(default=None, description="지하철 정보 (예: 잠실새내역 4번 출구에서 412m)") + directions_text: str | None = Field(default=None, description="찾아가는 길 설명") + + # 평점/리뷰 + rating: float | None = Field(default=None, description="별점 (0.0 ~ 5.0)") + visitor_review_count: int | None = Field(default=None, description="방문자 리뷰 수") + blog_review_count: int | None = Field(default=None, description="블로그 리뷰 수") + + # 영업 정보 + business_status: str | None = Field(default=None, description="영업 상태 (예: 영업 중)") + business_hours: str | None = Field(default=None, description="영업 시간 요약") + open_hours_detail: list[str] = Field(default_factory=list, description="요일별 상세 영업시간") + holiday_info: str | None = Field(default=None, description="휴무일 정보") + + # 연락처/링크 + phone_number: str | None = Field(default=None, description="전화번호") + homepage_url: str | None = Field(default=None, description="홈페이지 URL") + reservation_available: bool = Field(default=False, description="예약 가능 여부") + + # 부가 정보 + description: str | None = Field(default=None, description="한줄 설명") + amenities: list[str] = Field(default_factory=list, description="편의시설 목록") + keywords: list[str] = Field(default_factory=list, description="키워드/태그") + tv_appearances: list[str] = Field(default_factory=list, description="TV 방송 출연 정보") + menu_info: list[str] = Field(default_factory=list, description="대표 메뉴") + + # 이미지 + image_url: str | None = Field(default=None, description="대표 이미지 URL") + image_urls: list[str] = Field(default_factory=list, description="이미지 URL 목록 (최대 10개)") + + class Config: + json_schema_extra = { + "example": { + "place_id": "11679241", + "name": "늘푸른목장 잠실본점", + "category": "소고기구이", + "naver_map_url": "https://map.naver.com/p/search/늘푸른목장/place/11679241", + "latitude": 37.5112, + "longitude": 127.0867, + "address": "서울 송파구 백제고분로9길 34 1F", + "road_address": "서울 송파구 백제고분로9길 34 1F", + "subway_info": "잠실새내역 4번 출구에서 412m", + "directions_text": "잠실새내역 4번 출구에서 맥도널드 골목 끼고...", + "rating": 4.42, + "visitor_review_count": 1510, + "blog_review_count": 1173, + "business_status": "영업 중", + "business_hours": "24:00에 영업 종료", + "open_hours_detail": ["월 11:30 - 24:00", "화 11:30 - 24:00"], + "holiday_info": "연중무휴", + "phone_number": "02-3431-4520", + "homepage_url": "http://example.com", + "reservation_available": True, + "description": "된장찌개와 냉면으로 완성하는 한상차림", + "amenities": ["단체 이용 가능", "주차", "발렛파킹"], + "keywords": ["소고기", "한우", "회식"], + "tv_appearances": ["줄서는식당 14회 (24.05.13)"], + "menu_info": ["경주갈비살", "한우된장밥"], + "image_url": "https://...", + "image_urls": ["https://...", "https://..."] + } + } diff --git a/src/services/scraper/common_util.py b/src/services/scraper/common_util.py new file mode 100644 index 0000000..c3955a7 --- /dev/null +++ b/src/services/scraper/common_util.py @@ -0,0 +1,194 @@ +"""src.services.scraper.common_util +지도 스크래퍼 공통 유틸리티 함수 +""" +import re +import logging + +logger = logging.getLogger(__name__) + + +# ============================================================ +# 타임아웃 설정 상수 +# ============================================================ +SCRAPE_TIMEOUT_MS = 60000 # 스크래핑 타임아웃 (밀리초) +PAGE_LOAD_WAIT_SEC = 3 # 페이지 로드 대기 시간 (초) +ELEMENT_WAIT_TIMEOUT_MS = 10000 # 요소 대기 타임아웃 (밀리초) +MAX_IMAGE_COUNT = 10 # 이미지 최대 추출 개수 + + +# ============================================================ +# 파싱 유틸리티 함수 +# ============================================================ +def parse_review_count(text: str | None) -> int | None: + """ + 리뷰 텍스트에서 숫자 추출 + + Args: + text: "(753)" 또는 "리뷰 753개" 또는 "방문자 리뷰 1,510" 형식의 텍스트 + + Returns: + int | None: 파싱된 숫자 또는 None + """ + if not text: + return None + match = re.search(r'([\d,]+)', text) + if match: + return int(match.group(1).replace(',', '')) + return None + + +def parse_rating(text: str | None) -> float | None: + """ + 별점 텍스트에서 숫자 추출 + + Args: + text: "4.2" 또는 "4.42" 형식의 텍스트 + + Returns: + float | None: 파싱된 별점 또는 None + """ + if not text: + return None + try: + return float(text.strip()) + except ValueError: + return None + + +def parse_aria_label_value(aria_label: str | None, prefix: str) -> str | None: + """ + aria-label에서 접두사 제거하고 값 추출 + + Args: + aria_label: "주소: 서울특별시 송파구..." 형식 + prefix: "주소: " 같은 접두사 + + Returns: + str | None: 추출된 값 + """ + if not aria_label: + return None + + if aria_label.startswith(prefix): + return aria_label[len(prefix):].strip() + + return aria_label.strip() + + +# ============================================================ +# URL 파싱 유틸리티 함수 +# ============================================================ +def extract_coordinates_from_url(url: str) -> tuple[float | None, float | None]: + """ + URL에서 위도/경도 추출 + + 지원 패턴: + - 구글: !3d{latitude}!4d{longitude} + - 네이버/구글: @{lat},{lng} + + Args: + url: 지도 URL + + Returns: + tuple[float | None, float | None]: (위도, 경도) + """ + latitude = None + longitude = None + + # 패턴 1: !3d{latitude}!4d{longitude} (구글 지도) + lat_match = re.search(r'!3d(-?[\d.]+)', url) + lng_match = re.search(r'!4d(-?[\d.]+)', url) + + if lat_match: + try: + latitude = float(lat_match.group(1)) + except ValueError: + pass + + if lng_match: + try: + longitude = float(lng_match.group(1)) + except ValueError: + pass + + # 패턴 2: @{lat},{lng} (네이버/구글 공통) + if latitude is None or longitude is None: + alt_match = re.search(r'@(-?[\d.]+),(-?[\d.]+)', url) + if alt_match: + try: + latitude = float(alt_match.group(1)) + longitude = float(alt_match.group(2)) + except ValueError: + pass + + return latitude, longitude + + +def extract_google_place_id_from_url(url: str) -> str | None: + """ + URL에서 구글 Place ID 추출 + + Args: + url: 구글 지도 URL (예: ...!1s0x357ca44d6e36590b:0xc0019dfb09b9a4e2...) + + Returns: + str | None: Place ID 또는 None + """ + # 패턴 1: !1s 뒤의 Place ID (0x...형식) + match = re.search(r'!1s(0x[a-f0-9]+:0x[a-f0-9]+)', url) + if match: + return match.group(1) + + # 패턴 2: /place/ 뒤의 인코딩된 이름 + match = re.search(r'/place/([^/]+)', url) + if match: + return match.group(1) + + return None + + +def extract_naver_place_id_from_url(url: str) -> str | None: + """ + URL에서 네이버 Place ID 추출 + + Args: + url: 네이버 지도 URL (예: .../place/11679241?...) + + Returns: + str | None: Place ID 또는 None + """ + match = re.search(r'/place/(\d+)', url) + return match.group(1) if match else None + + +# ============================================================ +# 가격대 파싱 유틸리티 +# ============================================================ +def parse_price_level(price_aria: str | None) -> str | None: + """ + 가격대 aria-label에서 가격 수준 추출 + + Args: + price_aria: "비쌈", "₩₩₩" 등의 텍스트 + + Returns: + str | None: 가격대 문자열 (₩, ₩₩, ₩₩₩) 또는 None + """ + if not price_aria: + return None + + # ₩ 문자가 있으면 그대로 추출 + if '₩' in price_aria: + price_match = re.search(r'(₩+)', price_aria) + if price_match: + return price_match.group(1) + + # 텍스트로 매핑 + if '비싸' in price_aria or '비쌈' in price_aria: + return "₩₩₩" + elif '보통' in price_aria: + return "₩₩" + elif '저렴' in price_aria: + return "₩" + + return None diff --git a/src/services/scraper/platforms/google_map_scraper.py b/src/services/scraper/platforms/google_map_scraper.py new file mode 100644 index 0000000..5f93910 --- /dev/null +++ b/src/services/scraper/platforms/google_map_scraper.py @@ -0,0 +1,310 @@ +"""src.services.scraper.platforms.google_map_scraper +구글 지도 장소 스크래핑 로직 +""" +import logging +import asyncio +from urllib.parse import quote + +from playwright.async_api import async_playwright +from fastapi import HTTPException + +from src.services.scraper.playwright_browser import PlaywrightBrowser +from src.services.scraper.common_util import ( + parse_review_count, + parse_rating, + parse_aria_label_value, + parse_price_level, + extract_coordinates_from_url, + extract_google_place_id_from_url, + SCRAPE_TIMEOUT_MS, + PAGE_LOAD_WAIT_SEC, + ELEMENT_WAIT_TIMEOUT_MS, + MAX_IMAGE_COUNT, +) +from src.models.google_place_info import GooglePlaceInfo + +logger = logging.getLogger(__name__) + + +class GoogleMapScraper: + """구글 지도 장소 스크래퍼""" + + def __init__(self): + self.browser_controller = PlaywrightBrowser() + + async def search_and_scrape(self, query: str) -> GooglePlaceInfo: + """ + 구글 지도에서 장소 검색 후 첫 번째 결과의 상세 정보 스크래핑 + + Args: + query: 검색어 (예: "늘푸른목장") + + Returns: + GooglePlaceInfo: 추출된 장소 정보 + + Raises: + HTTPException: 스크래핑 실패 시 + """ + logger.info(f"[1/7] 구글 지도 검색 시작: query='{query}'") + + # 검색 URL 생성 + encoded_query = quote(query) + search_url = f"https://www.google.com/maps/search/{encoded_query}" + logger.info(f"검색 URL: {search_url}") + + async with async_playwright() as playwright: + try: + # [2/7] 브라우저 생성 + logger.info("[2/7] 브라우저 초기화...") + await self.browser_controller.create_browser_and_context(playwright) + + # [3/7] 검색 페이지 로드 + logger.info("[3/7] 검색 페이지 로드...") + await self.browser_controller.load_page( + search_url, + wait_until="domcontentloaded", + timeout=SCRAPE_TIMEOUT_MS + ) + + page = self.browser_controller.page + + # 검색 결과 또는 직접 장소 페이지 로드 대기 + logger.info("검색 결과 대기...") + await asyncio.sleep(PAGE_LOAD_WAIT_SEC) + + # [4/7] 첫 번째 검색 결과 클릭 (검색 결과 목록이 있는 경우) + logger.info("[4/7] 첫 번째 검색 결과 확인...") + + current_url = page.url + + # 이미 상세 페이지인지 확인 (URL에 /place/가 있으면 상세 페이지) + if '/maps/place/' not in current_url: + try: + # 검색 결과 목록에서 첫 번째 결과 클릭 + result_selector = 'div[role="feed"] > div:first-child a[href*="/maps/place/"]' + await page.wait_for_selector(result_selector, timeout=ELEMENT_WAIT_TIMEOUT_MS) + first_result = page.locator(result_selector).first + await first_result.click() + logger.info("첫 번째 검색 결과 클릭 완료") + except Exception as error: + logger.debug(f"셀렉터 1 실패: {error}, 대안 셀렉터 시도...") + try: + # 방법 2: 일반적인 장소 링크 + alt_selector = 'a[href*="/maps/place/"]' + await page.wait_for_selector(alt_selector, timeout=5000) + first_result = page.locator(alt_selector).first + await first_result.click() + except Exception as fallback_error: + logger.debug(f"셀렉터 2 실패: {fallback_error}, 직접 장소 페이지로 간주") + else: + logger.info("이미 장소 상세 페이지에 있음") + + # [5/7] 상세 페이지 로드 대기 + logger.info("[5/7] 상세 페이지 로드 대기...") + await asyncio.sleep(PAGE_LOAD_WAIT_SEC) + + # 장소명 요소 대기 (상세 페이지 로드 확인) + try: + await page.wait_for_selector('h1.DUwDvf', timeout=ELEMENT_WAIT_TIMEOUT_MS) + logger.info("상세 페이지 로드 완료") + except Exception: + logger.debug("h1.DUwDvf를 찾을 수 없음, 대안 셀렉터 시도") + + # URL에서 정보 추출 + current_url = page.url + place_id = extract_google_place_id_from_url(current_url) or "unknown" + latitude, longitude = extract_coordinates_from_url(current_url) + logger.info(f"Place ID: {place_id}, 좌표: ({latitude}, {longitude})") + + # [6/7] 장소 정보 추출 + logger.info("[6/7] 장소 정보 추출...") + + # 영업시간 드롭다운 펼치기 시도 + try: + hours_selectors = [ + '[aria-expanded="false"][jsaction*="openhours"]', + '.OMl5r.hH0dDd', + 'div[jsaction*="openhours.wfvdle141.dropdown"]', + '[data-hide-tooltip-on-mouse-move="true"][role="button"]' + ] + for selector in hours_selectors: + hours_button = page.locator(selector) + if await hours_button.count() > 0: + await hours_button.first.click() + try: + await page.wait_for_selector('table.eK4R0e tr', timeout=3000) + logger.debug(f"영업시간 드롭다운 펼침 (셀렉터: {selector})") + except Exception: + pass + break + except Exception as error: + logger.debug(f"영업시간 드롭다운 클릭 실패 (무시): {error}") + + info = await page.evaluate(f'''() => {{ + const result = {{}}; + const MAX_IMAGES = {MAX_IMAGE_COUNT}; + + // 장소명 + const nameElement = document.querySelector('h1.DUwDvf'); + result.name = nameElement ? nameElement.textContent.trim() : null; + + // 별점 + const ratingElement = document.querySelector('.F7nice span[aria-hidden="true"]'); + result.rating = ratingElement ? ratingElement.textContent.trim() : null; + + // 리뷰 수 (aria-label에서 추출) + const reviewElement = document.querySelector('span[role="img"][aria-label*="리뷰"]'); + result.review_aria = reviewElement ? reviewElement.getAttribute('aria-label') : null; + + // 가격대 + const priceElement = document.querySelector('.mgr77e span[role="img"]') || + document.querySelector('span[role="img"][aria-label*="₩"]') || + document.querySelector('span[role="img"][aria-label*="비싸"]') || + document.querySelector('span[role="img"][aria-label*="저렴"]'); + if (priceElement) {{ + const priceText = priceElement.textContent; + if (priceText && priceText.includes('₩')) {{ + result.price_aria = priceText.trim(); + }} else {{ + result.price_aria = priceElement.getAttribute('aria-label'); + }} + }} else {{ + result.price_aria = null; + }} + + // 카테고리 + const categoryElement = document.querySelector('button.DkEaL'); + result.category = categoryElement ? categoryElement.textContent.trim() : null; + + // 주소 (aria-label에서 추출) + const addressButton = document.querySelector('button[data-item-id="address"]'); + result.address_aria = addressButton ? addressButton.getAttribute('aria-label') : null; + + // 전화번호 (aria-label에서 추출) + const phoneButton = document.querySelector('button[data-item-id^="phone"]'); + result.phone_aria = phoneButton ? phoneButton.getAttribute('aria-label') : null; + + // 영업 상태 + const statusElement = document.querySelector('.ZDu9vd span'); + result.business_status = statusElement ? statusElement.textContent.trim() : null; + + // 영업 시간 (테이블에서 추출) + const businessHours = {{}}; + const hoursRows = document.querySelectorAll('tr.y0skZc'); + hoursRows.forEach(row => {{ + const dayElement = row.querySelector('td.ylH6lf div'); + const timeElement = row.querySelector('td.mxowUb'); + if (dayElement && timeElement) {{ + const day = dayElement.textContent.trim(); + let time = timeElement.getAttribute('aria-label'); + if (!time) {{ + const timeList = timeElement.querySelector('li.G8aQO'); + time = timeList ? timeList.textContent.trim() : timeElement.textContent.trim(); + }} + if (day && time) {{ + businessHours[day] = time; + }} + }} + }}); + result.business_hours = Object.keys(businessHours).length > 0 ? businessHours : null; + + // Plus Code (aria-label에서 추출) + const plusCodeButton = document.querySelector('button[data-item-id="oloc"]'); + result.plus_code_aria = plusCodeButton ? plusCodeButton.getAttribute('aria-label') : null; + + // 웹사이트 URL + const websiteLink = document.querySelector('a[data-item-id="authority"]'); + result.website_url = websiteLink ? websiteLink.href : null; + + // 대표 이미지 URL + const imageElement = document.querySelector('button[jsaction*="heroHeaderImage"] img') || + document.querySelector('img.sGi3W') || + document.querySelector('div[role="img"] img'); + result.image_url = imageElement ? imageElement.src : null; + + // 이미지 목록 (최대 MAX_IMAGES개) + const imageElements = document.querySelectorAll('img[src*="googleusercontent.com"]'); + const imageUrls = []; + const seenUrls = new Set(); + for (const img of imageElements) {{ + if (img.src && !seenUrls.has(img.src) && imageUrls.length < MAX_IMAGES) {{ + if (!img.src.includes('=s') || img.src.includes('=w') || img.src.includes('=h')) {{ + seenUrls.add(img.src); + imageUrls.push(img.src); + }} + }} + }} + result.image_urls = imageUrls; + + // 장소 설명/소개 (About 섹션) + const descElement = document.querySelector('[aria-label*="정보"] .PYvSYb') || + document.querySelector('.WeS02d.fontBodyMedium') || + document.querySelector('div[data-attrid="description"]'); + result.description = descElement ? descElement.textContent.trim() : null; + + // 편의시설/서비스 옵션 + const amenities = []; + const serviceButtons = document.querySelectorAll('.E0DTEd'); + serviceButtons.forEach(btn => {{ + const text = btn.textContent.trim(); + if (text && !amenities.includes(text)) {{ + amenities.push(text); + }} + }}); + const amenityElements = document.querySelectorAll('.hpLkke span, .LTs0Rc span'); + amenityElements.forEach(el => {{ + const text = el.textContent.trim(); + if (text && text.length > 1 && text.length < 30 && !amenities.includes(text)) {{ + amenities.push(text); + }} + }}); + result.amenities = amenities; + + // 인기 시간대 정보 + const popularTimesElement = document.querySelector('.g2BVhd'); + result.popular_times = popularTimesElement ? popularTimesElement.textContent.trim() : null; + + return result; + }}''') + + logger.info(f"추출 완료: name={info.get('name')}, category={info.get('category')}") + + # aria-label 값 파싱 + address = parse_aria_label_value(info.get('address_aria'), '주소: ') + phone_number = parse_aria_label_value(info.get('phone_aria'), '전화: ') + plus_code = parse_aria_label_value(info.get('plus_code_aria'), 'Plus Code: ') + price_level = parse_price_level(info.get('price_aria')) + + # [7/7] GooglePlaceInfo 생성 + logger.info("[7/7] 스크래핑 완료") + + return GooglePlaceInfo( + place_id=place_id, + name=info.get('name') or query, + category=info.get('category'), + google_map_url=current_url, + latitude=latitude, + longitude=longitude, + address=address, + plus_code=plus_code, + rating=parse_rating(info.get('rating')), + review_count=parse_review_count(info.get('review_aria')), + price_level=price_level, + business_status=info.get('business_status'), + business_hours=info.get('business_hours'), + phone_number=phone_number, + website_url=info.get('website_url'), + description=info.get('description'), + amenities=info.get('amenities', []), + popular_times=info.get('popular_times'), + image_url=info.get('image_url'), + image_urls=info.get('image_urls', []) + ) + + except HTTPException: + raise + except Exception as error: + logger.error(f"구글 지도 스크래핑 오류: {error}", exc_info=True) + raise HTTPException(status_code=500, detail="구글 지도 스크래핑에 실패했습니다") + finally: + await self.browser_controller.close_browser() diff --git a/src/services/scraper/platforms/naver_map_scraper.py b/src/services/scraper/platforms/naver_map_scraper.py new file mode 100644 index 0000000..8c0dae9 --- /dev/null +++ b/src/services/scraper/platforms/naver_map_scraper.py @@ -0,0 +1,392 @@ +"""src.services.scraper.platforms.naver_map_scraper +네이버 지도 장소 스크래핑 로직 +""" +import logging +import asyncio +from urllib.parse import quote + +from playwright.async_api import async_playwright +from fastapi import HTTPException + +from src.services.scraper.playwright_browser import PlaywrightBrowser +from src.services.scraper.common_util import ( + parse_review_count, + parse_rating, + extract_naver_place_id_from_url, + SCRAPE_TIMEOUT_MS, + PAGE_LOAD_WAIT_SEC, + ELEMENT_WAIT_TIMEOUT_MS, + MAX_IMAGE_COUNT, +) +from src.models.naver_place_info import NaverPlaceInfo + +logger = logging.getLogger(__name__) + + +class NaverMapScraper: + """네이버 지도 장소 스크래퍼""" + + def __init__(self): + self.browser_controller = PlaywrightBrowser() + + async def search_and_scrape(self, query: str) -> NaverPlaceInfo: + """ + 네이버 지도에서 장소 검색 후 첫 번째 결과의 상세 정보 스크래핑 + + Args: + query: 검색어 (예: "늘푸른목장") + + Returns: + NaverPlaceInfo: 추출된 장소 정보 + + Raises: + HTTPException: 스크래핑 실패 시 + """ + logger.info(f"[1/7] 네이버 지도 검색 시작: query='{query}'") + + # 검색 URL 생성 + encoded_query = quote(query) + search_url = f"https://map.naver.com/p/search/{encoded_query}" + logger.info(f"검색 URL: {search_url}") + + async with async_playwright() as playwright: + try: + # [2/7] 브라우저 생성 + logger.info("[2/7] 브라우저 초기화...") + await self.browser_controller.create_browser_and_context(playwright) + + # [3/7] 검색 페이지 로드 + logger.info("[3/7] 검색 페이지 로드...") + await self.browser_controller.load_page( + search_url, + wait_until="networkidle", + timeout=SCRAPE_TIMEOUT_MS + ) + + # searchIframe 대기 (네이버 지도는 iframe 구조) + logger.info("searchIframe 대기...") + await self.browser_controller.page.wait_for_selector( + '#searchIframe', + timeout=ELEMENT_WAIT_TIMEOUT_MS + ) + + # searchIframe으로 전환 + search_iframe = self.browser_controller.page.frame_locator('#searchIframe') + + # 검색 결과 로드 대기 + logger.info("검색 결과 목록 대기...") + + # [4/7] 첫 번째 검색 결과 클릭 + logger.info("[4/7] 첫 번째 검색 결과 클릭...") + + try: + # 방법 1: li.VLTHu 내부의 place_bluelink + await search_iframe.locator('li.VLTHu a.place_bluelink').first.wait_for( + timeout=ELEMENT_WAIT_TIMEOUT_MS + ) + place_link = search_iframe.locator('li.VLTHu a.place_bluelink').first + await place_link.click() + except Exception as error: + logger.debug(f"셀렉터 1 실패: {error}, 대안 셀렉터 시도...") + try: + # 방법 2: 검색 결과 리스트의 첫 번째 링크 + await search_iframe.locator('ul > li a[href="#"]').first.wait_for( + timeout=ELEMENT_WAIT_TIMEOUT_MS + ) + place_link = search_iframe.locator('ul > li a[href="#"]').first + await place_link.click() + except Exception as fallback_error: + logger.debug(f"셀렉터 2 실패: {fallback_error}, 마지막 대안 시도...") + # 방법 3: 장소명 텍스트가 있는 span 클릭 + await search_iframe.locator('span.YwYLL, span[class*="name"]').first.wait_for( + timeout=ELEMENT_WAIT_TIMEOUT_MS + ) + place_span = search_iframe.locator('span.YwYLL, span[class*="name"]').first + await place_span.click() + + logger.info("첫 번째 결과 클릭 완료, 상세 페이지 로드 대기...") + + # [5/7] 상세 페이지 로드 대기 + logger.info("[5/7] 상세 페이지 로드 대기...") + await asyncio.sleep(PAGE_LOAD_WAIT_SEC) + + # entryIframe 대기 + logger.info("entryIframe 대기...") + await self.browser_controller.page.wait_for_selector( + '#entryIframe', + timeout=ELEMENT_WAIT_TIMEOUT_MS + ) + + # entryIframe으로 전환 + entry_iframe = self.browser_controller.page.frame_locator('#entryIframe') + + # 상세 정보 컨테이너 대기 (장소명이 나타날 때까지) + await entry_iframe.locator('span.GHAhO').wait_for(timeout=ELEMENT_WAIT_TIMEOUT_MS) + logger.info("상세 페이지 로드 완료") + + # [6/7] 장소 정보 추출 + logger.info("[6/7] 장소 정보 추출...") + + # 현재 URL에서 Place ID 추출 + current_url = self.browser_controller.page.url + place_id = extract_naver_place_id_from_url(current_url) or "unknown" + logger.info(f"Place ID: {place_id}") + + # entryIframe을 찾아야 함 (name으로 찾기) + frames = self.browser_controller.page.frames + entry_frame = self.browser_controller.page.frame(name='entryIframe') + + if not entry_frame: + # URL에 'entry' 포함된 frame 찾기 + for frame in frames: + if frame.name == 'entryIframe' or (frame.url and '/entry/' in frame.url): + entry_frame = frame + break + + if not entry_frame: + raise HTTPException(status_code=500, detail="상세 페이지를 찾을 수 없습니다") + + logger.debug(f"entry_frame URL: {entry_frame.url}") + + # 추가 대기: DOM이 완전히 로드될 때까지 + await asyncio.sleep(2) + + # iframe 내에서 JavaScript 실행 + info = await entry_frame.evaluate(f'''() => {{ + const result = {{}}; + const MAX_IMAGES = {MAX_IMAGE_COUNT}; + + // 장소명 (여러 셀렉터 시도) + const nameElement = document.querySelector('span.GHAhO') || + document.querySelector('#_title span:first-child') || + document.querySelector('.place_section_header span'); + result.name = nameElement ? nameElement.textContent.trim() : null; + + // 카테고리 + const categoryElement = document.querySelector('span.lnJFt') || + document.querySelector('#_title span:nth-child(2)'); + result.category = categoryElement ? categoryElement.textContent.trim() : null; + + // 별점 (PXMot 클래스 내부 텍스트에서 숫자 추출) + const ratingContainer = document.querySelector('.PXMot.LXIwF') || + document.querySelector('.dAsGb .PXMot:first-child'); + if (ratingContainer) {{ + const ratingText = ratingContainer.textContent; + const ratingMatch = ratingText.match(/([\\d.]+)/); + result.rating = ratingMatch ? ratingMatch[1] : null; + }} + + // 방문자 리뷰 수 + const visitorReviewLink = document.querySelector('a[href*="/review/visitor"]'); + result.visitor_review_text = visitorReviewLink ? visitorReviewLink.textContent : null; + + // 블로그 리뷰 수 + const blogReviewLink = document.querySelector('a[href*="/review/ugc"]'); + result.blog_review_text = blogReviewLink ? blogReviewLink.textContent : null; + + // 한줄 설명 + const descElement = document.querySelector('div.XtBbS') || + document.querySelector('.dAsGb > div:last-child'); + result.description = descElement ? descElement.textContent.trim() : null; + + // 주소 (여러 셀렉터 시도) + const addressElement = document.querySelector('span.LDgIH') || + document.querySelector('.O8qbU.tQY7D span') || + document.querySelector('[class*="address"]'); + result.address = addressElement ? addressElement.textContent.trim() : null; + + // 도로명 주소 (주소 복사 버튼 근처) + const roadAddressElement = document.querySelector('.LDgIH'); + result.road_address = roadAddressElement ? roadAddressElement.textContent.trim() : null; + + // 지하철 정보 + const subwayElement = document.querySelector('div.nZapA'); + result.subway_info = subwayElement ? subwayElement.textContent.trim() : null; + + // 찾아가는 길 + const directionsElement = document.querySelector('span.zPfVt') || + document.querySelector('.place_section_content .zPfVt'); + result.directions_text = directionsElement ? directionsElement.textContent.trim() : null; + + // 영업 상태 + const businessStatusElement = document.querySelector('div.A_cdD em') || + document.querySelector('.pSavy em'); + result.business_status = businessStatusElement ? businessStatusElement.textContent.trim() : null; + + // 영업 시간 + const businessHoursElement = document.querySelector('span.U7pYf time') || + document.querySelector('.pSavy time'); + result.business_hours = businessHoursElement ? businessHoursElement.textContent.trim() : null; + + // 요일별 상세 영업시간 + const hoursDetailElements = document.querySelectorAll('.A_cdD .y6tNq'); + result.open_hours_detail = Array.from(hoursDetailElements).map(el => el.textContent.trim()); + + // 휴무일 정보 + const holidayElement = document.querySelector('.A_cdD .vV_z_') || + document.querySelector('[class*="holiday"]'); + result.holiday_info = holidayElement ? holidayElement.textContent.trim() : null; + + // 전화번호 + const phoneElement = document.querySelector('span.xlx7Q') || + document.querySelector('.nbXkr span'); + result.phone_number = phoneElement ? phoneElement.textContent.trim() : null; + + // 홈페이지 URL + const homepageLink = document.querySelector('a.place_bluelink[href*="http"]') || + document.querySelector('a[class*="homepage"]') || + document.querySelector('.place_section_content a[href^="http"]:not([href*="naver"])'); + result.homepage_url = homepageLink ? homepageLink.href : null; + + // 예약 가능 여부 (네이버 예약 버튼 존재 확인) + const reservationButton = document.querySelector('a[href*="booking.naver"]') || + document.querySelector('a[href*="reserve.naver"]') || + document.querySelector('button[aria-label*="예약"]'); + result.reservation_available = !!reservationButton; + + // 편의시설 + const amenitiesElement = document.querySelector('div.xPvPE') || + document.querySelector('.Uv6Eo div'); + result.amenities_text = amenitiesElement ? amenitiesElement.textContent.trim() : null; + + // 키워드/태그 (리뷰 링크 제외) + let keywordEls = document.querySelectorAll('.chip_group a, .place_section_content .chip a'); + if (keywordEls.length === 0) {{ + keywordEls = document.querySelectorAll('.place_section_content a[class*="tag"]'); + }} + result.keywords = Array.from(keywordEls) + .map(el => el.textContent.trim()) + .filter(text => text && !text.includes('리뷰')); + + // TV 방송 출연 정보 + let tvEls = document.querySelectorAll('div.TMK4W .A_cdD'); + if (tvEls.length === 0) {{ + tvEls = document.querySelectorAll('.place_section_content [class*="broadcast"]'); + }} + result.tv_appearances = Array.from(tvEls).map(el => el.textContent.trim()).filter(Boolean); + + // 대표 메뉴 + let menuEls = document.querySelectorAll('.place_section_content .LNvHf'); + if (menuEls.length === 0) {{ + menuEls = document.querySelectorAll('.place_section_content [class*="menu"] .name'); + }} + result.menu_info = Array.from(menuEls).map(el => el.textContent.trim()).filter(Boolean); + + // 좌표 추출 (window state 또는 meta 태그에서) + try {{ + // 방법 1: __APOLLO_STATE__ 에서 추출 + const apolloState = window.__APOLLO_STATE__; + if (apolloState) {{ + const placeKeys = Object.keys(apolloState).filter(k => k.startsWith('Place:')); + if (placeKeys.length > 0) {{ + const placeData = apolloState[placeKeys[0]]; + result.latitude = placeData?.y || placeData?.latitude || null; + result.longitude = placeData?.x || placeData?.longitude || null; + }} + }} + // 방법 2: meta 태그에서 추출 + if (!result.latitude) {{ + const geoMeta = document.querySelector('meta[name="geo.position"]'); + if (geoMeta) {{ + const [lat, lng] = geoMeta.content.split(';'); + result.latitude = parseFloat(lat); + result.longitude = parseFloat(lng); + }} + }} + }} catch (e) {{ + result.latitude = null; + result.longitude = null; + }} + + // 대표 이미지 + const imageElement = document.querySelector('div.fNygA img') || + document.querySelector('img.K0PDV') || + document.querySelector('.place_thumb img'); + result.image_url = imageElement ? imageElement.src : null; + + // 이미지 목록 (최대 MAX_IMAGES개, 필터링 개선) + const imageElements = document.querySelectorAll('img[src*="pstatic.net"]'); + const imageUrls = []; + const seenUrls = new Set(); + for (const img of imageElements) {{ + if (img.src && !seenUrls.has(img.src) && imageUrls.length < MAX_IMAGES) {{ + // 아이콘/로고/접근성 이미지 제외 + const isValidImage = !img.src.includes('icon') && + !img.src.includes('logo') && + !img.src.includes('accessor') && + !img.src.includes('sprite'); + if (isValidImage) {{ + seenUrls.add(img.src); + imageUrls.push(img.src); + }} + }} + }} + result.image_urls = imageUrls; + + return result; + }}''') + + logger.info(f"추출 완료: name={info.get('name')}, category={info.get('category')}, address={info.get('address')}") + + # 편의시설 파싱 + amenities = [] + if info.get('amenities_text'): + amenities = [a.strip() for a in info['amenities_text'].split(',')] + + # 네이버 지도 URL 생성 + naver_map_url = f"https://map.naver.com/p/search/{encoded_query}/place/{place_id}" if place_id != "unknown" else None + + # 좌표 추출 (JavaScript에서 추출한 값 사용) + latitude = info.get('latitude') + longitude = info.get('longitude') + if latitude is not None: + try: + latitude = float(latitude) + except (TypeError, ValueError): + latitude = None + if longitude is not None: + try: + longitude = float(longitude) + except (TypeError, ValueError): + longitude = None + + # [7/7] NaverPlaceInfo 생성 + logger.info(f"[7/7] 스크래핑 완료 (좌표: {latitude}, {longitude})") + + return NaverPlaceInfo( + place_id=place_id, + name=info.get('name') or query, + category=info.get('category'), + naver_map_url=naver_map_url, + latitude=latitude, + longitude=longitude, + rating=parse_rating(info.get('rating')), + visitor_review_count=parse_review_count(info.get('visitor_review_text')), + blog_review_count=parse_review_count(info.get('blog_review_text')), + description=info.get('description'), + address=info.get('address'), + road_address=info.get('road_address') or info.get('address'), + subway_info=info.get('subway_info'), + directions_text=info.get('directions_text'), + business_status=info.get('business_status'), + business_hours=info.get('business_hours'), + open_hours_detail=info.get('open_hours_detail', []), + holiday_info=info.get('holiday_info'), + phone_number=info.get('phone_number'), + homepage_url=info.get('homepage_url'), + reservation_available=info.get('reservation_available', False), + amenities=amenities, + keywords=info.get('keywords', []), + tv_appearances=info.get('tv_appearances', []), + menu_info=info.get('menu_info', []), + image_url=info.get('image_url'), + image_urls=info.get('image_urls', []) + ) + + except HTTPException: + raise + except Exception as error: + logger.error(f"네이버 지도 스크래핑 오류: {error}", exc_info=True) + raise HTTPException(status_code=500, detail="네이버 지도 스크래핑에 실패했습니다") + finally: + await self.browser_controller.close_browser() diff --git a/version.yml b/version.yml index 2b7a5ed..76b747c 100644 --- a/version.yml +++ b/version.yml @@ -34,11 +34,11 @@ # - 버전은 항상 높은 버전으로 자동 동기화됩니다 # =================================================================== -version: "0.1.2" -version_code: 15 # app build number +version: "0.1.3" +version_code: 16 # app build number project_type: "python" # spring, flutter, react, react-native, react-native-expo, node, python, basic metadata: - last_updated: "2026-01-13 15:05:48" + last_updated: "2026-01-18 04:56:18" last_updated_by: "Cassiiopeia" default_branch: "main" integrated_from: "SUH-DEVOPS-TEMPLATE"