From 95928c0b6668eca0421aade5ef2e02f6a8bbac5f Mon Sep 17 00:00:00 2001 From: hbyang Date: Thu, 8 Jan 2026 14:11:47 +0900 Subject: [PATCH] =?UTF-8?q?test=20=EC=9E=90=EB=8F=99=EC=99=84=EC=84=B1=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 3 +- test/__pycache__/main.cpython-314.pyc | Bin 0 -> 66472 bytes test/main.py | 482 +++++++++++++++++ test/server.py | 178 ++++++ test/templates/result.html | 717 ++++++++++++++++++++++++ test/templates/search.html | 749 ++++++++++++++++++++++++++ 6 files changed, 2128 insertions(+), 1 deletion(-) create mode 100644 test/__pycache__/main.cpython-314.pyc create mode 100644 test/main.py create mode 100644 test/server.py create mode 100644 test/templates/result.html create mode 100644 test/templates/search.html diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1bd5633..6500bf2 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,8 @@ "Bash(mv:*)", "Bash(rmdir:*)", "Bash(rm:*)", - "Bash(npm run build:*)" + "Bash(npm run build:*)", + "Bash(python3:*)" ] } } diff --git a/test/__pycache__/main.cpython-314.pyc b/test/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fe799011a6683496067aef47d970bb42703cad19 GIT binary patch literal 66472 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 WM^k5vWuP)Tr*nQ;uhN?7oc#~LcheLA literal 0 HcmV?d00001 diff --git a/test/main.py b/test/main.py new file mode 100644 index 0000000..59b97c4 --- /dev/null +++ b/test/main.py @@ -0,0 +1,482 @@ +""" +네이버 플레이스 검색 API 모듈 +업체명으로 검색하여 place_id를 찾고, 상세정보(사진 포함)를 조회 +""" + +import asyncio +import re +import requests +from dataclasses import dataclass +from typing import Optional, List, Dict, Any + + +# ============================================================ +# Data Classes +# ============================================================ + +@dataclass +class NaverConfig: + """네이버 API 설정""" + naver_client_id: str = "cp5MzIsZ8PSQPeQQkVKR" + naver_client_secret: str = "lhdrHgx31G" + naver_local_api_url: str = "https://openapi.naver.com/v1/search/local.json" + + +@dataclass +class PlaceDetailInfo: + """네이버 플레이스 상세 정보""" + place_id: str + name: str + category: str + address: str + road_address: str + phone: str + description: str + images: List[str] + business_hours: str + homepage: str + keywords: List[str] + facilities: List[str] + + +# ============================================================ +# Main API Class +# ============================================================ + +class NaverPlaceAPI: + """ + 네이버 플레이스 API 클래스 + + 주요 기능: + - quick_search(): 빠른 자동완성 검색 (place_id 없음) + - autocomplete_search(): place_id 포함 검색 (브라우저 폴백) + - get_place_detail(): place_id로 상세정보 조회 + - convert_to_crawling_response(): CrawlingResponse 형식 변환 + """ + + ACCOMMODATION_CATEGORIES = [ + "펜션", "숙박", "호텔", "모텔", "리조트", "게스트하우스", + "민박", "글램핑", "캠핑", "풀빌라", "스테이", "독채" + ] + + def __init__(self, config: NaverConfig = None): + self.config = config or NaverConfig() + self.search_url = self.config.naver_local_api_url + self.headers = { + "X-Naver-Client-Id": self.config.naver_client_id, + "X-Naver-Client-Secret": self.config.naver_client_secret, + } + self.browser_headers = { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", + "Accept": "application/json, text/plain, */*", + "Accept-Language": "ko-KR,ko;q=0.9", + "Referer": "https://map.naver.com/", + } + + # ============================================================ + # Public Methods + # ============================================================ + + async def quick_search(self, query: str) -> List[Dict[str, Any]]: + """ + 빠른 자동완성 검색 (place_id 조회 없음) + + Args: + query: 검색어 + + Returns: + [{"title": "업체명", "category": "카테고리", "address": "주소"}, ...] + """ + try: + response = await asyncio.to_thread( + requests.get, + self.search_url, + headers=self.headers, + params={"query": query, "display": 10}, + timeout=5 + ) + + if response.status_code != 200: + return [] + + items = response.json().get("items", []) + return [ + { + "title": self._clean_html(item.get("title", "")), + "category": item.get("category", ""), + "address": item.get("roadAddress") or item.get("address", ""), + } + for item in items + ] + + except Exception: + return [] + + async def autocomplete_search(self, query: str) -> List[Dict[str, Any]]: + """ + place_id 포함 검색 (API 실패 시 브라우저 폴백) + + Args: + query: 검색어 또는 네이버 지도 URL + + Returns: + [{"place_id": "123", "title": "업체명", "category": "카테고리", + "address": "주소", "is_accommodation": True}, ...] + """ + # URL인 경우 place_id 추출 + if query.startswith("http"): + place_id = self._extract_place_id_from_url(query) + if place_id: + detail = await self.get_place_detail(place_id) + if detail: + return [{ + "place_id": place_id, + "title": detail.name, + "category": detail.category, + "address": detail.road_address or detail.address, + "is_accommodation": self._is_accommodation(detail.category), + }] + return [] + + # API로 검색 + api_results = await self._search_with_api(query) + if api_results and any(r.get("place_id") for r in api_results): + return api_results + + # API 실패 시 브라우저로 검색 + print("API에서 place_id를 찾지 못함. 브라우저 검색 시도...") + browser_results = await self._search_with_browser(query) + + if browser_results and any(r.get("place_id") for r in browser_results): + # API 결과에 브라우저에서 찾은 place_id 매칭 + if api_results: + self._merge_place_ids(api_results, browser_results) + return api_results + return browser_results + + return api_results or [] + + async def get_place_detail(self, place_id: str) -> Optional[PlaceDetailInfo]: + """ + place_id로 상세정보 조회 + + Args: + place_id: 네이버 플레이스 ID + + Returns: + PlaceDetailInfo 또는 None + """ + if not place_id: + return None + + try: + response = await asyncio.to_thread( + requests.get, + f"https://map.naver.com/p/api/place/summary/{place_id}", + headers={**self.browser_headers, "Referer": f"https://map.naver.com/p/entry/place/{place_id}"} + ) + + if response.status_code != 200: + print(f"Detail API Error: {response.status_code}") + return None + + pd = response.json().get("data", {}).get("placeDetail", {}) + if not pd: + print("No placeDetail in response") + return None + + return PlaceDetailInfo( + place_id=place_id, + name=pd.get("name", ""), + category=self._parse_category(pd.get("category")), + address=self._parse_address(pd.get("address"), "address"), + road_address=self._parse_address(pd.get("address"), "roadAddress"), + phone="", + description="", + images=self._parse_images(pd.get("images")), + business_hours=self._parse_business_hours(pd.get("businessHours")), + homepage="", + keywords=self._parse_keywords(pd.get("visitorReviews")), + facilities=self._parse_facilities(pd.get("labels")) + ) + + except Exception as e: + print(f"Detail fetch error: {e}") + return None + + def convert_to_crawling_response(self, detail: PlaceDetailInfo) -> Dict[str, Any]: + """PlaceDetailInfo를 CrawlingResponse 형식으로 변환""" + address = detail.road_address or detail.address + address_parts = address.split() if address else [] + region = address_parts[0] if address_parts else "" + + # 태그 생성 + tags = [] + if region: + tags.append(f"#{region}") + for keyword in detail.keywords[:5]: + tags.append(f"#{keyword}" if not keyword.startswith("#") else keyword) + + # 시설 정보 + facilities = detail.facilities[:] + if detail.category: + for cat in detail.category.split(">"): + cat = cat.strip() + if cat and cat not in facilities: + facilities.append(cat) + + return { + "image_list": detail.images, + "image_count": len(detail.images), + "processed_info": { + "customer_name": detail.name, + "region": region, + "detail_region_info": address + }, + "marketing_analysis": { + "report": self._generate_report(detail, address), + "tags": tags, + "facilities": facilities + } + } + + # ============================================================ + # Private Methods - Search + # ============================================================ + + async def _search_with_api(self, query: str) -> List[Dict[str, Any]]: + """Local Search API로 검색 후 좌표로 place_id 조회""" + try: + response = await asyncio.to_thread( + requests.get, + self.search_url, + headers=self.headers, + params={"query": query, "display": 5}, + timeout=10 + ) + + if response.status_code != 200: + print(f"Local Search API Error: {response.status_code}") + return [] + + items = response.json().get("items", []) + results = [] + + for item in items: + title = self._clean_html(item.get("title", "")) + category = item.get("category", "") + mapx, mapy = item.get("mapx", ""), item.get("mapy", "") + lng = float(mapx) / 10000000 if mapx else 0 + lat = float(mapy) / 10000000 if mapy else 0 + + results.append({ + "place_id": "", + "title": title, + "category": category, + "address": item.get("roadAddress") or item.get("address", ""), + "lng": lng, + "lat": lat, + "is_accommodation": self._is_accommodation(category), + }) + + # 좌표로 place_id 찾기 + for result in results: + result["place_id"] = await self._find_place_id_by_coord( + result["title"], result["lng"], result["lat"] + ) + + return results + + except Exception as e: + print(f"Search error: {e}") + return [] + + async def _find_place_id_by_coord(self, name: str, lng: float, lat: float) -> str: + """좌표와 업체명으로 place_id 찾기""" + try: + response = await asyncio.to_thread( + requests.get, + "https://map.naver.com/p/api/search/allSearch", + headers=self.browser_headers, + params={"query": name, "type": "place", "searchCoord": f"{lng};{lat}", "displayCount": 1}, + timeout=5 + ) + + if response.status_code == 200: + result = response.json().get("result", {}) + if "ncaptcha" not in result: + place_list = result.get("place", {}).get("list", []) + if place_list: + return str(place_list[0].get("id", "")) + return "" + + except Exception: + return "" + + async def _search_with_browser(self, query: str) -> List[Dict[str, Any]]: + """Playwright 브라우저로 place_id 검색""" + try: + from playwright.async_api import async_playwright + except ImportError: + print("playwright가 설치되지 않았습니다. pip install playwright") + return [] + + results = [] + + try: + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + context = await browser.new_context(user_agent=self.browser_headers["User-Agent"]) + page = await context.new_page() + + await page.goto(f"https://map.naver.com/p/search/{query}", wait_until="domcontentloaded", timeout=20000) + await page.wait_for_timeout(5000) + + search_frame = page.frame(name="searchIframe") + if search_frame: + html = await search_frame.content() + text = await search_frame.inner_text('body') + results = self._parse_browser_results(html, text) + + await browser.close() + + except Exception as e: + print(f"Browser search error: {e}") + + return results[:10] + + def _parse_browser_results(self, html: str, text: str) -> List[Dict[str, Any]]: + """브라우저 HTML에서 검색 결과 파싱""" + # place_id 추출 + place_ids = [] + for pattern in [r'"id":"(\d+)"', r'/place/(\d+)', r'data-id="(\d+)"']: + place_ids.extend(re.findall(pattern, html)) + place_ids = list(dict.fromkeys(place_ids)) # 중복 제거 + + # 텍스트에서 업체 정보 파싱 + results = [] + lines = text.split('\n') + current_place = {} + place_index = 0 + + for line in lines: + line = line.strip() + if not line: + continue + + if line.startswith('이미지수'): + if current_place.get('title') and place_index < len(place_ids): + current_place['place_id'] = place_ids[place_index] + results.append(current_place) + place_index += 1 + current_place = {} + continue + + if not current_place.get('title') and len(line) > 1 and not line.isdigit(): + if line not in ['네이버페이', '톡톡', '쿠폰', '알림받기']: + current_place['title'] = line + continue + + if not current_place.get('category'): + for keyword in self.ACCOMMODATION_CATEGORIES + ['장소대여', '전통숙소']: + if keyword in line: + current_place['category'] = line + current_place['is_accommodation'] = self._is_accommodation(line) + break + + # 에라 모르겄다 그냥 전국 다 쳐넣어 + if not current_place.get('address'): + regions = ['서울', '부산', '대구', '인천', '광주', '대전', '울산', '세종', + '경기', '강원', '충북', '충남', '전북', '전남', '경북', '경남', '제주'] + for region in regions: + if line.startswith(region): + current_place['address'] = line + break + + if current_place.get('title') and place_index < len(place_ids): + current_place['place_id'] = place_ids[place_index] + results.append(current_place) + + return results + + def _merge_place_ids(self, api_results: List[Dict], browser_results: List[Dict]): + """브라우저 결과의 place_id를 API 결과에 매칭""" + for api_r in api_results: + for br_r in browser_results: + if br_r.get("place_id") and api_r.get("title"): + if api_r["title"] in br_r.get("title", "") or br_r.get("title", "") in api_r["title"]: + api_r["place_id"] = br_r["place_id"] + break + + # ============================================================ + # Private Methods - Parsing + # ============================================================ + + def _clean_html(self, text: str) -> str: + """HTML 태그 제거""" + return text.replace("", "").replace("", "") + + def _is_accommodation(self, category: str) -> bool: + """숙박 카테고리 여부""" + return bool(category and any(k in category for k in self.ACCOMMODATION_CATEGORIES)) + + def _extract_place_id_from_url(self, url: str) -> str: + """URL에서 place_id 추출""" + for pattern in [r'/place/(\d+)', r'/entry/place/(\d+)', r'place_id=(\d+)']: + match = re.search(pattern, url) + if match: + return match.group(1) + return "" + + def _parse_category(self, category_data) -> str: + if isinstance(category_data, dict): + return category_data.get("category", "") + return category_data if isinstance(category_data, str) else "" + + def _parse_address(self, address_data, key: str) -> str: + if isinstance(address_data, dict): + return address_data.get(key, "") + return address_data if isinstance(address_data, str) and key == "address" else "" + + def _parse_images(self, images_data, limit: int = 20) -> List[str]: + images = [] + if isinstance(images_data, dict): + for img in images_data.get("images", [])[:limit]: + if isinstance(img, dict): + url = img.get("origin") or img.get("url") or img.get("thumbnail") + if url: + images.append(url) + elif isinstance(img, str): + images.append(img) + return images + + def _parse_business_hours(self, hours_data) -> str: + if isinstance(hours_data, dict): + return hours_data.get("status", "") + return hours_data if isinstance(hours_data, str) else "" + + def _parse_keywords(self, reviews_data) -> List[str]: + if isinstance(reviews_data, dict): + display_text = reviews_data.get("displayText", "") + return [display_text] if display_text else [] + return [] + + def _parse_facilities(self, labels_data) -> List[str]: + facilities = [] + if isinstance(labels_data, dict): + if labels_data.get("booking"): + facilities.append("예약가능") + if labels_data.get("nPay"): + facilities.append("네이버페이") + if labels_data.get("talktalk"): + facilities.append("톡톡") + return facilities + + def _generate_report(self, detail: PlaceDetailInfo, address: str) -> str: + return ( + f"## 업체 정보\n{detail.name}은(는) {detail.category} 카테고리에 속한 업체입니다.\n\n" + f"## 위치\n{address}\n\n" + f"## 연락처\n{detail.phone or '정보 없음'}\n\n" + f"## 영업시간\n{detail.business_hours or '정보 없음'}\n\n" + f"## 설명\n{detail.description or '정보 없음'}" + ) diff --git a/test/server.py b/test/server.py new file mode 100644 index 0000000..f7496df --- /dev/null +++ b/test/server.py @@ -0,0 +1,178 @@ +""" +네이버 플레이스 검색 테스트 웹 서버 +Flask를 사용하여 검색 및 상세정보 조회 API 제공 +""" + +import asyncio +import sys +from io import StringIO +from flask import Flask, render_template, request, jsonify +from main import NaverPlaceAPI + +app = Flask(__name__) +place_api = NaverPlaceAPI() + + +# ============================================================ +# Utilities +# ============================================================ + +class LogCapture: + """콘솔 출력을 캡처하는 컨텍스트 매니저""" + + def __init__(self): + self.logs = [] + self._stdout = None + self._capture = None + + def __enter__(self): + self._stdout = sys.stdout + sys.stdout = self._capture = StringIO() + return self + + def __exit__(self, *args): + self.logs = [log for log in self._capture.getvalue().split('\n') if log.strip()] + sys.stdout = self._stdout + + def get_logs(self): + return self.logs + + +def run_async(coro): + """비동기 함수를 동기적으로 실행""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +# ============================================================ +# Routes - Pages +# ============================================================ + +@app.route('/') +def index(): + """검색 페이지""" + return render_template('search.html') + + +@app.route('/result') +def result(): + """결과 페이지""" + return render_template('result.html') + + +# ============================================================ +# Routes - API +# ============================================================ + +@app.route('/api/autocomplete', methods=['POST']) +def api_autocomplete(): + """ + 빠른 자동완성 API (place_id 없음) + + Request: {"query": "스테이"} + Response: {"results": [{"title": "...", "category": "...", "address": "..."}], "count": 10} + """ + try: + data = request.get_json() + query = data.get('query', '').strip() + + if not query or len(query) < 2: + return jsonify({'results': []}) + + results = run_async(place_api.quick_search(query)) + return jsonify({'results': results, 'count': len(results)}) + + except Exception as e: + return jsonify({'results': [], 'error': str(e)}) + + +@app.route('/api/search', methods=['POST']) +def api_search(): + """ + 검색 API (place_id 포함) + + Request: {"query": "스테이 머뭄"} + Response: {"results": [{"place_id": "123", "title": "...", ...}], "count": 5, "logs": [...]} + """ + try: + data = request.get_json() + query = data.get('query', '').strip() + + if not query: + return jsonify({'error': '검색어를 입력해주세요.'}), 400 + + with LogCapture() as log_capture: + results = run_async(place_api.autocomplete_search(query)) + + return jsonify({ + 'results': results, + 'count': len(results), + 'logs': log_capture.get_logs(), + 'query': query + }) + + except Exception as e: + import traceback + return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500 + + +@app.route('/api/detail', methods=['POST']) +def api_detail(): + """ + 상세정보 API + + Request: {"place_id": "1133638931"} + Response: {"detail": {...}, "crawling_response": {...}, "logs": [...]} + """ + try: + data = request.get_json() + place_id = data.get('place_id', '').strip() + + if not place_id: + return jsonify({'error': 'place_id를 입력해주세요.'}), 400 + + with LogCapture() as log_capture: + detail = run_async(place_api.get_place_detail(place_id)) + + if not detail: + return jsonify({'error': '상세 정보를 찾을 수 없습니다.', 'logs': log_capture.get_logs()}), 404 + + return jsonify({ + 'detail': { + 'place_id': detail.place_id, + 'name': detail.name, + 'category': detail.category, + 'address': detail.address, + 'road_address': detail.road_address, + 'phone': detail.phone, + 'description': detail.description, + 'images': detail.images, + 'business_hours': detail.business_hours, + 'homepage': detail.homepage, + 'keywords': detail.keywords, + 'facilities': detail.facilities, + }, + 'crawling_response': place_api.convert_to_crawling_response(detail), + 'logs': log_capture.get_logs() + }) + + except Exception as e: + import traceback + return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500 + + +# ============================================================ +# Entry Point +# ============================================================ + +if __name__ == '__main__': + print("=" * 60) + print("네이버 플레이스 검색 테스트 서버") + print("=" * 60) + print("브라우저에서 http://localhost:5001 으로 접속하세요") + print("=" * 60) + app.run(debug=True, port=5001, use_reloader=False) diff --git a/test/templates/result.html b/test/templates/result.html new file mode 100644 index 0000000..e662e10 --- /dev/null +++ b/test/templates/result.html @@ -0,0 +1,717 @@ + + + + + + 크롤링 결과 + + + +
+ + +
+
+

