최상단을 프로젝트 단위로 평평하게 둔다 — 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
224 lines
9.3 KiB
TypeScript
224 lines
9.3 KiB
TypeScript
/**
|
|
* 구조화 데이터 ↔ 화면 값 대조. **★ 절대규칙 3 을 코드로 강제하는 자리다.**
|
|
*
|
|
* JSON-LD 는 AI 검색이 읽는 값이고 화면은 사람이 읽는 값이다. 둘이 다르면
|
|
* '검색엔진에만 다른 말을 하는' 상태가 된다 — 클로킹으로 취급될 수 있고, 무엇보다 거짓이다.
|
|
*
|
|
* ★ 왜 여기(렌더러 안)에 있나
|
|
* 예전에는 백엔드가 **자기만의 HTML 을 따로 구워** 그걸 대조했다. 방문자가 보는 페이지는
|
|
* 이 렌더러가 굽는데, 검사는 아무도 안 보는 페이지에 대고 하고 있었다 — 두 렌더러가
|
|
* 어긋나는 순간 게이트는 통과인데 실제 페이지는 다른 내용이 된다.
|
|
* 검사는 **나갈 바로 그 HTML** 에 대고 해야 의미가 있다.
|
|
*
|
|
* ★ 검사 방식
|
|
* JSON-LD 의 모든 스칼라 값이 화면 텍스트(또는 src/href)에 나타나는지 본다.
|
|
* 나타나지 않으면 "화면에 없는 것을 구조화 데이터가 주장하고 있다" 는 뜻이고, 그게 거짓이다.
|
|
*/
|
|
import type {Json} from './jsonld';
|
|
|
|
/** 화면에 텍스트로 나타나지 않는 구조·메타 값. 대조 대상에서 제외한다. */
|
|
const STRUCTURAL = new Set([
|
|
'@context',
|
|
'@type',
|
|
'@id',
|
|
'priceCurrency',
|
|
'addressCountry',
|
|
'inLanguage',
|
|
// 좌표는 지도 핀용 메타지 본문에 쓸 값이 아니다. 대신 payload 와 직접 대조한다(verifyGeo).
|
|
'latitude',
|
|
'longitude',
|
|
]);
|
|
|
|
/** 이름과 값을 따로 대조하는 서브트리. 일반 순회에서는 건너뛴다. */
|
|
const SPECIAL_SUBTREE = new Set(['amenityFeature', 'makesOffer', 'geo']);
|
|
|
|
/**
|
|
* **페이지를 설명하는** 노드. 본문 대조 대상이 아니다.
|
|
*
|
|
* ★ 이게 왜 필요한가
|
|
* WebPage 의 name·description·datePublished 는 `<title>` 과 `<meta>` 로 나가는 값이지
|
|
* 본문에 찍히는 문장이 아니다. 이걸 본문에서 찾으면 멀쩡한 사이트가 전부 실패한다.
|
|
* 대조해야 할 것은 **사업장에 대한 주장**(상호·주소·전화·가격·편의시설)과 FAQ 문답이다.
|
|
* 그것들이 화면에 없으면서 구조화 데이터에만 있으면 그게 거짓이다.
|
|
*/
|
|
const PAGE_META_TYPES = new Set(['WebPage', 'WebSite', 'BreadcrumbList', 'ImageObject']);
|
|
|
|
/**
|
|
* **합성값** 속성. 통째로는 화면에 없고, 구성 요소가 각각 화면에 있으면 통과다.
|
|
*
|
|
* 예: priceRange `'20,000원 ~ 60,000원'` 는 객실 요금에서 계산한 요약이다. 화면에는
|
|
* `20,000원` 과 `60,000원` 이 각 객실 카드에 따로 나온다 — 두 끝값이 다 보이면
|
|
* 그 범위는 화면이 뒷받침하는 주장이다. 통짜 비교를 고집하면 사실인 값이 발행을 막는다.
|
|
* ★ 대신 **구성 요소가 하나라도 화면에 없으면 실패**다 — 검사를 느슨하게 하는 게 아니다.
|
|
*/
|
|
const COMPOSITE_PROPS = new Set(['priceRange']);
|
|
|
|
/** 합성값을 구성 요소로 쪼갠다(범위 구분자·쉼표 기준). 빈 조각은 버린다. */
|
|
function splitComposite(token: string): string[] {
|
|
return token
|
|
.split(/\s*[~–—]\s*|\s+-\s+/)
|
|
.map((part) => part.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
const SCRIPT_RE = /<script\b[^>]*>[\s\S]*?<\/script>/gi;
|
|
const STYLE_RE = /<style\b[^>]*>[\s\S]*?<\/style>/gi;
|
|
const TAG_RE = /<[^>]+>/g;
|
|
|
|
const ENTITIES: Record<string, string> = {
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
''': "'",
|
|
' ': ' ',
|
|
};
|
|
|
|
function unescapeHtml(value: string): string {
|
|
return value
|
|
.replace(/&(?:amp|lt|gt|quot|#39|nbsp);/g, (m) => ENTITIES[m] ?? m)
|
|
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
|
|
.replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(parseInt(code, 16)));
|
|
}
|
|
|
|
/**
|
|
* 화면에 실제로 보이는 텍스트.
|
|
*
|
|
* ★ script(= JSON-LD 자신)와 style 을 먼저 걷어낸다. 이걸 안 하면 JSON-LD 가
|
|
* 자기 자신을 근거로 통과해버려 검사가 통째로 무의미해진다.
|
|
*/
|
|
export function visibleText(html: string): string {
|
|
const body = html.replace(SCRIPT_RE, ' ').replace(STYLE_RE, ' ').replace(TAG_RE, ' ');
|
|
return unescapeHtml(body).replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
/** 리스트 인덱스를 건너뛴 실제 속성 이름. */
|
|
function propName(path: string[]): string {
|
|
for (let i = path.length - 1; i >= 0; i -= 1) {
|
|
if (!/^\d+$/.test(path[i])) return path[i];
|
|
}
|
|
return path[path.length - 1] ?? '';
|
|
}
|
|
|
|
type Scalar = string | number | boolean;
|
|
|
|
function* iterScalars(node: unknown, path: string[] = []): Generator<[string[], Scalar]> {
|
|
if (Array.isArray(node)) {
|
|
for (let i = 0; i < node.length; i += 1) yield* iterScalars(node[i], [...path, String(i)]);
|
|
return;
|
|
}
|
|
if (node && typeof node === 'object') {
|
|
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
|
|
if (SPECIAL_SUBTREE.has(key)) continue;
|
|
yield* iterScalars(value, [...path, key]);
|
|
}
|
|
return;
|
|
}
|
|
if (node === null || node === undefined) return;
|
|
yield [path, node as Scalar];
|
|
}
|
|
|
|
/** 숫자를 화면 표기(1,000 단위 구분)로. 화면이 그렇게 그리므로 대조도 같은 표기를 본다. */
|
|
function asShown(value: Scalar): string[] {
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
return [String(value), value.toLocaleString('ko-KR')];
|
|
}
|
|
return [String(value)];
|
|
}
|
|
|
|
/**
|
|
* JSON-LD 값이 전부 화면에 나타나는지 대조한다. 불일치 목록을 돌려준다(비면 통과).
|
|
*
|
|
* @param html 실제로 나갈 페이지 HTML
|
|
* @param nodes 그 페이지에 실린 JSON-LD 노드 전부
|
|
*/
|
|
export function verifyJsonLd(html: string, nodes: Json[]): string[] {
|
|
const text = visibleText(html);
|
|
const problems: string[] = [];
|
|
|
|
const seen = new Set<string>();
|
|
const report = (message: string) => {
|
|
// 같은 값이 여러 노드에 반복되면(name 은 사업장·WebSite·WebPage 에 다 있다)
|
|
// 같은 사유가 여러 번 쌓인다 — 운영자가 볼 목록이지 통계가 아니다.
|
|
if (seen.has(message)) return;
|
|
seen.add(message);
|
|
problems.push(message);
|
|
};
|
|
|
|
for (const node of nodes) {
|
|
// 페이지 메타 노드는 head 로 나가는 값이라 본문에 없다 — 대조하면 전부 오탐이 된다.
|
|
if (PAGE_META_TYPES.has(String(node['@type'] ?? ''))) continue;
|
|
|
|
for (const [path, value] of iterScalars(node)) {
|
|
const prop = propName(path);
|
|
if (STRUCTURAL.has(prop) || value === '' || typeof value === 'boolean') continue;
|
|
|
|
const token = String(value);
|
|
// URL·이미지는 본문 텍스트가 아니라 요소 속성(src/href)에 있다.
|
|
if (token.startsWith('http://') || token.startsWith('https://')) {
|
|
if (!html.includes(token)) report(`${prop}: '${token}' 이 화면에 없다`);
|
|
continue;
|
|
}
|
|
if (asShown(value).some((shown) => text.includes(shown))) continue;
|
|
|
|
if (COMPOSITE_PROPS.has(prop)) {
|
|
// 구성 요소가 전부 화면에 있으면 통과. 하나라도 없으면 그 조각을 사유로 남긴다.
|
|
const parts = splitComposite(token);
|
|
const missing = parts.filter((part) => !text.includes(part));
|
|
if (parts.length > 1 && missing.length === 0) continue;
|
|
report(`${prop}: '${missing.join(' · ') || token}' 이 화면에 없다`);
|
|
continue;
|
|
}
|
|
report(`${prop}: '${token}' 이 화면에 없다`);
|
|
}
|
|
|
|
// 편의시설·가격은 이름과 값을 따로 본다(구조가 name/value 쌍이라 일반 순회로는 약하다).
|
|
for (const feature of asArray(node.amenityFeature)) {
|
|
const name = String(feature?.name ?? '');
|
|
if (name && !text.includes(name)) report(`amenityFeature[${name}]: 화면에 없다`);
|
|
}
|
|
for (const offer of asArray(node.makesOffer)) {
|
|
const name = String(offer?.name ?? '');
|
|
if (name && !text.includes(name)) report(`makesOffer[${name}]: 화면에 없다`);
|
|
const price = offer?.price;
|
|
if (price !== undefined && price !== null && !asShown(price as Scalar).some((s) => text.includes(s))) {
|
|
report(`makesOffer[${name}].price: '${price}' 이 화면에 없다`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return problems;
|
|
}
|
|
|
|
function asArray(value: unknown): Record<string, unknown>[] {
|
|
if (Array.isArray(value)) return value.filter((v) => v && typeof v === 'object');
|
|
if (value && typeof value === 'object') return [value as Record<string, unknown>];
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 좌표가 payload 와 같은지. 지도 핀이 엉뚱한 데 찍히는 것도 거짓 정보다.
|
|
*
|
|
* 좌표는 화면 텍스트로 나오지 않아 본문 대조로는 확인할 수 없다 —
|
|
* 검사에서 빼는 것과 검사를 안 하는 것은 다르므로, 원본과 직접 맞춘다.
|
|
*/
|
|
export function verifyGeo(nodes: Json[], latitude?: number, longitude?: number): string[] {
|
|
const problems: string[] = [];
|
|
const TOLERANCE = 1e-6;
|
|
|
|
for (const node of nodes) {
|
|
const geo = node.geo as Record<string, unknown> | undefined;
|
|
if (!geo) continue;
|
|
const pairs: [string, unknown, number | undefined][] = [
|
|
['latitude', geo.latitude, latitude],
|
|
['longitude', geo.longitude, longitude],
|
|
];
|
|
for (const [name, shown, source] of pairs) {
|
|
if (source === undefined || typeof shown !== 'number' || Math.abs(shown - source) > TOLERANCE) {
|
|
problems.push(`geo.${name}: 구조화 데이터 ${String(shown)} != payload ${String(source)}`);
|
|
}
|
|
}
|
|
}
|
|
return problems;
|
|
}
|