# Website Generator — 구현 계획

> 상태: **결정 18건 확정 · 미측정 2건 · 구현 완료 · 실호출 검증 완료(2026-08-11)**
> 분류·추출 규격은 `social-url-structure-audit` 에 있고 여기서는 **인용만 한다**.
> 이 문서는 그 규격을 코드 계약으로 옮기고, 무엇을 테스트로 강제할지를 정한다.

## 근거 문서 (복제하지 않는다)

| 문서 | 이 작업에 주는 것 |
|---|---|
| [`sources/website-pipeline.html`](../../audit-sources/website-pipeline.html) | 이벤트에서 저장까지 전 과정 · 단계별 확정 상태 |
| [`sources/website-kinds.html`](../../audit-sources/website-kinds.html) | URL 종류 5종 · 도메인 목록 · 패턴 배정 |
| [`sources/website-content-mapping.html`](../../audit-sources/website-content-mapping.html) | 패턴 7개 · 필드별 폴백 체인 · 판정 캐스케이드 |
| [`sources/website-image.html`](../../../docs/features/social-url-structure-audit/sources/website-image.html) | 도메인 지문 17필드의 실측 근거 (3분할 일관 신호) |
| [`qna/website.md`](../../audit-sources/website-qna.md) | 결정 기록 12건 |

데이터: `scaffold/website-{kinds,extract,extract-samples,patterns,rss}.json`

---

## 0. 범위

- `SocialPlatform.WEB` 으로 떨어진 URL 을 **`Content` 하나**로 만든다.
- 페이지 정보(`text` · `publishedAt` · `mediaUrls`)는 최상위로, **도메인 지문 17필드는 `data{}` 로** 간다.
- `tokens.web{}` 는 **폐기한다**(Q11).
- `Account` 와 `Venue` 는 만들지 않는다 — 웹에는 작성자도 장소도 없다.

**이번 라운드에서 빼는 것**

- 소유권 판정 5갈래(본문 계약주소 대조) — 규격은 Q6·Q7 에 있으나 미확정
- ~~`web_social` 526건의 실제 fetcher~~ → **W-18 에서 갈렸다.** `facebook` 186 · `threads` 7 은 부르면 나와서 참조로 옮겼고, `truthsocial` 79 는 URL 에서만 읽는다. 남은 것은 `pinterest` 25 처럼 아무것도 못 주는 것들이다
- `image:` 지문 — 토큰 이미지는 아직 수집 자체가 없다

---

## 1. 이 소셜만 다른 것 넷

**① 호스트를 소유하지 않는다.** `WebsiteFetcher.hosts` 가 빈 배열이다. 담당 소셜을 못 찾은 URL 이 여기로 떨어지는 **catch-all** 이라, 다른 7소셜처럼 "이 호스트는 내 것" 이라고 선언할 수 없다.

**② 종류가 URL 이 아니라 도메인 목록으로 갈린다.** 다른 소셜은 경로 패턴으로 `sourceType` 이 나오는데(`/status/{id}` → `x_tweet`), 웹은 **도메인이 무엇이냐**로 갈린다. 그래서 `classify()` 가 목록을 참조해야 한다.

**③ 값의 출처가 사이트마다 다르다.** 다른 소셜은 API 응답 스키마가 하나다. 웹은 같은 `text` 를 `og:title` 에서 받는 곳이 35곳, `jsonld:headline` 이 5곳, `meta:title` 이 1곳이다. **폴백 체인이 필드마다 따로** 필요한 유일한 소셜이다.

**④ 지문이 콘텐츠보다 검증돼 있다.** `isNews` · 도메인 731일+ · `hasTracking` · `techCount` 4개+ 는 세 시간분할 일관 신호다. 반면 `text` 는 이번에 처음 뽑는다. **덜 검증된 것을 최상위에, 더 검증된 것을 `data{}` 에** 두는 배치라, 그 이유가 문서에 남아야 한다(Q11).

---

## 2. 구조

```
Router              정규화 · host 조회 실패 → WEB catch-all
   ↓
WebsiteFetcher      classify(ParsedUrl) → SocialRoute<WebsiteSourceType>   순수·무료
                    fetchSite(url, apex, opts) → WebsiteInfo | null
                      ├ HTML GET 1회          — 표준 8종 파싱
                      ├ RDAP                  — web_site 에만
                      ├ urlscan (search)      — web_site 에만
                      └ RSS 1회               — text_body·published 가 빌 때만
   ↓
WebsiteGenerator    WebsiteInfo → SocialObjectGroup { content } | null
```

**묶음은 0개 아니면 1개다.** `Account` 도 `Venue` 도 만들지 않으므로 사슬 조립·`parentRef`·순환 방어가 전부 해당되지 않는다. Telegram 과 같은 이유로 X 에서 가장 어려웠던 부분이 여기서는 없다.

### 🔑 축이 둘이다 — `sourceType` 과 패턴은 시점도 입력도 다르다

**이 생성기를 이해하는 핵심이다.** 둘을 한 축으로 착각하면 순서가 뒤집히고, 그러면 호출 절감이 통째로 사라진다.

