최상단을 프로젝트 단위로 평평하게 둔다 — 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
410 lines
21 KiB
Python
410 lines
21 KiB
Python
"""한국관광공사 TourAPI(KorService2) 어댑터 — 숙박·음식점.
|
|
|
|
★ 왜 이 어댑터가 다른 어댑터보다 우선인가 (docs/DATA_SOURCE_RESEARCH.md)
|
|
네이버·카카오·OTA 에서 긁어온 값은 **출처를 밝힐 수 없다**. robots 를 어겼거나 재게시 근거가 없어서다.
|
|
출처를 못 밝히는 사실은 `llms.txt`·`AnswerBlock` 의 "출처와 검증 시각" 신호를 채우지 못하고,
|
|
AI 검색이 인용할 이유도 없다(GEO 연구: 인용률을 올리는 요인 1위가 '출처 인용'이다).
|
|
|
|
TourAPI 는 반대다 — **무료·이용허락범위 제한없음**이라 재게시에 제약이 없고, 출처를 당당히 밝힐 수 있다.
|
|
같은 사실이라도 여기서 온 것이 값이 더 크다.
|
|
(단 '대한민국구석구석' 에 contentId 로 열리는 공개 페이지는 **없다** — 아래 SOURCE_URL 주석 참고.)
|
|
|
|
★ 수집 경로
|
|
detailCommon2 개요·홈페이지·좌표·대표사진
|
|
detailIntro2 업종별 소개정보(체크인·주차·취사 / 영업시간·대표메뉴 …)
|
|
detailInfo2 숙박은 **객실 단위** 정보(인원·요금·면적·객실사진), 음식점은 반복정보
|
|
detailImage2 추가 사진
|
|
|
|
★ 이미지 저작권 — 여기서 끝낸다 (docs/DECISIONS.md 1-2)
|
|
TourAPI 는 사진마다 공공누리 유형(`cpyrhtDivCd`)을 준다.
|
|
Type1 출처표시 상업적 이용 O · 변형 O
|
|
Type2 출처표시 + 상업적이용금지 상업적 이용 X ← 버린다
|
|
Type3 출처표시 + 변경금지 상업적 이용 O · 변형 X
|
|
Type4 출처표시 + 상업적이용금지 + 변경금지 ← 버린다
|
|
사장님 홈페이지는 **상업적 이용**이므로 Type2·Type4 는 수집 단계에서 통째로 버린다.
|
|
Type3 는 담되 `license` 에 표시해 둔다 — 뒤에서 크롭·리사이즈하면 '변경금지' 를 어긴다.
|
|
|
|
**금지 (docs/DECISIONS.md 1-1, 결론과 무관하게 영구)**
|
|
캡차 우회 · 봇 탐지 우회 · IP 회전. 여기는 공식 API 라 해당 사항이 없고, 앞으로도 만들지 않는다.
|
|
"""
|
|
import re
|
|
from typing import Optional
|
|
from urllib.parse import unquote, urlencode
|
|
|
|
import httpx
|
|
|
|
from common.enums import LinkChannel
|
|
from common.logger import LOG
|
|
from config.server_configs import external_api_config
|
|
from services.collector.base import CollectedFact, CollectedMedia, RawSource
|
|
|
|
BASE_URL = "https://apis.data.go.kr/B551011/KorService2"
|
|
|
|
# fact 의 출처로 남기는 주소. **인증키를 뺀** 해당 레코드의 API 주소다.
|
|
#
|
|
# ★ 왜 '대한민국구석구석' 페이지가 아닌가 (2026-08-31 실측)
|
|
# 처음엔 korean.visitkorea.or.kr/detail/ms_detail.do?cotid={contentId} 를 썼는데 **틀렸다.**
|
|
# 구석구석의 `cotid` 는 TourAPI 의 `contentid` 와 다른 체계(GUID)라, 어떤 contentId 를 넣어도
|
|
# 같은 크기(7,702B)의 "안내 페이지" 가 돌아온다. 검색 결과 페이지도 전부 JS 렌더라
|
|
# contentId 로 바로 열리는 공개 URL 이 존재하지 않는다.
|
|
#
|
|
# 그래서 **그 레코드를 정확히 가리키면서 재현 가능한 주소**를 쓴다. 키가 없으면 데이터 대신
|
|
# 인증 오류가 오지만, 자기 키를 가진 사람은 같은 값을 그대로 받아볼 수 있다 —
|
|
# 출처의 목적(어디서 왔고 어떻게 확인하는가)은 이것으로 충족된다.
|
|
# ★ 인증키는 절대 넣지 않는다. 이 주소는 DB 에 저장되고 발행본에도 나간다.
|
|
SOURCE_URL = (
|
|
"https://apis.data.go.kr/B551011/KorService2/detailCommon2"
|
|
"?contentId={content_id}&MobileOS=ETC&MobileApp=o2o-web4ai&_type=json"
|
|
)
|
|
# 화면에 적을 출처 이름. URL 만으로는 무엇인지 알 수 없어서 함께 남긴다.
|
|
SOURCE_LABEL = "한국관광공사 TourAPI"
|
|
|
|
REQUEST_TIMEOUT = 25
|
|
MAX_MEDIA = 30
|
|
|
|
CONTENT_TYPE_LODGING = "32"
|
|
CONTENT_TYPE_RESTAURANT = "39"
|
|
_SUPPORTED = (CONTENT_TYPE_LODGING, CONTENT_TYPE_RESTAURANT)
|
|
|
|
# ★ 상업적 이용이 허용된 공공누리 유형만 담는다. 목록에 없는 코드는 '모른다' 로 보고 버린다 —
|
|
# 새 코드가 생겼을 때 조용히 통과시키는 것보다 빠뜨리는 쪽이 안전하다.
|
|
COMMERCIAL_OK_LICENSES = frozenset({"type1", "type3"})
|
|
# 변형(크롭·리사이즈)까지 금지된 유형. 뒤 단계가 이걸 보고 원본 그대로 써야 한다.
|
|
NO_DERIVATIVE_LICENSES = frozenset({"type3", "type4"})
|
|
|
|
# 이 어댑터가 받는 주소. 사장님이 붙여넣는 것은 구석구석 주소이고,
|
|
# tour:// 는 내부에서 콘텐츠 id 를 바로 지정할 때 쓴다(채널 발견·재수집).
|
|
_COTID = re.compile(r"[?&]cotid=([0-9a-zA-Z\-]+)", re.I)
|
|
_TOUR_SCHEME = re.compile(r"^tour://(\d+)/(\d+)$", re.I)
|
|
_HOSTS = ("visitkorea.or.kr",)
|
|
|
|
_BR = re.compile(r"<br\s*/?>", re.I)
|
|
_TAGS = re.compile(r"<[^>]+>")
|
|
_HREF = re.compile(r'href=["\']([^"\']+)["\']', re.I)
|
|
# "13실", "16명", "대지 면적 11,570㎡" 처럼 숫자에 단위가 붙어 온다. number 필드는 숫자만 남긴다.
|
|
_LEADING_NUM = re.compile(r"^\s*([\d,]+(?:\.\d+)?)")
|
|
|
|
# ★ "가능 / 불가" 로 시작하는 필드들. 뒤에 괄호 설명이 붙는다:
|
|
# parkinglodging = "가능 (객실당 1대 무료 주차)"
|
|
# parkingfood = "불가 (인근 주차장 이용)" ← 뒤에 '이용' 이 있다고 true 로 읽으면 안 된다
|
|
# 그래서 **맨 앞 토큰**으로만 부호를 판정한다. 문자열 전체를 훑으면 뒤집힌다(실측).
|
|
_NEG_HEAD = ("불가", "없음", "미제공", "불가능", "없슴")
|
|
_POS_HEAD = ("가능", "있음", "제공", "무료", "부분")
|
|
|
|
|
|
class TourApiAdapter:
|
|
"""TourAPI 콘텐츠 → fact·사진 후보."""
|
|
|
|
id = "tour_api"
|
|
|
|
# ---- 계약 ----------------------------------------------------------
|
|
def can_handle(self, url: str) -> bool:
|
|
u = (url or "").strip().lower()
|
|
if _TOUR_SCHEME.match(u):
|
|
return True
|
|
return any(h in u for h in _HOSTS) and bool(_COTID.search(u))
|
|
|
|
async def fetch(self, url: str) -> RawSource:
|
|
channel = LinkChannel.ETC
|
|
key = (external_api_config.tour_api_key or "").strip()
|
|
if not key:
|
|
return RawSource.failure(url, self.id, "TOUR_API_KEY 가 설정되지 않았다", channel)
|
|
|
|
parsed = self._parse_url(url)
|
|
if not parsed:
|
|
return RawSource.failure(url, self.id, f"콘텐츠 id 를 찾지 못했다: {url}", channel)
|
|
content_id, content_type = parsed
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
|
|
common = await self._call(client, key, "detailCommon2", contentId=content_id)
|
|
if not common:
|
|
return RawSource.failure(url, self.id, f"detailCommon2 결과 없음(contentId={content_id})", channel)
|
|
common = common[0]
|
|
|
|
# URL 에 타입이 없으면 공통정보가 알려준다.
|
|
content_type = content_type or str(common.get("contenttypeid") or "")
|
|
if content_type not in _SUPPORTED:
|
|
return RawSource.failure(
|
|
url, self.id,
|
|
f"지원하지 않는 콘텐츠 타입: {content_type} (숙박 32 · 음식점 39 만 받는다)",
|
|
channel,
|
|
)
|
|
|
|
intro = await self._call(client, key, "detailIntro2", contentId=content_id, contentTypeId=content_type)
|
|
info = await self._call(client, key, "detailInfo2", contentId=content_id, contentTypeId=content_type)
|
|
images = await self._call(client, key, "detailImage2", contentId=content_id, imageYN="Y")
|
|
except httpx.TimeoutException:
|
|
return RawSource.failure(url, self.id, f"{REQUEST_TIMEOUT}s 안에 응답이 없다", channel)
|
|
except httpx.HTTPError as ex:
|
|
return RawSource.failure(url, self.id, f"네트워크 오류: {ex}", channel)
|
|
except RuntimeError as ex:
|
|
return RawSource.failure(url, self.id, str(ex), channel)
|
|
|
|
source_url = SOURCE_URL.format(content_id=content_id)
|
|
intro_row = intro[0] if intro else {}
|
|
|
|
facts = self._common_facts(common)
|
|
if content_type == CONTENT_TYPE_LODGING:
|
|
facts += self._lodging_facts(intro_row)
|
|
facts += self._room_facts(info)
|
|
else:
|
|
facts += self._restaurant_facts(intro_row)
|
|
|
|
media = self._media(common, images, info if content_type == CONTENT_TYPE_LODGING else [])
|
|
|
|
LOG.i(
|
|
f"[tour_api] {common.get('title')} — fact {len(facts)}건 · 사진 {len(media)}장 "
|
|
f"(contentId={content_id} type={content_type})"
|
|
)
|
|
return RawSource(
|
|
url=source_url,
|
|
adapter_id=self.id,
|
|
channel=channel,
|
|
text=self._plain(common.get("overview")),
|
|
facts=facts,
|
|
media=media,
|
|
)
|
|
|
|
# ---- HTTP ----------------------------------------------------------
|
|
async def _call(self, client: httpx.AsyncClient, key: str, op: str, **params) -> list[dict]:
|
|
"""오퍼레이션 1회. 결과가 없으면 빈 목록 — 없는 것과 실패를 구분한다.
|
|
|
|
★ 포털의 'Encoding' 키를 그대로 넣어도 이중 인코딩되지 않도록 원문으로 되돌린다
|
|
(services/external/tour_api.py 와 같은 처리다).
|
|
"""
|
|
query = urlencode(
|
|
{"serviceKey": unquote(key), "MobileOS": "ETC", "MobileApp": "o2o-web4ai",
|
|
"_type": "json", "numOfRows": "50", "pageNo": "1", **params},
|
|
safe="",
|
|
)
|
|
res = await client.get(f"{BASE_URL}/{op}?{query}")
|
|
if res.status_code != 200:
|
|
raise RuntimeError(f"{op} HTTP {res.status_code}")
|
|
|
|
try:
|
|
payload = res.json()
|
|
except ValueError:
|
|
# 인증 실패·쿼터 초과는 XML 로 온다. 본문 앞부분을 그대로 올려 원인을 감추지 않는다.
|
|
raise RuntimeError(f"{op} 응답이 JSON 이 아니다: {res.text[:160]}")
|
|
|
|
header = payload.get("response", {}).get("header", {})
|
|
code = str(header.get("resultCode") or "")
|
|
if code not in ("0000", "00"):
|
|
raise RuntimeError(f"{op} 실패 [{code}] {header.get('resultMsg')}")
|
|
|
|
items = (payload.get("response", {}).get("body", {}).get("items") or {}).get("item")
|
|
if isinstance(items, dict):
|
|
return [items]
|
|
return items or []
|
|
|
|
@staticmethod
|
|
def _parse_url(url: str) -> Optional[tuple[str, str]]:
|
|
"""(contentId, contentTypeId) — 타입을 모르면 빈 문자열."""
|
|
m = _TOUR_SCHEME.match((url or "").strip())
|
|
if m:
|
|
return m.group(2), m.group(1)
|
|
m = _COTID.search(url or "")
|
|
return (m.group(1), "") if m else None
|
|
|
|
# ---- 값 다듬기 -------------------------------------------------------
|
|
@classmethod
|
|
def _plain(cls, value) -> str:
|
|
"""<br> 은 줄바꿈으로, 나머지 태그는 제거. ★ 글자는 지우지 않는다 — '불가' 가 사라지면 부호가 뒤집힌다."""
|
|
if value is None:
|
|
return ""
|
|
text = _TAGS.sub("", _BR.sub("\n", str(value)))
|
|
return re.sub(r"[ \t]+", " ", text).strip()
|
|
|
|
@classmethod
|
|
def _yn(cls, value) -> Optional[bool]:
|
|
"""'Y'/'N' 또는 '1'/'0'. 그 외에는 판단하지 않는다(None)."""
|
|
v = str(value or "").strip().upper()
|
|
if v in ("Y", "1"):
|
|
return True
|
|
if v in ("N", "0"):
|
|
return False
|
|
return None
|
|
|
|
@classmethod
|
|
def _head_bool(cls, value) -> Optional[bool]:
|
|
"""'가능 (…)' / '불가 (…)' 처럼 **맨 앞 토큰**이 부호인 필드.
|
|
|
|
★ 문자열 전체를 훑으면 "불가 (인근 주차장 이용)" 이 true 로 뒤집힌다(실측).
|
|
"""
|
|
text = cls._plain(value)
|
|
if not text:
|
|
return None
|
|
head = text[:6]
|
|
if any(neg in head for neg in _NEG_HEAD):
|
|
return False
|
|
if any(pos in head for pos in _POS_HEAD):
|
|
return True
|
|
return None
|
|
|
|
@classmethod
|
|
def _number(cls, value) -> Optional[str]:
|
|
"""'13실' · '11,570㎡' → '13' · '11570'. 숫자로 시작하지 않으면 담지 않는다."""
|
|
m = _LEADING_NUM.match(cls._plain(value))
|
|
if not m:
|
|
return None
|
|
num = m.group(1).replace(",", "")
|
|
return num if num not in ("", "0") else None
|
|
|
|
@staticmethod
|
|
def _fact(key: str, value: Optional[str], *, unit: Optional[str] = None) -> Optional[CollectedFact]:
|
|
if value is None or not str(value).strip():
|
|
return None
|
|
return CollectedFact(
|
|
key=key, value=str(value).strip(),
|
|
scope="unit" if unit else "place", unit_name=unit,
|
|
)
|
|
|
|
@classmethod
|
|
def _collect(cls, pairs) -> list[CollectedFact]:
|
|
return [f for f in (cls._fact(*p[:2], unit=p[2] if len(p) > 2 else None) for p in pairs) if f]
|
|
|
|
# ---- 업종별 매핑 -----------------------------------------------------
|
|
def _common_facts(self, common: dict) -> list[CollectedFact]:
|
|
"""모든 업종 공통.
|
|
|
|
★ overview 를 `intro` fact 로 만들지 않는다 (2026-08-31 수정).
|
|
`intro` 는 스키마에서 allow_llm=True — **LLM 이 쓰는 칸**이지 수집하는 사실이 아니다
|
|
(절대규칙 7, tests/test_collector.py::test_adapters_do_not_collect_llm_written_fields).
|
|
|
|
한 번 어겼더니 이렇게 됐다: TourAPI overview 457자가 그대로 VERIFIED 로 들어가
|
|
사장님 사이트의 '숙소 소개' 를 차지하고, 정작 Gemini 가 쓴 162자 소개문은
|
|
PENDING_OWNER 로 뒤에 밀렸다. 원문을 그대로 싣는 것은 GEO 관점에서도 복제 콘텐츠다.
|
|
|
|
원문은 버리지 않는다 — RawSource.text 로 나가 place_links.raw 에 박제되고,
|
|
소개문·FAQ 생성 시점에만 근거로 쓰인다(services/copy_service.py).
|
|
"""
|
|
return []
|
|
|
|
def _lodging_facts(self, intro: dict) -> list[CollectedFact]:
|
|
"""숙박 detailIntro2 → 사업장 단위 fact.
|
|
|
|
★ 스키마에 없는 값은 만들지 않는다. roomcount·scalelodging·foodplace 는
|
|
lodging 스키마에 자리가 없어서 **일부러 버린다** — 억지로 다른 key 에 넣으면
|
|
'식음료장 있음' 이 '조식 제공' 으로 둔갑한다.
|
|
"""
|
|
return self._collect([
|
|
("check_in_time", self._plain(intro.get("checkintime"))[:40]),
|
|
("check_out_time", self._plain(intro.get("checkouttime"))[:40]),
|
|
("cancel_policy", self._plain(intro.get("refundregulation"))[:1000]),
|
|
("cooking_allowed", self._bool_str(self._head_bool(intro.get("chkcooking")))),
|
|
("parking", self._bool_str(self._head_bool(intro.get("parkinglodging")))),
|
|
("pickup_service", self._bool_str(self._head_bool(intro.get("pickup")))),
|
|
("bbq_available", self._bool_str(self._yn(intro.get("barbecue")))),
|
|
])
|
|
|
|
def _restaurant_facts(self, intro: dict) -> list[CollectedFact]:
|
|
"""음식점 detailIntro2 → 사업장 단위 fact.
|
|
|
|
★ 일부러 매핑하지 않는 필드 — 뜻이 미묘하게 다른 것들이다.
|
|
kidsfacility '어린이놀이방 유무' 이지 '아이 동반 가능' 이 아니다.
|
|
놀이방이 없다고 kids_allowed=false 로 올리면 아이를 데려올 수 있는 가게를
|
|
'아이 동반 불가' 로 만든다(실측: 가문 → kids_allowed=false 로 잘못 나왔다).
|
|
chkcreditcardfood '가능/없음' 이라 payment_methods(결제수단 **목록**)에 넣을 수 없다.
|
|
smoking '모두 금연석' 은 restaurant 스키마에 자리가 없다.
|
|
infocenterfood 문의 전화일 뿐 예약 채널이라는 근거가 없다.
|
|
"""
|
|
facts = self._collect([
|
|
("business_hours", self._plain(intro.get("opentimefood"))[:500]),
|
|
("closed_days", self._plain(intro.get("restdatefood"))[:200]),
|
|
("signature_menu", self._plain(intro.get("firstmenu"))[:200]),
|
|
("parking", self._bool_str(self._head_bool(intro.get("parkingfood")))),
|
|
("takeout", self._bool_str(self._head_bool(intro.get("packing")))),
|
|
("reservation_required", self._bool_str(self._head_bool(intro.get("reservationfood")))),
|
|
])
|
|
# treatmenu 는 "돔베고기 / 멸치국수 / 물만두 등" 처럼 취급 메뉴를 이어 붙여 준다.
|
|
# ★ 가격이 없으므로 이름만 단위로 만든다. 이름 없는 단위는 base 계약이 막는다.
|
|
for name in self._split_menu(intro.get("treatmenu")):
|
|
facts.append(CollectedFact(key="menu_name", value=name, scope="unit", unit_name=name))
|
|
return facts
|
|
|
|
@classmethod
|
|
def _split_menu(cls, value) -> list[str]:
|
|
"""'게살해물요리 / 난자완스 / 특색냉채 등' → 3건. 꼬리의 '등' 은 메뉴가 아니다."""
|
|
text = cls._plain(value)
|
|
if not text:
|
|
return []
|
|
out: list[str] = []
|
|
for token in re.split(r"[/,·]", text):
|
|
name = re.sub(r"\s*등\s*$", "", token).strip()
|
|
if len(name) >= 2 and name not in out:
|
|
out.append(name)
|
|
return out[:20]
|
|
|
|
def _room_facts(self, rooms: list[dict]) -> list[CollectedFact]:
|
|
"""숙박 detailInfo2 → **객실 단위** fact.
|
|
|
|
★ 이 자리가 비어 있어서 place.units 가 전 사업장 0건이었다(naver_place_adapter 주석 참조).
|
|
TourAPI 는 객실을 행으로 주므로 여기서 가장 깨끗하게 채워진다.
|
|
★ 면적은 roomsize2(㎡)를 쓴다 — 스키마의 room_size 단위가 ㎡ 다. roomsize1 은 평이라 섞으면 3배 틀린다.
|
|
★ 요금은 비수기 최소요금(offseason)을 주중·주말로, 성수기(peak) 주중값을 peak_price 로 담는다.
|
|
'최소요금' 이라는 것을 사장님이 확인 화면에서 보고 고칠 수 있게 후보로만 올린다.
|
|
"""
|
|
facts: list[CollectedFact] = []
|
|
seen: list[str] = []
|
|
for row in rooms:
|
|
name = self._plain(row.get("roomtitle"))
|
|
if not name or name in seen:
|
|
continue
|
|
seen.append(name)
|
|
facts += self._collect([
|
|
("room_type", name, name),
|
|
("standard_capacity", self._number(row.get("roombasecount")), name),
|
|
("max_capacity", self._number(row.get("roommaxcount")), name),
|
|
("room_size", self._number(row.get("roomsize2")), name),
|
|
("weekday_price", self._number(row.get("roomoffseasonminfee1")), name),
|
|
("weekend_price", self._number(row.get("roomoffseasonminfee2")), name),
|
|
("peak_price", self._number(row.get("roompeakseasonminfee1")), name),
|
|
("has_kitchen", self._bool_str(self._yn(row.get("roomcook"))), name),
|
|
("has_aircon", self._bool_str(self._yn(row.get("roomaircondition"))), name),
|
|
])
|
|
return facts
|
|
|
|
@staticmethod
|
|
def _bool_str(value: Optional[bool]) -> Optional[str]:
|
|
"""★ None(모름)과 False(아니오)를 구분한다. 모르는 것을 '아니오' 로 만들지 않는다."""
|
|
return None if value is None else ("true" if value else "false")
|
|
|
|
# ---- 사진 -----------------------------------------------------------
|
|
def _media(self, common: dict, images: list[dict], rooms: list[dict]) -> list[CollectedMedia]:
|
|
"""대표사진 + 추가사진 + 객실사진. 상업적 이용이 막힌 유형은 여기서 버린다."""
|
|
out: list[CollectedMedia] = []
|
|
seen: set[str] = set()
|
|
dropped = 0
|
|
|
|
def add(url, license_code, label=None, unit=None) -> None:
|
|
nonlocal dropped
|
|
url = (url or "").strip()
|
|
if not url or url in seen:
|
|
return
|
|
code = str(license_code or "").strip().lower()
|
|
# ★ 라이선스를 모르면 담지 않는다. 상업 사이트에 실을 사진이라 '모름' 은 위험 쪽이다.
|
|
if code not in COMMERCIAL_OK_LICENSES:
|
|
dropped += 1
|
|
return
|
|
seen.add(url)
|
|
out.append(CollectedMedia(
|
|
origin_url=url, label=label or None, unit_name=unit,
|
|
license=code,
|
|
))
|
|
|
|
add(common.get("firstimage"), common.get("cpyrhtDivCd"), label=self._plain(common.get("title")))
|
|
for img in images:
|
|
add(img.get("originimgurl"), img.get("cpyrhtDivCd"), label=self._plain(img.get("imgname")))
|
|
for row in rooms:
|
|
unit = self._plain(row.get("roomtitle")) or None
|
|
for i in range(1, 6):
|
|
add(row.get(f"roomimg{i}"), row.get(f"cpyrhtDivCd{i}"),
|
|
label=self._plain(row.get(f"roomimg{i}alt")), unit=unit)
|
|
if len(out) >= MAX_MEDIA:
|
|
break
|
|
|
|
if dropped:
|
|
LOG.i(f"[tour_api] 상업적 이용이 허용되지 않은 사진 {dropped}장 제외(공공누리 유형)")
|
|
return out[:MAX_MEDIA]
|