최상단을 프로젝트 단위로 평평하게 둔다 — o2o-negosium 과 같은 규약이고, 이 레포만
다르게 갈 이유가 없다. negodata/{backend,front} 가 프로젝트 안에서 f/b 를 가르는 선례,
lps-admin/ 이 백엔드 없이 프론트만 가진 최상단 폴더의 선례다.
backend/ frontend/{admin,site,shared} → solution/{backend,front,site,shared} + admin/
## 왜
내부 라우트(/local-content, /places/:id/seo)의 이름과 화면 코드가 사장님 번들에
그대로 실려 나가고 있었다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다"를
번들이 깨고 있었다 — 라우트 가드는 화면을 가리지 번들은 못 가린다.
번들을 갈라 확인했다: 사장님 dist 에서 local-content · /places · SeoAudit 이 전부 0건이다.
그 과정에서 두 곳이 더 새고 있었다.
- AppShell 의 NAV 배열이 내부 메뉴를 하드코딩하고 있었다. 앱을 가른 뒤에도 dist 에
local-content 가 남아서 찾았다. 메뉴는 이제 앱이 prop 으로 들고 온다.
- EditorHeader·BuilderPage·LoginPage 가 /places 로 링크하고 있었다. 그 화면이 admin 으로
나갔으니 사장님 앱에서는 404 다. 링크를 걷어내고 LoginPage 기본 도착지는 '/' 로 바꿨다
(앱마다 홈이 다르고 각 라우터의 '/' 가 이미 그걸 안다).
## admin 에 백엔드를 두지 않았다
내부 화면이 부르는 훅이 전부 router/v1/{place,fact,local,validator} 에 이미 있다.
자체 백엔드를 두면 place·fact·link 를 같은 DB 에 대고 두 번 구현하게 된다.
대가는 solution/backend 가 죽으면 admin 도 멈추는 것 — 내부 도구라 감수한다.
## admin 의 `@` 는 solution/front/src 를 가리킨다
내부 화면이 쓰는 API 클라이언트·UI·수집 배선이 solution 에 한 벌만 있고 그 파일들끼리도
`@/...` 로 서로를 부른다. admin 에서 `@` 를 자기 src 로 잡으면 그 참조가 전부 깨진다
(실측 TS2307 14건). 복제하는 길도 있지만 RecollectPanel 주석이 금지한다 —
"수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
admin 자기 파일만 `@admin` 이고, 의존 방향은 admin → solution 한 쪽뿐이다.
admin 이 여는 빌더는 다른 오리진이라 절대 URL + 새 탭이다(admin/src/lib/solutionUrl.ts).
react-router Link 로 두면 admin 안에서 라우트를 찾다 404 다.
## 그 밖
- npm 워크스페이스 루트를 레포 루트로 올렸다(admin 이 solution 밖이라).
- docker-compose 를 255→174줄로 줄이고 admin(:3002) 서비스를 넣었다. ADMIN_BIND 기본값은
127.0.0.1 — 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- 발행 호스트를 프론트 .env 에 따로 적지 않는다. compose 가 루트의 SITE_PUBLIC_HOST 를
VITE_PUBLISH_HOST 로 흘려보낸다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- nginx/site.conf 를 git 에서 빼고 .example 만 남겼다(.env·*.toml 과 같은 규약).
compose 가 bind mount 하므로 클론 직후 복사해야 한다 — 없으면 Docker 가 그 자리에
디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- config.test.toml.example 을 추가했다. 없으면 클론한 사람이 pytest 를 아예 못 돌린다
(conftest import 단계에서 죽는다). 외부 API 키는 전부 빈값이다 —
APP_ENV=test 가 .env 를 안 읽는 이유를 여기서 우회하면 안 된다.
- 경로가 한 칸 깊어져 test_schema_ddl(parents[2]→[3]) 과 test_site_theme 을 고쳤다.
검증: front·admin·site 전부 lint 0 / build 0. 백엔드 514 passed.
남은 4건(test_build_publish 3 · test_snapshot 1)은 이 변경 전부터 실패하던 것으로,
손대지 않은 메인 체크아웃에서 같은 4건이 같게 실패하는 것을 확인했다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
531 lines
24 KiB
Python
531 lines
24 KiB
Python
"""정적 HTML 어댑터 — **사장님이 확정한 자기 홈페이지** 전용.
|
|
|
|
★ 왜 이 어댑터가 지금 등록되는가 (docs/DATA_SOURCE_RESEARCH.md, 2026-08-28)
|
|
docs/DECISIONS.md 1-1 이 "약관·robots.txt 기준 허용 범위" 결론 전까지 등록을 보류했던
|
|
그 어댑터다. 실측 결론은 이렇다.
|
|
|
|
야놀자·여기어때 HTTP 403 + Cloudflare 챌린지. 기술적으로 막혔고, 같은 행위에
|
|
민사 10억 배상 선례가 있다(야놀자 v 여기어때, 서울중앙지법 2021-08).
|
|
네이버·카카오 robots.txt 가 `Disallow: /`. 명시적 불허.
|
|
사장님 자체 홈페이지 사장님이 URL 을 확정해 주고, 그 사실의 주인도 사장님이다. **가능.**
|
|
|
|
즉 이 어댑터의 정당성은 전부 "사장님이 확정한 URL 만 본다" 에서 나온다.
|
|
그 전제가 깨지면(플랫폼 URL 이 흘러들어오면) 정당성도 같이 깨지므로,
|
|
아래 _DENY_HOSTS 로 **구조적으로** 막는다. 운영자가 실수로 넣어도 안 긁힌다.
|
|
|
|
★ 표본 근거 — 왜 이 어댑터가 숙박에서 특히 값이 큰가
|
|
네이버 지역검색 `link` 필드 충전율(업종별 25건 표본): 숙박 96% 중 자체 도메인 19건,
|
|
음식점 64%, 카페 92% 중 인스타 17건. 숙박은 자체 홈페이지 보유율이 3업종 중 가장 높다.
|
|
|
|
**금지 (docs/DECISIONS.md 1-1, 결론과 무관하게 영구)**
|
|
캡차 우회 · 봇 탐지 우회 · IP 회전. 여기에 하나 더 —
|
|
**robots.txt 를 확인하고 그대로 따른다.** 사장님 홈페이지라도 예외 없다.
|
|
막히면 실패로 돌려주고 폴백 3단계로 간다(공식 API → 사장님 붙여넣기 → 최소 정보 생성).
|
|
|
|
★ 값을 만드는 원칙
|
|
구조화된 것(JSON-LD schema.org, OpenGraph)만 fact 로 올린다. 본문 텍스트에서
|
|
키워드를 주워 억지로 매핑하지 않는다 — 유일한 예외가 체크인·체크아웃 시각인데,
|
|
숙박에서 critical 이고 "체크인" 이라는 낱말과 시각이 붙어 있는 형태만 좁게 본다.
|
|
"""
|
|
import asyncio
|
|
import json
|
|
import re
|
|
import time
|
|
from html import unescape
|
|
from html.parser import HTMLParser
|
|
from typing import Optional
|
|
from urllib.parse import urljoin, urlparse
|
|
from urllib.robotparser import RobotFileParser
|
|
|
|
import httpx
|
|
|
|
from common.enums import LinkChannel
|
|
from common.logger import LOG
|
|
from services.collector.base import CollectedFact, CollectedMedia, RawSource
|
|
|
|
# 우리를 밝히는 UA. ★ 브라우저인 척하지 않는다 — robots.txt 판정도 이 이름으로 받는다.
|
|
# ★ ASCII 만 쓴다. HTTP 헤더는 latin-1 로 인코딩되므로 한글을 넣으면
|
|
# 요청이 나가기도 전에 UnicodeEncodeError 로 죽는다(실측).
|
|
BOT_NAME = "o2o-web4ai-collector"
|
|
USER_AGENT = f"{BOT_NAME}/1.0 (+owner-confirmed URL only; contact: owner of the listed business)"
|
|
|
|
REQUEST_TIMEOUT = 20
|
|
MAX_BYTES = 3 * 1024 * 1024 # 본문 상한. 넘으면 거기까지만 읽는다.
|
|
MAX_MEDIA = 30
|
|
ROBOTS_TTL = 3600 # 호스트당 robots.txt 캐시 수명(초)
|
|
|
|
HEADERS = {
|
|
"User-Agent": USER_AGENT,
|
|
"Accept": "text/html,application/xhtml+xml",
|
|
"Accept-Language": "ko-KR,ko;q=0.9",
|
|
}
|
|
|
|
# ★ 이 호스트들은 이 어댑터가 절대 건드리지 않는다.
|
|
# - 전용 어댑터가 따로 있거나(naver_place)
|
|
# - 실측·판례로 수집 불가 결론이 난 곳이거나(야놀자·여기어때·카카오맵)
|
|
# - 공식 OAuth 로만 가져와야 하는 곳(인스타그램)이다.
|
|
# can_handle 에서 걸러 AdapterNotFound 로 떨어뜨린다.
|
|
_DENY_HOSTS = (
|
|
"naver.com", "naver.me", # 플레이스·지도·블로그·예약 — robots Disallow: /
|
|
"kakao.com", "daum.net", # 카카오맵 — robots Disallow, 내부 API 406
|
|
"yanolja.com", "goodchoice.kr", # OTA — 403 + 민사 10억 선례
|
|
"dailyhotel.com", "catchtable.co.kr",
|
|
"airbnb.co.kr", "airbnb.com",
|
|
"booking.com", "agoda.com", "expedia.co.kr",
|
|
"instagram.com", "facebook.com", # Graph API(사장님 OAuth)로만
|
|
"baemin.com", "yogiyo.co.kr", "coupangeats.com",
|
|
"diningcode.com", "siksinhot.com", "mangoplate.com",
|
|
)
|
|
|
|
# schema.org 타입 → 우리가 관심 있는 업소인가. 목록에 없는 타입은 fact 를 만들지 않는다.
|
|
_LB_TYPES = {
|
|
"localbusiness", "lodgingbusiness", "hotel", "motel", "resort", "hostel",
|
|
"bedandbreakfast", "campground", "vacationrental",
|
|
"restaurant", "cafeorcoffeeshop", "foodestablishment", "bakery", "barorpub",
|
|
"touristattraction", "store",
|
|
}
|
|
|
|
# 부정 표현. ★ naver_place_adapter 와 같은 판정을 쓴다 — "주차 불가" 가 parking=true 로 뒤집히면
|
|
# 업종을 가리지 않고 같은 사고가 난다.
|
|
_NEGATIONS = ("불가", "없음", "없슴", "미제공", "제공하지", "안됨", "안 됨", "불가능", "금지", "not available")
|
|
|
|
# 편의시설 표기 → 업종 스키마의 bool key. 사장님 홈페이지는 표기가 제각각이라
|
|
# 한국어·영어를 같이 본다. ★ 여기 없는 표현은 fact 로 만들지 않는다.
|
|
_AMENITY_BOOL = {
|
|
"주차": "parking", "발렛": "parking", "parking": "parking",
|
|
"와이파이": "wifi", "무선인터넷": "wifi", "무선 인터넷": "wifi", "wifi": "wifi", "wi-fi": "wifi",
|
|
"바비큐": "bbq_available", "바베큐": "bbq_available", "bbq": "bbq_available",
|
|
"취사": "cooking_allowed", "조리": "cooking_allowed",
|
|
"조식": "breakfast", "breakfast": "breakfast",
|
|
"반려동물": "pet_allowed", "애견": "pet_allowed", "pet": "pet_allowed",
|
|
"픽업": "pickup_service",
|
|
"유아용품": "baby_amenities", "아기": "baby_amenities",
|
|
"에어컨": "has_aircon", "냉방": "has_aircon",
|
|
"주방": "has_kitchen", "취사시설": "has_kitchen",
|
|
"테라스": "terrace",
|
|
"포장": "takeout", "테이크아웃": "takeout",
|
|
"배달": "delivery",
|
|
"콘센트": "power_outlet",
|
|
"휠체어": "wheelchair_accessible", "장애인": "wheelchair_accessible",
|
|
}
|
|
|
|
# 업종마다 "영업시간" 자리를 차지하는 key. 스키마에 없는 것은 fact 기록 단계에서 거부된다.
|
|
_HOURS_KEYS = ("business_hours", "operating_hours", "reception_hours")
|
|
|
|
# ★ 본문에서 유일하게 허용하는 정규식 — 체크인·체크아웃.
|
|
# 숙박에서 critical 이고(틀리면 예약 클레임) 비면 AI 검색이 OTA 설명을 대신 인용한다.
|
|
# "체크인" 이라는 낱말 **바로 뒤**의 시각만 본다. 떨어져 있으면 잡지 않는다.
|
|
_CHECKIN = re.compile(r"체크\s*인[^0-9]{0,12}((?:오전|오후)?\s*\d{1,2}\s*[:시]\s*\d{0,2})")
|
|
_CHECKOUT = re.compile(r"체크\s*아웃[^0-9]{0,12}((?:오전|오후)?\s*\d{1,2}\s*[:시]\s*\d{0,2})")
|
|
|
|
_robots_cache: dict[str, tuple[float, Optional[RobotFileParser]]] = {}
|
|
|
|
|
|
class _Extract(HTMLParser):
|
|
"""한 번 훑으면서 JSON-LD · meta · 본문 텍스트를 같이 걷는다.
|
|
|
|
표준 라이브러리만 쓴다 — 이것 하나 때문에 파서 의존성을 새로 얹지 않는다.
|
|
"""
|
|
|
|
def __init__(self):
|
|
super().__init__(convert_charrefs=True)
|
|
self.ld_raw: list[str] = []
|
|
self.meta: dict[str, str] = {}
|
|
self.title: str = ""
|
|
self._text: list[str] = []
|
|
self._skip = 0 # script/style 안쪽 깊이
|
|
self._in_ld = False
|
|
self._in_title = False
|
|
|
|
def handle_starttag(self, tag, attrs):
|
|
a = {k.lower(): (v or "") for k, v in attrs}
|
|
if tag in ("script", "style", "noscript"):
|
|
self._skip += 1
|
|
if tag == "script" and a.get("type", "").lower().strip() == "application/ld+json":
|
|
self._in_ld = True
|
|
self.ld_raw.append("")
|
|
elif tag == "title":
|
|
self._in_title = True
|
|
elif tag == "meta":
|
|
name = (a.get("property") or a.get("name") or "").lower().strip()
|
|
content = a.get("content", "").strip()
|
|
if name and content and name not in self.meta:
|
|
self.meta[name] = content
|
|
|
|
def handle_endtag(self, tag):
|
|
if tag in ("script", "style", "noscript"):
|
|
self._skip = max(0, self._skip - 1)
|
|
self._in_ld = False
|
|
elif tag == "title":
|
|
self._in_title = False
|
|
|
|
def handle_data(self, data):
|
|
if self._in_ld:
|
|
self.ld_raw[-1] += data
|
|
elif self._in_title:
|
|
self.title += data
|
|
elif not self._skip:
|
|
s = data.strip()
|
|
if s:
|
|
self._text.append(s)
|
|
|
|
@property
|
|
def text(self) -> str:
|
|
return " ".join(self._text)
|
|
|
|
|
|
class StaticHtmlAdapter:
|
|
"""사장님 확정 홈페이지 → fact·사진 후보."""
|
|
|
|
id = "static_html"
|
|
|
|
def can_handle(self, url: str) -> bool:
|
|
"""http(s) 이고, 전용 어댑터·수집 불가 결론이 난 호스트가 아니면 손든다.
|
|
|
|
★ 이 어댑터는 **넓다**. 레지스트리에서 반드시 좁은 어댑터 뒤에 등록해야 한다.
|
|
"""
|
|
u = (url or "").strip().lower()
|
|
if not u.startswith(("http://", "https://")):
|
|
return False
|
|
host = urlparse(u).netloc.split(":")[0]
|
|
if not host:
|
|
return False
|
|
return not any(host == d or host.endswith("." + d) for d in _DENY_HOSTS)
|
|
|
|
async def fetch(self, url: str) -> RawSource:
|
|
channel = self._channel(url)
|
|
|
|
allowed, why = await self._robots_allows(url)
|
|
if not allowed:
|
|
# ★ 우회하지 않는다. 사장님 홈페이지라도 robots 가 막으면 그대로 실패다.
|
|
return RawSource.failure(url, self.id, f"robots.txt 가 수집을 허용하지 않는다: {why}", channel)
|
|
|
|
try:
|
|
html = await self._get_html(url)
|
|
except Exception as ex:
|
|
return RawSource.failure(url, self.id, str(ex), channel)
|
|
|
|
parser = _Extract()
|
|
try:
|
|
parser.feed(html)
|
|
except Exception as ex: # 깨진 HTML 도 여기까지 온 만큼은 쓴다
|
|
LOG.w(f"[static_html] HTML 파싱 도중 중단: {ex} ({url})")
|
|
|
|
nodes = self._ld_nodes(parser.ld_raw)
|
|
biz = self._pick_business(nodes)
|
|
|
|
facts = self._to_facts(biz, parser)
|
|
media = self._to_media(biz, parser, url)
|
|
|
|
if not facts and not media:
|
|
return RawSource.failure(
|
|
url, self.id, "구조화 데이터(JSON-LD·OpenGraph)가 없어 확신할 수 있는 값을 찾지 못했다", channel
|
|
)
|
|
|
|
LOG.i(f"[static_html] {parser.title.strip()[:40] or url} — fact {len(facts)}건 · 사진 {len(media)}장")
|
|
return RawSource(
|
|
url=url,
|
|
adapter_id=self.id,
|
|
channel=channel,
|
|
text=parser.text[:20000],
|
|
facts=facts,
|
|
media=media,
|
|
)
|
|
|
|
# ---- robots ---------------------------------------------------------
|
|
async def _robots_allows(self, url: str) -> tuple[bool, str]:
|
|
"""robots.txt 를 우리 UA 이름으로 판정한다.
|
|
|
|
★ 못 가져오면 허용으로 본다(관례). 하지만 **가져왔는데 막고 있으면 무조건 따른다.**
|
|
호스트 단위로 캐시해서 페이지마다 다시 묻지 않는다.
|
|
"""
|
|
parts = urlparse(url)
|
|
origin = f"{parts.scheme}://{parts.netloc}"
|
|
now = time.monotonic()
|
|
|
|
cached = _robots_cache.get(origin)
|
|
if cached and now - cached[0] < ROBOTS_TTL:
|
|
rp = cached[1]
|
|
else:
|
|
rp = await self._load_robots(origin)
|
|
_robots_cache[origin] = (now, rp)
|
|
|
|
if rp is None:
|
|
return True, ""
|
|
if rp.can_fetch(BOT_NAME, url):
|
|
return True, ""
|
|
return False, f"{origin}/robots.txt 가 {BOT_NAME} 에게 이 경로를 금지한다"
|
|
|
|
async def _load_robots(self, origin: str) -> Optional[RobotFileParser]:
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
|
|
res = await client.get(f"{origin}/robots.txt", headers=HEADERS)
|
|
if res.status_code != 200:
|
|
return None
|
|
# HTML 을 돌려주는 서버가 있다(robots.txt 가 없어서 SPA 로 떨어지는 경우). 그건 규칙이 아니다.
|
|
body = res.text
|
|
if body.lstrip()[:15].lower().startswith(("<!doctype", "<html")):
|
|
return None
|
|
rp = RobotFileParser()
|
|
rp.parse(body.splitlines())
|
|
return rp
|
|
except (httpx.HTTPError, UnicodeDecodeError):
|
|
return None
|
|
|
|
# ---- 가져오기 --------------------------------------------------------
|
|
async def _get_html(self, url: str) -> str:
|
|
"""HTML 본문. text/html 이 아니거나 상한을 넘으면 거기서 멈춘다.
|
|
|
|
★ 재시도하지 않는다. 429·403 은 그대로 실패로 올린다 — 재시도 폭주가 곧 차단 사유다.
|
|
"""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT, follow_redirects=True) as client:
|
|
async with client.stream("GET", url, headers=HEADERS) as res:
|
|
if res.status_code == 429:
|
|
raise RuntimeError("서버가 요청을 제한했다(429)")
|
|
if res.status_code != 200:
|
|
raise RuntimeError(f"HTTP {res.status_code}")
|
|
ctype = res.headers.get("content-type", "").lower()
|
|
if "html" not in ctype:
|
|
raise RuntimeError(f"HTML 이 아니다: {ctype or '알 수 없음'}")
|
|
|
|
chunks: list[bytes] = []
|
|
total = 0
|
|
async for chunk in res.aiter_bytes():
|
|
chunks.append(chunk)
|
|
total += len(chunk)
|
|
if total >= MAX_BYTES:
|
|
break
|
|
raw = b"".join(chunks)
|
|
enc = res.encoding or "utf-8"
|
|
return raw.decode(enc, errors="replace")
|
|
except httpx.TimeoutException:
|
|
raise RuntimeError(f"{REQUEST_TIMEOUT}s 안에 응답이 없다")
|
|
except httpx.HTTPError as ex:
|
|
raise RuntimeError(f"네트워크 오류: {ex}")
|
|
|
|
# ---- JSON-LD --------------------------------------------------------
|
|
@staticmethod
|
|
def _ld_nodes(raw_blocks: list[str]) -> list[dict]:
|
|
"""JSON-LD 블록들을 평평한 dict 목록으로. @graph·배열·중첩을 모두 편다."""
|
|
out: list[dict] = []
|
|
|
|
def walk(node):
|
|
if isinstance(node, list):
|
|
for x in node:
|
|
walk(x)
|
|
elif isinstance(node, dict):
|
|
out.append(node)
|
|
for key in ("@graph", "mainEntity", "itemListElement"):
|
|
if key in node:
|
|
walk(node[key])
|
|
|
|
for block in raw_blocks:
|
|
block = block.strip()
|
|
if not block:
|
|
continue
|
|
try:
|
|
walk(json.loads(block))
|
|
except (json.JSONDecodeError, ValueError):
|
|
continue # 깨진 블록 하나가 전체를 죽이지 않는다
|
|
return out
|
|
|
|
@staticmethod
|
|
def _pick_business(nodes: list[dict]) -> Optional[dict]:
|
|
"""업소를 가리키는 노드 하나. 여러 개면 필드가 가장 많은 것을 쓴다."""
|
|
cands = []
|
|
for n in nodes:
|
|
types = n.get("@type")
|
|
types = [types] if isinstance(types, str) else (types or [])
|
|
if any(str(t).lower() in _LB_TYPES for t in types):
|
|
cands.append(n)
|
|
return max(cands, key=len) if cands else None
|
|
|
|
# ---- fact -----------------------------------------------------------
|
|
def _to_facts(self, biz: Optional[dict], parser: _Extract) -> list[CollectedFact]:
|
|
"""★ 스키마에 있는 key 만 만든다. 없는 key 는 fact 기록 단계에서 통째로 거부된다."""
|
|
facts: list[CollectedFact] = []
|
|
biz = biz or {}
|
|
|
|
# ★ 소개문(intro)은 수집하지 않는다 — allow_llm=True, LLM 의 출력 칸이다(절대규칙 7).
|
|
# JSON-LD description·og:description 원문은 RawSource.text 로 나가
|
|
# 생성 근거로만 쓰인다(services/copy_service.py).
|
|
|
|
# 영업시간. ★ 재조립하지 않고 원문 표기를 그대로 옮긴다 —
|
|
# "매일 09:00~21:00" 과 "평일만" 을 구조화하려다 틀리면 손님이 헛걸음한다.
|
|
hours = self._hours(biz)
|
|
if hours:
|
|
for key in _HOURS_KEYS:
|
|
facts.append(CollectedFact(key=key, value=hours))
|
|
|
|
# 체크인·체크아웃. JSON-LD 가 우선이고, 없을 때만 본문 정규식으로 좁게 본다.
|
|
for ld_key, our_key, pattern in (
|
|
("checkinTime", "check_in_time", _CHECKIN),
|
|
("checkoutTime", "check_out_time", _CHECKOUT),
|
|
):
|
|
value = self._clean(biz.get(ld_key))
|
|
if not value:
|
|
m = pattern.search(parser.text)
|
|
value = re.sub(r"\s+", "", m.group(1)) if m else ""
|
|
if value:
|
|
facts.append(CollectedFact(key=our_key, value=value[:40]))
|
|
|
|
# 가격대 · 결제수단 — 값이 그대로 문자열인 것들.
|
|
for ld_key, our_key in (("priceRange", "price_range"), ("paymentAccepted", "payment_methods")):
|
|
value = self._clean(biz.get(ld_key))
|
|
if value:
|
|
facts.append(CollectedFact(key=our_key, value=value[:200]))
|
|
|
|
facts.extend(self._bool_facts(biz))
|
|
facts.extend(self._menu_facts(biz))
|
|
return facts
|
|
|
|
def _bool_facts(self, biz: dict) -> list[CollectedFact]:
|
|
"""편의시설 → bool fact.
|
|
|
|
★ 목록에 없다고 "없다"로 단정하지 않는다 — 사장님이 안 적었을 뿐일 수 있다.
|
|
반대로 "불가/없음"이 적혀 있으면 명시적인 '아니오'이므로 false 로 담는다.
|
|
"""
|
|
labels: list[str] = []
|
|
|
|
for item in self._as_list(biz.get("amenityFeature")):
|
|
if isinstance(item, dict):
|
|
name = self._clean(item.get("name"))
|
|
# LocationFeatureSpecification.value 가 false 면 '없다'는 뜻이다.
|
|
if item.get("value") is False:
|
|
name = f"{name} 없음"
|
|
if name:
|
|
labels.append(name)
|
|
elif isinstance(item, str):
|
|
labels.append(item)
|
|
|
|
# schema.org 가 전용 필드로 주는 것들. bool 이면 부호를 그대로 쓴다.
|
|
for ld_key, our_key in (("petsAllowed", "pet_allowed"), ("smokingAllowed", "smoking")):
|
|
raw = biz.get(ld_key)
|
|
if isinstance(raw, bool):
|
|
labels.append(f"{ld_key} {'' if raw else '없음'}")
|
|
_AMENITY_BOOL.setdefault(ld_key.lower(), our_key)
|
|
elif isinstance(raw, str) and raw.strip():
|
|
labels.append(f"{ld_key} {raw}")
|
|
_AMENITY_BOOL.setdefault(ld_key.lower(), our_key)
|
|
|
|
out: list[CollectedFact] = []
|
|
seen: set[str] = set()
|
|
for text in labels:
|
|
low = text.lower()
|
|
negated = any(neg in low for neg in _NEGATIONS)
|
|
for needle, key in _AMENITY_BOOL.items():
|
|
if needle not in low or key in seen:
|
|
continue
|
|
seen.add(key)
|
|
out.append(CollectedFact(key=key, value="false" if negated else "true"))
|
|
return out
|
|
|
|
def _menu_facts(self, biz: dict) -> list[CollectedFact]:
|
|
"""hasMenu → 단위(메뉴) 스코프 fact.
|
|
|
|
★ 이름 없는 항목은 담지 않는다 — unit_name 이 비면 base 계약이 예외를 던진다.
|
|
가격은 통화 기호 없이 숫자만 오는 경우가 많아 원문 표기를 그대로 옮긴다.
|
|
"""
|
|
sections = self._as_list(biz.get("hasMenu")) + self._as_list(biz.get("menu"))
|
|
items: list[dict] = []
|
|
for sec in sections:
|
|
if isinstance(sec, dict):
|
|
items += [x for x in self._as_list(sec.get("hasMenuItem")) if isinstance(x, dict)]
|
|
for sub in self._as_list(sec.get("hasMenuSection")):
|
|
if isinstance(sub, dict):
|
|
items += [x for x in self._as_list(sub.get("hasMenuItem")) if isinstance(x, dict)]
|
|
|
|
out: list[CollectedFact] = []
|
|
seen: list[str] = []
|
|
for item in items:
|
|
name = self._clean(item.get("name"))
|
|
if not name or name in seen:
|
|
continue
|
|
seen.append(name)
|
|
out.append(CollectedFact(key="menu_name", value=name, scope="unit", unit_name=name))
|
|
|
|
offer = item.get("offers")
|
|
offer = offer[0] if isinstance(offer, list) and offer else offer
|
|
price = self._clean(offer.get("price")) if isinstance(offer, dict) else ""
|
|
if price:
|
|
out.append(CollectedFact(key="menu_price", value=price[:40], scope="unit", unit_name=name))
|
|
|
|
# ★ menu_intro 도 allow_llm=True 라 수집하지 않는다 — 메뉴 설명 문장은
|
|
# LLM 이 쓰는 칸이다. 원문은 RawSource.text 로 나가 근거로만 쓰인다.
|
|
return out
|
|
|
|
def _hours(self, biz: dict) -> str:
|
|
"""openingHours(문자열) / openingHoursSpecification(구조) 둘 다 받는다."""
|
|
parts: list[str] = []
|
|
for x in self._as_list(biz.get("openingHours")):
|
|
s = self._clean(x)
|
|
if s and s not in parts:
|
|
parts.append(s)
|
|
for spec in self._as_list(biz.get("openingHoursSpecification")):
|
|
if not isinstance(spec, dict):
|
|
continue
|
|
days = ", ".join(
|
|
str(d).rsplit("/", 1)[-1] for d in self._as_list(spec.get("dayOfWeek")) if d
|
|
)
|
|
span = f"{self._clean(spec.get('opens'))}~{self._clean(spec.get('closes'))}".strip("~")
|
|
text = f"{days} {span}".strip()
|
|
if text and text not in parts:
|
|
parts.append(text)
|
|
return " · ".join(parts)[:500]
|
|
|
|
# ---- 사진 -----------------------------------------------------------
|
|
def _to_media(self, biz: Optional[dict], parser: _Extract, base_url: str) -> list[CollectedMedia]:
|
|
"""JSON-LD image 를 앞에, OpenGraph og:image 를 뒤에.
|
|
|
|
★ origin_url 은 절대 URL 로 만들어 둔다 — 상대경로로 저장하면 나중에 출처를 되짚을 수 없고,
|
|
docs/DECISIONS.md 1-2(이미지 재게시 권리) 결론에 따라 걸러낼 수도 없게 된다.
|
|
"""
|
|
raw: list[str] = []
|
|
for item in self._as_list((biz or {}).get("image")):
|
|
if isinstance(item, str):
|
|
raw.append(item)
|
|
elif isinstance(item, dict):
|
|
raw.append(str(item.get("url") or item.get("contentUrl") or ""))
|
|
for key in ("og:image", "og:image:secure_url", "twitter:image"):
|
|
if parser.meta.get(key):
|
|
raw.append(parser.meta[key])
|
|
|
|
out: list[CollectedMedia] = []
|
|
seen: set[str] = set()
|
|
for src in raw:
|
|
src = (src or "").strip()
|
|
if not src or src.startswith("data:"):
|
|
continue
|
|
absolute = urljoin(base_url, src)
|
|
if absolute in seen:
|
|
continue
|
|
seen.add(absolute)
|
|
out.append(CollectedMedia(origin_url=absolute))
|
|
if len(out) >= MAX_MEDIA:
|
|
break
|
|
return out
|
|
|
|
# ---- 잡동사니 --------------------------------------------------------
|
|
@staticmethod
|
|
def _channel(url: str) -> LinkChannel:
|
|
"""사장님 확정 URL 이므로 기본은 공식 홈페이지다. 블로그만 따로 표시한다."""
|
|
u = (url or "").lower()
|
|
if any(b in u for b in ("blog.", "/blog", "tistory.com", "brunch.co.kr")):
|
|
return LinkChannel.BLOG
|
|
return LinkChannel.OFFICIAL_SITE
|
|
|
|
@staticmethod
|
|
def _as_list(value) -> list:
|
|
if value is None:
|
|
return []
|
|
return value if isinstance(value, list) else [value]
|
|
|
|
@staticmethod
|
|
def _clean(value) -> str:
|
|
"""문자열로 정규화. 숫자·bool 도 받아서 표기를 그대로 남긴다."""
|
|
if value is None or isinstance(value, (dict, list)):
|
|
return ""
|
|
text = unescape(str(value))
|
|
return re.sub(r"\s+", " ", text).strip()
|