| | `sourceType` (6종) | 패턴 (P1~P7) |
|---|---|---|
| 묻는 것 | **어디서 온 URL 인가** | **받아보니 뭐가 있나** |
| 입력 | 도메인 문자열만 | 응답의 표준 8종 |
| 시점 | **네트워크 전** | **네트워크 후** |
| 가르는 것 | **부를지 말지** | **저장할지 말지** |
| 정하는 곳 | `classify()` — 순수·무료 | 캐스케이드 R1~R7 |

**스텝**

1. 라우터가 정규화하고, 담당 소셜을 못 찾으면 `WEB` 으로 떨군다
2. `classify()` 가 **도메인만 보고** `sourceType` 을 정한다 — 이 시점에 패턴은 알 수 없다
3. `sourceType` 이 **호출을 가른다** — `web_tool`·`web_social` 은 여기서 끝나므로 **패턴 판정 자체가 없다**
4. fetch → 표준 8종을 **전부 먼저** 파싱한다
5. 캐스케이드가 **값만 보고** 패턴을 정한다 — `sourceType` 을 참조하지 않는다
6. 패턴이 **저장을 가른다** — P1~P3 은 Content, P4~P7 은 사유만
7. `sourceType` 이 다시 한 번 — `web_site` 에만 `data{}` 지문이 붙는다

**둘은 직교한다.** 같은 `sourceType` 안에 여러 패턴이 나온다. 조사 실측 격자다.

```
              P1   P2   P3   P4   P5   P6   P7
reference     33   14    6    7    ·    ·    ·
site           3   10    2    5    2    1    5
social         4    1    4    9    2    1    ·
tool           1    3    ·    4    ·    ·    ·
directory      ·    3    1    3    ·    ·    ·
```

`web_reference` 60개가 넷으로 흩어진다. **`sourceType` 만으로는 무엇을 채울지 못 정한다.**

⚠️ `tool`·`social` 행에 패턴이 있는 것은 **조사 흔적**이다. 조사에서는 전부 받아봤고, 그 결과 P4 가 최다로 나와서 *"호출해도 얻는 게 없다"* 가 확인됐다. 그래서 3번에서 안 부르기로 정한 것이고, **런타임에서는 저 두 행이 생기지 않는다.**

🔴 **순서를 뒤집으면 안 된다.** 패턴으로 `sourceType` 을 정하려면 전부 fetch 해야 하고, 그러면 42.6% 를 호출 전에 거르는 이득이 사라진다. `truthsocial` 79건처럼 얻는 게 0인 곳도 매번 부르게 된다.

**호출 수가 종류마다 다르다.** 이것이 이 생성기의 핵심 분기다.

| 종류 | HTML | RDAP | urlscan | RSS | P1~P3 | P4~P7 |
|---|:--:|:--:|:--:|:--:|---|---|
| `web_tool` · `web_social` | — | — | — | — | 해당 없음 | 해당 없음 |
| `web_directory` · `web_reference` | ○ | — | — | 조건부 | `text` Content | **없음** |
| `web_site` | ○ | ○ | ○ | 조건부 | `text` + 지문 Content | **지문만 Content** (W-15) |

**오른쪽 두 칸이 2×2 격자다.** `sourceType` 이 지문 여부를, 패턴이 텍스트 여부를 정한다.

---

## 3. 결정 로그

확정된 것만 적는다. 미확정은 §8 에 있다.

