최상단을 프로젝트 단위로 평평하게 둔다 — 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
242 lines
10 KiB
Python
242 lines
10 KiB
Python
"""채널 URL 후보를 믿어도 되는지 판정하는 자리.
|
|
|
|
Perplexity 가 돌려준 URL 목록에서 **상세 페이지가 아닌 것과 후기 블로그를 걸러낸다.**
|
|
외부 호출이 없는 순수 함수라 실호출 없이 테스트할 수 있다.
|
|
|
|
★ 왜 호출 코드에서 떼어냈나
|
|
"왜 이 URL 이 빠졌나 / 왜 이 쓰레기가 남았나" 는 필터 규칙 문제지 HTTP 문제가 아니다.
|
|
규칙만 따로 읽고 따로 고칠 수 있어야 한다(services/llm/__init__.py 의 겹 설명 참조).
|
|
|
|
★ 조용히 버리지 않는다. 걸러낸 것은 전부 (url, 사유) 로 돌려주고, 호출측이 로그와
|
|
운영 화면에 싣는다 — 필터가 과해서 진짜 채널을 버렸는지 사람이 판단할 수 있어야 한다.
|
|
"""
|
|
import json
|
|
from dataclasses import dataclass
|
|
from urllib.parse import urlparse
|
|
|
|
from common.enums import LinkChannel
|
|
from common.logger import LOG
|
|
|
|
# 검토할 오탐 후보가 과도하게 늘지 않도록 제한한다.
|
|
MAX_LINKS = 6
|
|
MAX_LINKS_PER_CHANNEL = 3
|
|
|
|
|
|
# URL 호스트 → 채널 코드. 접미사 매칭이라 서브도메인(place.naver.com 등)도 잡힌다.
|
|
_HOST_CHANNEL = (
|
|
("yanolja.com", LinkChannel.YANOLJA),
|
|
("goodchoice.kr", LinkChannel.GOODCHOICE),
|
|
("yeogi.com", LinkChannel.GOODCHOICE),
|
|
("place.naver.com", LinkChannel.NAVER_PLACE),
|
|
("naver.me", LinkChannel.NAVER_PLACE),
|
|
("m.place.naver.com", LinkChannel.NAVER_PLACE),
|
|
# 사람이 실제로 공유하는 주소가 이 호스트다(map.naver.com/p/entry/place/...).
|
|
# 블로그·카페는 위 _NAVER_BLOG_HOSTS 가 먼저 걸러내므로 여기 넣어도 후기가 섞이지 않는다.
|
|
("map.naver.com", LinkChannel.NAVER_PLACE),
|
|
("instagram.com", LinkChannel.INSTAGRAM),
|
|
)
|
|
|
|
# 네이버 도메인 중 블로그·카페는 플레이스가 아니다 — 채널 판정을 분리한다.
|
|
_NAVER_BLOG_HOSTS = (
|
|
# ★ 안내·도움말 페이지. 가게 채널이 아니다 — 실측으로 '도플로' 수집에
|
|
# pages.map.naver.com/useful-tips 가 유일한 네이버 링크로 등록돼 사진 0장이 됐다.
|
|
"pages.map.naver.com", "help.naver.com", "guide.naver.com",
|
|
"blog.naver.com", "cafe.naver.com", "m.blog.naver.com", "m.cafe.naver.com",
|
|
"post.naver.com", "m.post.naver.com", "in.naver.com",
|
|
)
|
|
|
|
|
|
# ── 상세 페이지 판정 ──────────────────────────────────────────────────────
|
|
# ★ 실측(2026-08-27 '핑크비치펜션', 발견 15건)에서 확인된 쓰레기 유형:
|
|
# https://nol.yanolja.com/ 루트 — 이 가게와 무관
|
|
# https://www.goodchoice.kr/ 루트
|
|
# https://nol.yanolja.com/sub-home/pension 카테고리(업종 목록)
|
|
# https://nol.yanolja.com/programmatic/... SEO 랜딩
|
|
# https://blog.naver.com/... 후기 4건
|
|
# 이런 URL 을 확정 대기 목록에 남기면 사람이 15건을 일일이 봐야 한다.
|
|
|
|
# 상세 페이지일 수 없는 경로 첫 조각. 숫자 ID 가 뒤에 붙어도 상세가 아니다(SEO 랜딩·목록).
|
|
_NON_DETAIL_PREFIXES = frozenset({
|
|
"sub-home", "sub_home", "programmatic", "search", "event", "promotion",
|
|
"category", "theme", "curation", "magazine", "notice", "help", "login", "signup",
|
|
})
|
|
|
|
# 상세 URL 이 숫자 ID 를 갖지 않는 예외 호스트(단축 링크·지도).
|
|
_ID_OPTIONAL_HOSTS = ("naver.me", "map.naver.com", "instagram.com")
|
|
|
|
# 상세 판정에 숫자 ID 를 요구하는 채널. OTA 상세는 항상 숫자 ID 를 갖는다.
|
|
_ID_REQUIRED_CHANNELS = (LinkChannel.YANOLJA, LinkChannel.GOODCHOICE, LinkChannel.NAVER_PLACE)
|
|
|
|
# 탈락 사유 코드 — 운영자가 "왜 빠졌나"를 보는 값이라 문자열을 고정한다.
|
|
REASON_NON_HTTP = "non_http"
|
|
REASON_DUPLICATE = "duplicate"
|
|
REASON_BLOG = "blog"
|
|
REASON_ROOT = "root"
|
|
REASON_CATEGORY = "category"
|
|
REASON_OVERFLOW = "overflow" # 상한 초과로 잘림 — 버린 게 아니라 순위가 밀린 것
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DiscoveredLink:
|
|
"""발견된 채널 URL 1건. **사실이 아니라 '여기를 보라'는 포인터다.**"""
|
|
|
|
url: str
|
|
channel: LinkChannel
|
|
title: str | None = None
|
|
|
|
|
|
def classify_url(url: str) -> LinkChannel:
|
|
"""URL → 채널 코드. 모르는 도메인은 ETC 로 떨어뜨린다(버리지 않는다)."""
|
|
try:
|
|
host = (urlparse(url).hostname or "").lower()
|
|
except (ValueError, AttributeError):
|
|
return LinkChannel.ETC
|
|
if not host:
|
|
return LinkChannel.ETC
|
|
|
|
# 블로그·카페는 naver.com 접미사에 걸리기 전에 먼저 걸러낸다.
|
|
if any(host == h or host.endswith("." + h) for h in _NAVER_BLOG_HOSTS):
|
|
return LinkChannel.BLOG
|
|
|
|
for suffix, channel in _HOST_CHANNEL:
|
|
if host == suffix or host.endswith("." + suffix):
|
|
return channel
|
|
return LinkChannel.ETC
|
|
|
|
|
|
def _path_segments(url: str) -> list[str]:
|
|
"""URL 경로를 조각으로 나눈다. 빈 조각은 버린다."""
|
|
try:
|
|
path = urlparse(url).path or ""
|
|
except (ValueError, AttributeError):
|
|
return []
|
|
return [seg for seg in path.split("/") if seg]
|
|
|
|
|
|
def _detail_reject_reason(url: str, channel: LinkChannel) -> str | None:
|
|
"""이 URL 이 '이 가게의 상세 페이지' 인가. 아니면 탈락 사유를, 맞으면 None 을 돌려준다.
|
|
|
|
★ 판정을 못 하겠으면 통과시킨다(사람이 확정 단계에서 본다). 여기서 과하게 버리면
|
|
진짜 채널을 잃는데, 그건 필터가 없는 것보다 나쁘다.
|
|
"""
|
|
segments = _path_segments(url)
|
|
|
|
# 루트('/' 또는 경로 없음)는 업소와 무관한 서비스 첫 화면이다.
|
|
if not segments:
|
|
return REASON_ROOT
|
|
|
|
# SEO 랜딩·목록 경로는 숫자 ID 가 뒤에 붙어도 상세가 아니다.
|
|
if segments[0].lower() in _NON_DETAIL_PREFIXES:
|
|
return REASON_CATEGORY
|
|
|
|
try:
|
|
host = (urlparse(url).hostname or "").lower()
|
|
except (ValueError, AttributeError):
|
|
host = ""
|
|
# 단축 링크(naver.me)·지도·인스타 프로필은 숫자 ID 를 갖지 않는다 — 경로만 있으면 통과.
|
|
if any(host == h or host.endswith("." + h) for h in _ID_OPTIONAL_HOSTS):
|
|
return None
|
|
|
|
# OTA 상세 URL 은 항상 숫자 ID 를 갖는다. 없으면 목록·카테고리다.
|
|
if channel in _ID_REQUIRED_CHANNELS:
|
|
if not any(seg.isdigit() for seg in segments):
|
|
return REASON_CATEGORY
|
|
|
|
return None
|
|
|
|
|
|
def filter_links(links: list[DiscoveredLink], include_blogs: bool) -> tuple[list[DiscoveredLink], list[tuple[str, str]]]:
|
|
"""발견된 URL 을 '이 가게의 상세 페이지' 인 것만 남긴다.
|
|
|
|
★ 전부 걸러지면 빈 목록을 돌려준다. 억지로 하나 남기지 않는다 —
|
|
'못 찾음'은 정상 결과이고, 사장님 직접 입력으로 폴백한다.
|
|
"""
|
|
kept: list[DiscoveredLink] = []
|
|
dropped: list[tuple[str, str]] = []
|
|
per_channel: dict[LinkChannel, int] = {}
|
|
for link in links:
|
|
# 블로그·카페는 채널이 아니라 후기다. 크롤링해도 이 가게의 공식 정보가 아니다.
|
|
if link.channel is LinkChannel.BLOG and not include_blogs:
|
|
dropped.append((link.url, REASON_BLOG))
|
|
continue
|
|
reason = _detail_reject_reason(link.url, link.channel)
|
|
if reason:
|
|
dropped.append((link.url, reason))
|
|
continue
|
|
# ★ 상한 — 같은 채널에서 서로 다른 업소가 우수수 딸려오는 것을 막는다.
|
|
# links 는 '모델이 고른 것 → search_results' 순으로 들어오므로 앞쪽이 더 믿을 만하다.
|
|
used = per_channel.get(link.channel, 0)
|
|
if used >= MAX_LINKS_PER_CHANNEL or len(kept) >= MAX_LINKS:
|
|
dropped.append((link.url, REASON_OVERFLOW))
|
|
continue
|
|
per_channel[link.channel] = used + 1
|
|
kept.append(link)
|
|
return kept, dropped
|
|
|
|
|
|
def _parse_content_links(content: str) -> list[dict]:
|
|
"""구조화 출력(JSON) 본문에서 links 배열을 꺼낸다. 깨져 있으면 빈 목록."""
|
|
if not content:
|
|
return []
|
|
try:
|
|
doc = json.loads(content)
|
|
except (json.JSONDecodeError, TypeError):
|
|
LOG.w("[perplexity] 구조화 출력 파싱 실패 — search_results 만 사용한다")
|
|
return []
|
|
links = doc.get("links")
|
|
return links if isinstance(links, list) else []
|
|
|
|
|
|
def collect_links(payload: dict) -> tuple[list[DiscoveredLink], list[tuple[str, str]]]:
|
|
"""모델이 고른 links + search_results(실제 검색 출처)를 합쳐 URL 목록을 만든다.
|
|
|
|
search_results 를 함께 쓰는 이유: 모델 답변은 환각이 섞이지만 search_results 는
|
|
실제로 조회된 URL 이라 더 믿을 만하다. 어차피 다음 단계(동일 업소 검증)가 걸러낸다.
|
|
|
|
반환: (URL 목록, [(버린 URL, 사유), ...]). 중복·비 http 도 사유로 남긴다."""
|
|
seen: set[str] = set()
|
|
out: list[DiscoveredLink] = []
|
|
dropped: list[tuple[str, str]] = []
|
|
|
|
def _add(url, title):
|
|
if not isinstance(url, str):
|
|
return
|
|
url = url.strip()
|
|
if not url:
|
|
return
|
|
if url in seen:
|
|
dropped.append((url, REASON_DUPLICATE))
|
|
return
|
|
if not url.lower().startswith(("http://", "https://")):
|
|
dropped.append((url, REASON_NON_HTTP))
|
|
return
|
|
seen.add(url)
|
|
out.append(DiscoveredLink(url=url, channel=classify_url(url), title=(title or None)))
|
|
|
|
choices = payload.get("choices") or []
|
|
content = ""
|
|
if choices and isinstance(choices[0], dict):
|
|
content = ((choices[0].get("message") or {}).get("content")) or ""
|
|
for row in _parse_content_links(content):
|
|
if isinstance(row, dict):
|
|
_add(row.get("url"), row.get("title"))
|
|
|
|
for row in payload.get("search_results") or []:
|
|
if isinstance(row, dict):
|
|
_add(row.get("url"), row.get("title"))
|
|
|
|
return out, dropped
|
|
|
|
|
|
def search_count(payload: dict) -> int:
|
|
"""이번 호출의 검색 횟수. usage 에 없으면 search_results 개수로 대체한다."""
|
|
usage = payload.get("usage") or {}
|
|
for key in ("num_search_queries", "search_queries", "num_searches"):
|
|
value = usage.get(key)
|
|
if isinstance(value, int):
|
|
return value
|
|
results = payload.get("search_results")
|
|
return len(results) if isinstance(results, list) else 0
|
|
|