상세 정보를 불러오는 중...

+
+ +
+
⚠️
+

+ +
+ +
+ + + + +
+

+

+

+ + + + + +

+
+ + +
+

+ + + + + + 원본 상세 정보 +

+
+
+ + +
+
+

+ + + + + 추천 타겟 키워드 +

+
+
+ +
+

+ + + + + + 시설 및 서비스 +

+
+
+
+ + +
+

+ + + + + + 수집된 이미지 (0장) +

+
+
+ + +
+

+ + + + + + + 마케팅 분석 리포트 +

+
+
+ + +
+

+ CrawlingResponse JSON + +

+

+            
+
+
+ + + + diff --git a/test/templates/search.html b/test/templates/search.html new file mode 100644 index 0000000..148287d --- /dev/null +++ b/test/templates/search.html @@ -0,0 +1,749 @@ + + + + + + 네이버 플레이스 검색 + + + +
+
+

네이버 플레이스 검색

+

숙박/펜션 업체를 검색하고 상세 정보를 확인하세요

+
+ + + +
+
+

검색 중입니다... (브라우저 검색은 5-7초 소요)

+
+ +
+ + +
+
+ + + + + + 검색 로그 + + +
+
+
+ +

+ 검색 결과에서 업체를 클릭하면 상세 정보 페이지로 이동합니다. +

+
+ + + +