| ID | 결정 | 근거 |
|---|---|---|
| **W-1** | `platform_key` = **URL**. apex 도메인이 아니다. 우선순위는 `rel=canonical` → 리다이렉트 최종 URL → 원본 정규화 URL | 경로 있는 링크가 최소 51.7% 이고, 공유 도메인 키의 33.3% 가 서로 다른 페이지를 한 키에 눌러담고 있었다. apex 로 잡으면 `coincommunities.org` 112개 페이지가 1행이 된다.<br>canonical 을 앞에 두는 이유는 **사이트가 스스로 선언한 정답**이라서다 — 리다이렉트 최종 URL 은 추적 파라미터가 붙거나 A/B URL 로 튈 수 있다. 커버리지도 70.8% 로 둘째로 높다. `en.m.wikipedia.org` → `en.wikipedia.org` 301 을 실측했다.<br>🔴 **키가 fetch 후에 확정된다.** `classify()` 가 낸 `sourceKey` 와 최종 `platformKey` 가 다를 수 있다는 것을 계약에 명시한다 |
| **W-2** | `sourceType` 을 **5종**으로 나눈다 — `web_tool` · `web_social` · `web_directory` · `web_reference` · `web_site`<br>(W-18 로 `web_social_post` 가 더해져 지금은 6종이다) | 종류를 가르면 42.6% 가 호출 전에 걸러지고 57.4% 에만 필드가 필요하다. 다른 7소셜과 구조도 같아진다 |
| **W-3** | **도메인별 CSS 셀렉터를 만들지 않는다.** 표준 8종만 본다 | 사이트가 표준을 유지할 유인이 따로 있다 — 검색 리치결과와 SNS 공유 미리보기가 깨지면 사이트 주인이 손해를 본다. 셀렉터에는 그 유인이 없어 리뉴얼 한 번에 깨지고, 180개를 유지할 방법도 없다 |
| **W-4** | 규칙은 **도메인 단위가 아니라 패턴 단위**(P1~P7) | 도메인별 규칙은 124개가 되고 새 도메인이 오면 안 돈다. 패턴은 7개이고 배정만 하면 된다 |
| **W-5** | **패턴은 도메인에 고정하지 않는다.** 런타임 판정은 받은 값으로 한다 | 같은 도메인인데 URL 경로에 따라 갈리는 것이 19건이다(`fomo.family` 프로필은 site-wide, 블로그는 article). 배정표는 기대값·검수용이다 |
| **W-6** | **필드마다 폴백 체인이 다르다** | `text` 는 `og:title` 35/41 로 OpenGraph 가 압도하는데 `published` 는 `jsonld:datePublished` 32 대 `article:published_time` 9 로 JSON-LD 가 이긴다. 하나의 체인으로 묶으면 한쪽이 반드시 틀린다 |
| **W-7** | 판정은 **순서가 있는 캐스케이드**(R1~R7) | 못 쓰는 경우를 먼저 걸러내고 쓸 수 있는 것만 등급을 매긴다. 뒤집으면 로그인 안내문이 *"제목과 요약이 다 있다"* 로 읽혀 P1 에 들어간다 |
| **W-8** | 결측을 **넷으로 가른다** — `site_wide` · `gated` · `unseen` · `placeholder` | 앞 셋은 "정보 없음" 이고 `placeholder` 만 정보다. 컨텐츠가 빈 토큰사이트가 소유권 조사에서 계약주소를 안 채운 도메인과 정확히 겹쳤다. 두 조사는 서로를 몰랐다 |
| **W-9** | **도메인 지문 17필드는 `Content.data{}`**. `tokens.web{}` 폐기 | `ContentDataInput` 의 정의가 *"조회축이 아닌 값"* 이라 맞고, 저장소가 하나로 준다. 복제는 오히려 이득이다 — `resolveContent` 가 갱신을 안 해서(G-6) 한 행에 모으면 `mutable` 필드의 첫 시점이 영구 고착된다. 상세는 Q11 |
| **W-10** | `fingerprints[]` 에서 **`domain:` 을 뺀다.** `image:` 만 남긴다 | W-9 의 따름. 그리고 지금대로면 `axiom.trade` 310토큰 · `knowyourmeme` 117토큰이 지문에 들어가 *"같은 팀"* 이 아니라 *"같은 거래소를 쓴다"* 를 세게 된다 |
| **W-11** | **RSS 는 결측 보정으로만** 부른다. 항목 대조는 **파이프라인의 `normalizeUrl` 을 그대로 쓴다** | `text_body` 나 `published` 가 빈 경우에만 도메인당 1회. P1·P2 는 호출이 0회 그대로다. `dailymail` 기사에서 OpenGraph 가 통째로 비었는데 피드에는 제목·요약·발행시각이 다 있었다.<br>🔴 **대조에서 쿼리를 통째로 떼면 안 된다**(2026-08-11 실호출). 워드프레스 기본 퍼머링크가 `?p=1` 이라 항목 링크가 `https://site.lol/?p=1` 인데, 쿼리를 떼면 경로가 `/` 만 남아 **사이트 홈과 같은 키**가 된다. 홈을 건 토큰이 그 사이트의 최신 글을 자기 내용으로 가져갔고, 그것이 이 대조가 막으려던 바로 그 사고다. 더 나쁜 것은 **`unfilled` 신호가 사라진다**는 점이다 — 새로 깐 워드프레스는 *"Hello world!"* 를 항상 갖고 있어서 W-8 이 지키려던 집단이 전부 `article` 로 올라간다(실측 `unfilled` 예시 넷 중 셋이 워드프레스였다).<br>피드 34개에서 **항목 링크에 쿼리가 붙은 것은 0건**이라 쿼리를 남기는 대가는 없었다. 그리고 피드만 HTML 엔티티를 안 풀고 있어서 `&amp;` 가 `amp;b` 라는 없는 파라미터로 파싱되고 있었다 |
| **W-12** | `tags` 는 **P1 에서만** 시도하고 기본은 빈 배열 | 최고가 참조 40% 이고 툴·디렉터리는 0% 다. 판정한 에이전트들이 *"site-wide category labels, not topics"* 를 반복해 지적했다 |
| **W-13** | **패턴을 `data.pattern` 에 남긴다** — Content 를 만드는 경우에도 | 안 남기면 **결측이 실패인지 그 패턴의 정상인지** 구분이 안 된다. `P3 title-only` 는 `text.body` 가 비는 것이 정상인데, 기록이 없으면 나중에 *"왜 이 행만 body 가 없지"* 를 답할 수 없다. §6 은 Content 를 **안 만드는** 경우의 사유만 정했고 만드는 경우가 비어 있었다 |
| **W-15** | **`web_site` 는 패턴과 무관하게 지문을 받는다.** P4~P7 이면 **지문만 있는 Content** 를 만든다 | 두 축이 직교한다는 원칙을 그대로 따른다 — `sourceType` 이 지문 여부를, 패턴이 텍스트 여부를 정한다. RDAP 와 urlscan `search` 는 무료라 비용이 0 이고, **안 받으면 영구 유실**이다(러그된 도메인은 재수집해도 값이 달라진다). `web_site` 28개 중 P4~P7 이 13개라 절반이 이 칸에 들어온다. `text` 가 빈 이유는 `data.pattern` 이 설명한다(W-13) |
| **W-16** | **종류 판정 2단(토큰명 대조)은 Generator 로 내린다** | `classify()` 의 *"순수·무료·네트워크 없음"* 은 **8소셜 공유 계약**이라, 토큰 컨텍스트를 받으려고 시그니처를 넓히면 나머지 일곱이 따라오고 그중 어느 것도 토큰명을 안 쓴다. `sourceType` 은 목록만으로 확정하고, 토큰명 대조는 Generator 진입 시점에 **"자기 사이트 확신도"** 를 높이는 보조 신호로만 쓴다 |
| **W-17** | **`tags` 는 계약을 안 바꾼다.** 빈 배열을 기본으로 두고 P1 에서만 채운다 | `ContentInput` 은 8소셜 공유 타입이라 `optional` 로 바꾸면 개방 범위가 넓고, `strictNullChecks: false` 라 컴파일러가 실수를 못 막는다(G-19 에서 확인). *"안 채운 것"* 과 *"비어 있는 것"* 이 둘 다 `[]` 로 보이는 것은 `data.pattern` 이 대신 구분해준다 |
| **W-14** | **목록 우선순위를 명시한다** — `directory` → `tool` → `social` → `reference` → `site` | 한 도메인이 두 목록에 걸린다. `moonshot.com` 과 `bags.fm` 은 런치패드라 툴이면서 동시에 토큰마다 페이지를 주는 디렉터리다. 순서를 안 정하면 **목록에 넣는 순서가 판정을 바꾼다.** 디렉터리를 앞에 두는 이유는 그쪽이 더 좁은 판정이라서다 — 툴 목록은 넓고 디렉터리는 7개뿐이다 |
| **W-18** | **소셜 게시물 URL 은 부르지 않고 행을 만든다** — 새 종류 `web_social_post` · 새 subtype `social_post` | 아래 별도 절. **`web_social` 목록이 두 가지를 뭉치고 있었다** — 부르면 나오는 것과, 안 나오지만 URL 이 값을 담은 것 |

### 질문 없이 둔 가정

- `text.body` 는 **요약으로 충분하다**고 본다. JSON-LD `articleBody` 로 본문 전문이 오는 곳은 11개 도메인이고 전부 뉴스사다. 목적이 *"이 링크가 무슨 주제인가"* 라 요약으로 답이 된다.
- `og:image` 는 **한 개만** 담는다(G-16). `mediaUrls` 의 뜻이 *"콘텐츠에 첨부된 이미지"* 라 여러 개면 첨부 N건으로 읽힌다.
- HTML 엔티티는 이미 `decodeHtmlEntities` 를 거친다(`social-fetcher.util.ts:101`). 표준 메타에도 같은 문제가 있어 실측 44건이 깨져 있었다.

### W-18 — 소셜 게시물 URL (2026-08-11, 실호출로 확정)

착수 당시 `web_social` 20개 도메인은 *"호출해도 얻는 것이 없다"* 로 한 덩어리였다. **실호출로 재보니 세 부류였다.**

| 부류 | 실측 | 결정 |
|---|---|---|
| **부르면 나온다** — 열하나. `facebook` 186 · `twitch` 28 · `4chan` 11 · `qq` 7 · `discord` 7 · `threads` 7 · `bilibili` 6 · `tumblr` 5 · `naver` 5 · `spotify` 2 | `/reel/` 은 `og:description` 이 캡션, `threads` 는 본문(`"Shroud / Kenya, 2023"`), `4chan` 은 og 가 없어도 `<title>` 이 스레드 제목, `discord` 초대는 서버명·멤버수, `spotify` 는 곡명+발매일 | **참조 목록으로 옮긴다.** 새 코드가 필요 없고 기존 캐스케이드가 그대로 돈다 |
| **안 나오는데 URL 이 값을 담았다** — `truthsocial` 79 | 응답은 플랫폼 소개문뿐. 그런데 post id 가 Mastodon 스노플레이크라 상위 48비트가 발행시각이고, 실제 id 셋이 `2026-07-25` · `07-26` · `07-26` 으로 순서까지 맞았다 | **`web_social_post`.** 부르지 않고 URL 에서만 읽어 Content 를 만든다 |
| **아무것도 없다** — `pinterest` 25 | 1.1MB 를 받아도 `<title>` 조차 없는 순수 클라이언트 렌더. `facebookexternalhit` · `Twitterbot` · `Googlebot` 세 UA 로도 같았고, URL 이 `/pin/{숫자}` 라 슬러그도 없다 | **그대로 둔다.** 채울 계약 필드가 하나도 없다 |

**조사가 왜 뭉쳤는지도 설명된다.** 도메인당 샘플 두 개로 판정했는데 그 둘이 서로 다른 URL 종류였다 — `facebook` 은 *"사진 샘플은 og 가 전부 비어"* 라 더 나쁜 쪽으로 통일했다. 조사 자체가 `mixed: true` · confidence `medium`/`low` 로 그 불확실성을 표시해 두고 있었다.

**`tumblr` 과 `discord.com` 은 더 나빴다.** `tumblr` 의 샘플 둘은 **같은 페이지**였다 — 경로가 둘 다 `/desk-drawerr` 이고 차이는 `#:~:text=` 텍스트 조각뿐인데 서버는 그 값을 못 본다. `discord.com` 은 샘플이 루트 한 장뿐이었는데 이 도메인의 실제 링크는 초대 페이지다. 둘 다 배정을 정정했다.

**옮기고 나서 실호출로 두 개가 더 걸렸다.** `twitch` 가 `BLOCKED` 로 떨어졌는데 `GATE_PHRASES` 의 `'sign up to'` 가 `"…sign up to chat…"` 이라는 **권유 문구**에 맞은 것이었다. `gated` 는 `BLOCKED` 이라 28건이 영원한 재시도 대상이 된다 — 조사 샘플 전수 대조에서 그 문구는 한 번도 발동한 적이 없어서 뺐다. 그리고 `discord.gg` 만 옮기면 아무 일도 안 일어난다. 리다이렉트 해제(G-18)가 `discord.com/invite/{code}` 로 바꾸므로 **`discord.com` 도 같이** 옮겨야 한다.

**UA 가 더 컸다.** 일반 Chrome UA 로 부르면 `facebook` 이 HTTP 400 을 내는데 우리 UA(`Mozilla/5.0 (social-fetcher)`)로는 200 에 og 가 다 온다. **브라우저 위장이 오히려 나빴고, 크롤러를 사칭할 이유도 없었다.**

#### 왜 껍데기가 아닌가

`ContentSubtype` 에는 이미 같은 부류가 셋 있다 — `SEARCH` · `INTENT` · `TREND`. 그 블록의 기준은 *"부를 수 있나"* 가 아니라 **"`platform_key` 를 만들고 계약된 필드를 채울 수 있나"** 다(G-4). `truthsocial` 은 키·계정·게시물 id·발행시각을 준다. `pinterest` 는 키 말고 아무것도 못 줘서 이 기준에 걸린다. 즉 **G-6 을 넓히는 것이 아니라 G-4 를 적용하는 것**이다.

#### 키는 정규화한 URL 이다 (W-1 유지)

같은 글에 표기가 둘이다 — `/@user/posts/{id}` 와 `/@user/{id}`. 샘플 10건이 URL 로는 10개 고유인데 `(handle, postId)` 로 접으면 **6개**였다. 접지 않으면 중복을 보려고 만든 행이 중복을 만든다.

실호출로 확인했다 — 세 토큰이 두 표기로 같은 글을 걸었고 `contentId` 가 하나로 모였다. `entry_url` 은 원본 표기 그대로 남는다.

#### `data.pattern` 을 안 넣는다

패턴은 정의상 **응답을 받아본 뒤** 정하는 축인데 여기는 응답이 없다. 8번째 값을 만들면 *"판정했다"* 와 *"판정할 것이 없었다"* 가 같은 어휘가 된다. 본문이 비는 이유는 `subtype` 이 설명한다.

#### 딸린 변경

- `mibextid` 를 `TRACKING_PARAMS` 에 넣었다. Meta 앱 내장 브라우저가 붙이는 꼬리표라, 안 지우면 **같은 게시물이 두 키가 된다.**
- 규칙은 **명시 목록**(`WEB_POST_HOSTS`)이다. 일반 규칙(*"경로 2단계 + 숫자 id"*)은 `pinterest.com/search/pins` 를 게시물로 오인하고 두 표기도 못 접는다. 목록 밖에서는 아무 일도 안 일어난다.

---

## 4. URL 종류별 산출

| `sourceType` | 무엇을 부르나 | 산출 | 실패 시 |
|---|---|---|---|
| `web_tool` | 없음 | 없음 | — |
| `web_social` | 없음 | 없음 | — |
| `web_social_post` | **없음** | `content` 1개 — 키·계정·게시물 id·발행시각 (W-18) | — |
| `web_directory` | HTML (+조건부 RSS) | 패턴이 P1~P3 이면 `content` 1개 | 사유 기록 |
| `web_reference` | HTML (+조건부 RSS) | 〃 | 〃 |
| `web_site` | HTML · RDAP · urlscan (+조건부 RSS) | 〃 + `data{}` 지문 | 〃 |

**`web_tool` 과 `web_social` 이 `null` 을 내는 것이 정상 경로다.** 실패가 아니다.

**`web_social_post` 만 "안 부르는데 산출이 있는" 칸이다.** 두 축의 예외이고, 그래서 `attempted` 가 `false` 인 채로 `status` 가 `OK` 다 — 부르지 않은 호출이 `attempts` 에 세어지면 비용·백오프 계산이 조용히 틀린다(R-13).

---

## 5. 필드 매핑

### `ContentInput` ← 표준 8종

| 칸 | 폴백 체인 | 실측 (P1 41도메인) |
|---|---|---|
| `platformKey` | 정규화 URL | — |
| `subtype` | `ContentSubtype` 신설 필요 — §7 | — |
| `text.primary` | `og:title` → `jsonld:headline` → `meta:title` → `twitter:title` | og 35 · jsonld 5 · meta 1 |
| `text.body` | `og:description` → `jsonld:articleBody` → `meta:description` → `twitter:description` | og 33 · jsonld 4 · meta 3 |
| `publishedAt` | `jsonld:datePublished` → `article:published_time` | **jsonld 32** · article 9 |
| `mediaUrls[0]` | `og:image` → `jsonld:image` | og 39 · jsonld 1 |
| `tags` | `jsonld:keywords` → `article:tag` (**P1 에서만**) | jsonld 13 · article 4 · meta 4 · 없음 20 |
| `outboundUrls` | 이번 라운드 제외 | — |
| `metrics` | 없다 — 웹에는 참여지표가 없다 | — |
| `creatorId` · `venueId` | 없다 | — |

### `ContentDataInput` 에 더할 18필드

이름은 `WEBSITE_VALUE_NATURE` 의 키를 그대로 쓴다(G-7).

```
domain              domainCreatedDate    domainExpiryDate    registrar
certValidFrom       certIssuer           scanTime            scanUuid
ogSiteName          isNews               ip                  asn
server              techCount            hasTracking         hasTokenTool

pattern             ← 신설 (W-13). "P1" ~ "P3" 중 하나
```

`pattern` 만 이름 규칙(G-7)의 예외다. fetcher 반환 필드가 아니라 **우리가 판정한 값**이라 원래 이름이 없다.

⚠️ **`domain` 과 `domainCreatedDate` 는 실제로 조회축이다** — 공유 카운트의 키이고 나이 필터(731일+)의 축이다. `ContentDataInput` 주석이 *"축이 되는 순간 최상위로 승격한다"* 고 했으나, MongoDB 는 중첩 필드에도 인덱스를 걸 수 있어 기능상 문제가 없다. **왜 축인데 `data` 에 두는지를 주석으로 남긴다** — 나중에 규칙만 보고 되돌리는 것을 막는다.

---

## 6. 실패 표현

**정상적으로 아무것도 안 만드는 경우와 실패를 갈라야 한다.**

> **이 표는 2026-08-11 감사(F2)로 한 번 갈렸다.** 착수 때는 Content 를 안 만드는 갈래도
> 전부 `OK` 였는데, 그러면 *"불렀고 값도 있었다"* 와 *"불렀는데 쓸 값이 없었다"* 가 같은
> 상태가 되어 재수집 배치가 둘을 구분할 수 없었다. 아래가 코드의 현재 계약이다.

| 상황 | `FetchStatus` | `attempted` | Content |
|---|---|---|---|
| **P1·P2·P3** | `OK` | `true` | **만든다** · `data.pattern` 기록 |
| **P4~P7 + `web_site`** (지문 있음) | `OK` | `true` | **지문만 만든다** — `text` 가 빈다 (W-15) |
| `web_social_post` | `OK` | **`false`** | **만든다** — 호출 0회 (W-18) |
| `web_tool` · `web_social` | `UNSUPPORTED` | **`false`** | 없음 |
| P4·P6·P7 (`web_site` 아님) | `NOT_FOUND` | `true` | 없음 |
| P5 로그인벽 · 봇 차단 | `BLOCKED` | `true` | 없음 |
| HTML 을 못 받음 | `NOT_FOUND` | `true` | 없음 |
| 예외가 올라옴 | `ERROR` | `true` | 없음 |

**상태를 가르는 기준은 *"재시도로 풀릴 수 있는가"* 다.** `gated` 만 대상 쪽 사정이라 `BLOCKED` 이고, 나머지는 다시 불러도 같은 답이 온다.

🔴 **`attempted` 는 상태와 독립이다.** `UNSUPPORTED` 와 `web_social_post` 만 `false` 이고, HTML 을 한 번이라도 받았으면 결말과 무관하게 `true` 다. `site_wide` 가 링크의 39.3% 라, 여기를 `false` 로 두면 **다수 경로가 통째로 안 세어진다.**

**패턴명은 `data.pattern` 이 남긴다**(W-13). 행이 없는 갈래는 그것도 없으므로, 그 경우 사유를 담는 곳은 `tokens.social_urls[].status` 하나다.

🔴 **`placeholder` 는 결측이 아니라 신호다.** 채울 자리를 만들어 놓고 안 채웠다는 뜻이라, 소유권 조사에서 계약주소를 안 채운 도메인 집단과 정확히 겹쳤다. `absent` 로 뭉개면 그 신호가 사라진다.

---

## 7. 선행 작업 (생성기 코드보다 먼저)

- [ ] **`WebsiteSourceType` 5종 신설** + `SocialRoute` 판별 유니온 적용 (G-14)
- [ ] **`ContentSubtype` 에 웹 값 추가** — 지금 `unknown` 은 *"아는 플랫폼인데 그 안 어디인지 모르는 것"* 이라 뜻이 다르다(`social-graph.consts.ts:53`)
- [ ] **`ContentDataInput` 에 18필드 추가** — 지문 17 + `pattern`(W-13). 입력 계약 · `@prop` · `toData()` 셋 다
- [ ] **`WebsiteInfo` 타입 신설** — 지금 `fetchSite` 가 `Record<string, unknown>` 을 반환한다(T-004 미완)
- [ ] **표준 8종 파서 이관** — `probe-website-extract.py` 의 파싱 규칙을 TS 로
- [ ] **도메인 목록 4종을 코드로** — 툴 13 · 소셜 26 · 디렉터리 7 · 참조 94
- [ ] **PSL 도입** — `apexDomain()` 의 하드코딩 6개를 교체. 토큰링크의 19.1% 가 잘못 묶여 있다
- [ ] `tokens.web{}` · `updateWeb()` 제거 + `fingerprints` 에서 `domain:` 제거 (W-9 · W-10)
- [ ] `SocialGeneratorRouter` 에 `WebsiteGenerator` 등록 — 빠뜨리면 부팅에서 터진다

---

## 8. 확인 필요 항목 (구현 전 해소)

파이프라인 문서 §3 과 같은 목록이다. **1번이 시그니처를 가른다.**

**결정 넷은 2026-08-11 에 닫혔다** — W-15(지문 범위) · W-16(2단 위치) · W-17(`tags`) · W-1(`platformKey`).
남는 둘은 결정이 아니라 **측정**이다. 답을 정하는 것이 아니라 세어봐야 나온다.

| # | 무엇이 | 왜 막나 | 어떻게 닫나 |
|---|---|---|---|
| 1 | `web_site` 안의 참조 비중 | 목록에 없는 참조가 `web_site` 로 떨어진다. `thaipbs.or.th` 13건이 실제로 그랬다. 비중이 크면 참조 목록을 늘려야 하고, 작으면 3단 신호로 충분하다 | 657개 도메인을 훑는다 (Q3 열림) |
| 2 | `google.com` 66건 | 검색 결과 · `docs.` · `sites.` 가 섞였고 G-4 검색 규칙과 겹친다. `docs.`·`sites.` 는 서브도메인마다 주인이 달라 `blog.jp` 와 같은 부류다 | 66건의 내부 구성을 본다 (Q5 열림) |

**둘 다 구현을 막지 않는다.** 1번은 목록 크기 문제이고 2번은 도메인 하나의 처리라, 규격을 바꾸지 않는다.

---

## 9. 구현 순서

1. 선행 작업 §7 — 타입과 목록이 먼저다
2. **테스트 ①층(종류 라우팅)** — 목록을 코드로 옮기는 즉시 걸 수 있다
3. 표준 8종 파서 + 패턴 캐스케이드
4. **테스트 ②층(패턴 판정)** — 파서가 있어야 돌린다
5. 필드별 체인 + RSS 보정
6. **테스트 ③층(필드 매핑)**
7. `WebsiteGenerator` 조립 + Writer 통과 확인
8. 루브릭 §8 체크리스트 23항목 전수 점검

---

## 10. 테스트 — 3층 규격

**이 소셜은 종류가 다섯이고 패턴이 일곱이라 분기가 서른 다섯이다.** 문서에만 적으면 안 지켜진다는 것이 G-19 에서 실측으로 나왔다 — R-16 은 처음부터 루브릭에 있었는데 6소셜 중 4소셜이 위반 중이었다. **강제한 것만 살아남는다.**

세 층 다 **네트워크 호출이 0회**다. 픽스처가 이미 커밋돼 있다.

### 공통 — 레지스트리로 선언한다

검사 대상을 손으로 나열하면 **그 목록도 잊을 대상이 된다**(G-19). 그래서 종류와 패턴을 `Record` 로 선언한다.

```ts
const KIND_EXPECT: Record<WebsiteSourceType, …> = { … };   // 종류를 더하면 TS2741
const PATTERN_EXPECT: Record<WebsitePattern, …> = { … };   // P8 을 더하면 TS2741
```

이것이 `classify-contract.spec.ts` 가 이미 쓰는 방식이고, 실증까지 끝났다.

### ① `classify` — `sourceType` 라우팅

**여기가 가장 중요하다.** `sourceType` 이 호출 여부를 가르므로, 여기서 한 칸이 밀리면
부르지 말아야 할 곳을 부르거나 불러야 할 곳을 건너뛴다. 그리고 **손으로 유지하는 목록이 입력**이라
사람이 건드릴 일이 가장 많은 자리다.

| | |
|---|---|
| 파일 | `test/unit/social-fetcher/website-classify.spec.ts` |
| 픽스처 | `scaffold/website-kinds.json` — 797 도메인 |
| 입력 | 도메인 문자열 (네트워크 0회) |
| 단언 | `classify(parsed).sourceType` 이 배정된 종류와 같다 |

**단언 목록 여덟.** 앞의 셋이 회귀를 잡고, 뒤의 다섯이 계약을 지킨다.

1. **797 도메인의 `sourceType` 이 배정과 같다** — 목록에 도메인 하나를 추가하다 **다른 것이 딸려 옮겨가는 것**을 잡는다
2. **목록 우선순위**(W-14) — `moonshot.com` 과 `bags.fm` 은 툴 목록과 디렉터리 목록에 **둘 다** 있다. `web_directory` 가 나와야 한다. 이 단언이 없으면 목록을 선언하는 **코드 줄 순서가 판정을 바꾼다**
3. **목록에 없으면 `web_site`** — 잔여가 기본값이다. 새 도메인이 와도 판정이 나와야 한다
4. **`sourceKey` 가 비지 않는다**(R-16) — 웹은 `classify` 가 항상 값을 낸다. 다른 소셜에서 이 계약이 6소셜 중 4소셜에서 깨져 있었다(G-19)
5. **PSL 교정** — `abtc.grok.me` 와 `animebtc.grok.me` 가 **다른 키**가 된다. 지금은 둘 다 `grok.me` 라 46개 토큰별 사이트가 한 도메인으로 세어진다
6. **정규화 불변** — `WWW.NYPOST.COM` · `www.nypost.com` · `nypost.com` 이 같은 키다
7. **순수하다** — `classify` 안에서 네트워크가 나가지 않는다. HTTP 목을 걸고 **호출 0회**를 단언한다. 다른 7소셜과 공유하는 계약이라 여기서 깨면 라우터 전체가 오염된다
8. **결정적이다** — 같은 URL 을 두 번 부르면 같은 답이다

⚠️ **1번은 `Record<WebsiteSourceType, string[]>` 로 목록을 선언한 뒤 단언한다.** 배열을 손으로
나열하면 종류를 하나 더할 때 그 배열이 잊힌다 — G-19 가 다룬 실패 모드 그대로다.

### ② 패턴 판정 — `website-patterns.spec.ts`

**여기가 핵심이다.** 캐스케이드는 순서가 곧 규칙이라, R3 과 R4 를 바꾸면 `longdog.lol` 이 `P7 unfilled` 에서 `P4 site-wide` 로 **조용히 넘어간다.**

| | |
|---|---|
| 픽스처 | `scaffold/website-extract-samples.json` — 216 URL 의 표준 원문 |
| 기대값 | `scaffold/website-patterns.json` — 124 도메인 배정 |
| 입력 | 표준 8종 값 (파싱 결과) |
| 단언 | 판정된 패턴이 배정과 같다 |

⚠️ **확신도로 갈라야 한다.** 배정 124건 중 `low` 가 10건 · `mixed` 가 19건이다. 그대로 얼리면 **틀린 판정이 정답으로 고착된다.**

| 그룹 | 건수 | 처리 |
|---|---|---|
| `high` | 86 | **강제.** 바뀌면 실패 |
| `medium` | 28 | 강제하되 별도 `describe` — 실패 시 원인을 바로 안다 |
| `low` | 10 | 스냅샷만. 바뀌면 검토 목록에 올린다 |

`router-cases.json` 의 `group` 필드가 이미 같은 용도다.

**같이 단언할 것 둘.**

- **`data.pattern` 이 판정과 같다**(W-13) — Content 를 만든 경우에도 어느 패턴이었는지 남아야 한다
- **`sourceType` 을 참조하지 않는다** — 같은 표준 값을 `web_reference` 로도 `web_site` 로도 넣어보고 **같은 패턴이 나오는지** 본다. 두 축이 섞이면 여기서 잡힌다

### ③ 필드 매핑 — `website-extract.spec.ts`

| | |
|---|---|
| 픽스처 | 〃 216 URL |
| 기대값 | `website-patterns.json` 의 `fields{}` |
| 단언 | `text.primary` 를 채운 표준이 기대와 같다 |
| 잡는 것 | **폴백 체인 순서 변경.** `published` 를 `article:published_time` 우선으로 바꾸면 32건이 갈린다 |

### 그 밖에 — 정상 경로와 적대적 입력

X 에서 단위 테스트 23개가 전부 통과하는 상태로 자기 인용 트윗에 힙이 터졌다. 정상 입력만 넣었기 때문이다.

**정상 경로**
- 종류 5종마다 호출 수가 §2 표와 같은가 — `web_tool` 은 fetch 를 **한 번도 부르지 않는가**
- `web_site` 만 `data{}` 가 채워지는가
- RSS 보정이 **`text_body`·`published` 가 빌 때만** 도는가 (P1 에서 피드를 부르면 실패)
- 실물 `SocialGraphWriter` 에 산출을 통과시킨다 — R-1·R-3·R-4·R-5 는 Writer 가 런타임에 던지는 규칙이라 단위 테스트로는 안 잡힌다

**결손·적대적**
- 표준 8종이 **전부 빈** HTML → `P6`/`P7` 로 떨어지고 Content 가 없는가
- `og:title` 만 있고 나머지 전부 없음 → `P3` 이고 `text.body` 가 **비는가**(빈 문자열이 아니라)
- `jsonld` 가 깨진 JSON → 예외를 삼키고 다음 표준으로 가는가
- RSS 링크가 있는데 열면 **HTML 앱 셸** → 파싱 실패를 흡수하는가 (실측 `truthsocial`)
- RSS 피드에 이 URL 이 **없음** → 빈 칸을 그대로 두는가 (억지로 최신 항목을 넣으면 안 된다)
- 3MB 를 넘는 HTML → 잘린 채로도 파싱이 도는가
- 리다이렉트가 다른 도메인으로 → `platformKey` 가 **최종 URL** 인가 원본인가 (⚠️ §8 에 없는 구멍. 여기서 정해야 한다)
