# Social Generator — 횡단 결정

> 상태: 이 문서 자체의 초안은 X 작업(2026-08-06)에서 시작됐다. 아래 항목은 확정 시점이
> 각각 다르며(G-1 부터 G-21 까지), 진행 현황은 문서 하단 표 참고.
> 소셜별 결정은 각 소셜 문서에, 8곳에 복제되면 안 되는 것만 여기 남긴다.

## 이 문서의 역할

소셜 8종의 생성기를 **소셜당 문서 1개**로 진행한다(`x.md` · `telegram.md` · …).
이 문서는 그 소셜 문서들에 **복제되면 안 되는 것**만 담는다 — 한 번만 정하고 8곳이 참조하는 규칙.

소셜별로 각자 판단하게 두면 무슨 일이 나는지는 선례가 있다:
검색 처리를 소셜별로 미뤄 X 196건 · TikTok 57건 · Instagram 1건이 전량 미수집으로 남았고
횡단 Task 를 새로 파야 했다 — [`fetcher-typing-refactor/tasks/task-012.md`](../../docs/features/fetcher-typing-refactor/tasks/task-012.md).

## 근거 문서 (인용만 한다, 여기 복제하지 않는다)

| 문서 | 이 작업에 주는 것 |
|---|---|
| [`social-schema-definition/schema-v5.html`](./schema.html) §6 | 생성기 설계 원천 |
| [`social-recording-process/be-system-design.md`](../../docs/features/social-recording-process/be-system-design.md) §1.2 · §1.5 · §3.5 | 반환 계약 · 필드 소유권(wiring/generator/unfilled) · outcome→status 매핑 |
| [`social-url-structure-audit/`](../../docs/features/social-url-structure-audit) `scaffold/{social}.json` → `objectFields[]` | 소셜별 필드·조인키의 근거. **여기가 원천이다** |
| 〃 `sources/{social}.html` | 위 JSON 을 사람이 읽는 형태로 렌더한 것 + **부록에 미채택 소스의 raw 스키마**. ⚠️ **자동 재렌더가 없어 JSON 보다 뒤처진다** — 둘이 어긋나면 JSON 이 맞다 |
| [`fetcher-typing-refactor/design.md`](../../docs/features/fetcher-typing-refactor/design.md) | 소비할 fetcher 반환 타입 · 결정 로그 D-001~D-023 |

코드상의 계약: `src/modules/social-recording/social-generator.interface.ts` ·
`social-recording.types.ts`(`SocialObjectGroup` · `SocialObjectGraph`) ·
`src/modules/social-graph/social-graph.types.ts`(`ContentInput` 등)

## 설명 문서

> **[`rule-gaps-explainer.html`](../../docs/archive/social-generator/rule-gaps-explainer.html)** — 2026-08-07 결정 중 루브릭에
> 아직 반영되지 않은 **4건**(G-5 · G-10 · G-13 · G-14)이 각각 무엇을 막아주는지 그림으로 설명한다.
> `GenerateResult` 세 갈래를 눌러보는 표, 보조 호출 판정의 좌우 비교, `platform_key` 수렴 그림,
> `SocialRoute` 좁히기 전후 격자가 있다.
>
> **넷이 루브릭에 반영되면 역할이 끝난다** — 그 뒤로는 결정의 배경 설명으로만 남는다.

## 소셜 하나를 착수할 때

> **[`guides/social-generator-process.html`](../social-generator-process.html)** 을 먼저 연다.
>
> 프로세스 6단계와 **사람이 멈추는 두 지점**(착수 전 결정 · 구현 후 리뷰), 매번 반복되는
> 결정 축 7개, 선행 작업 체크포인트가 있다.
>
> **단계 0(근거 읽기)을 건너뛰지 않는다.** X 에서 `scaffold/x.json` 의 `liveTests` 를 안 보고
> 실호출을 다시 요청했다. 중첩 author 가 33키라는 것도 정지 계정 응답 모양도 이미 거기 있었다.
>
> **이번 소셜의 결정은 착수 문서가 갖는다** — [`youtube-kickoff.html`](../../docs/features/social-generator/youtube-kickoff.html).
> 프로세스 페이지는 소셜이 바뀌어도 그대로이고, 착수 문서만 `{social}-kickoff.html` 로 새로 쓴다.
> 답이 확정되면 `{social}.md` 로 옮겨 적고 착수 문서는 그때의 기록으로 남는다.
>
> ✅ **YouTube 는 이 순환이 한 바퀴 돌았다** — 10/10 확정 후 [`youtube.md`](./generators/youtube.md) 로 옮겨졌고,
> 착수 문서는 **선택지와 그때의 근거 숫자**를 가진 기록으로 남았다(playlist 0건 · 영상 246건 · 채널 427건).
>
> ⚠️ **착수 문서는 결정 카드만으로 부족하다.** YouTube 에서 **URL 매핑표와 필드 배치표**를 짜자
> 카드에 없던 결정이 **넷 더** 나왔다 — 넷 다 "빠진 자리" 라 늘어놓아야 보이는 종류였다.
> 그래서 표 둘이 프로세스 §4 에서 단계 1 산출물로 규격화됐다.

## ⚠️ 구현 전 필독 — 루브릭

> **[`guides/social-generator-rubric.md`](../social-generator-rubric.md)** — R-1 ~ R-18
> **[`guides/social-generator-rubric.html`](../social-generator-rubric.html)** — 작업 중에 여는 쪽

**이 문서와 역할이 다르다.** 여기는 "왜 그렇게 정했나"(G-*)의 기록이고, 루브릭은
**"그래서 무엇을 지켜야 하나"**(R-*)의 구속력 있는 규칙이다. 그래서 `guides/` 에 있다 —
소셜 8종을 다 만든 뒤에도, 이 feature 문서가 닫힌 뒤에도 유효하다.

루브릭에는 `token_links` 정책과 R-1~R-18, 그리고 **각 규칙을 어겼을 때 무엇이 어떻게 틀리는지**가
있다. 절반은 Writer 가 던지지 않고 **조용히 잘못된 데이터를 남기는** 종류라, 규칙만 읽고
구현하면 통과했는지 알 수 없다.

**소셜 하나를 구현할 때마다 루브릭 §8 체크리스트 23항목을 처음부터 끝까지 훑는다.**

## 결정

| ID | 항목 | 상태 | 결론 |
|----|------|------|------|
| G-1 | 생성기↔fetcher 경계 타입 | ✅ 확정 | **구체 fetcher 클래스를 주입받는다.** `SocialFetcher` 인터페이스에는 `classify()` 만 있고, 생성기는 자기 소셜의 좁은 타입(`SocialRoute<XSourceType>` 등)을 그대로 받아 **캐스팅이 0개**다. 코드가 이미 이 형태다(`social-fetcher.types.ts:64`) |
| G-2 | `SocialObjectGroup` 조립 규칙 | ✅ 확정 | 생성기는 **`parentRef` 만 정확히 건다.** `linkDepth` 는 Writer 가 체인에서 계산한다(`social-graph.writer.ts:103`). 인용·리트윗 원본은 **각각 별도 묶음**으로 낸다 — 스키마가 이미 전제한다(`LINK_MAX_DEPTH=2` · `parentRelation`).<br>⚠️ **모든 묶음은 `rootRef` 에서 `parentRef` 를 타고 도달 가능해야 한다.** 즉 그래프는 가지도 고아도 없는 **사슬 하나**다. Writer 가 그렇지 않은 그래프를 던진다(2026-08-07). 근거는 G-9 |
| G-9 | 콘텐츠가 여러 콘텐츠를 참조할 수 있나 | ✅ 확정 | **없다. 8종 전부 단일 부모다.** X quote·retweet 1개(답글 부모는 콘텐츠 참조가 아니라 `data.inReplyToId`), TikTok duet·stitch 1개, Instagram remix 1개, Reddit crosspost 1개, GitHub fork 1개, Telegram 전달 1개, YouTube·Web 0개. `ParentRelation` 이 `quote·retweet·fork` 인 것 자체가 **셋 다 출처가 하나인 연산**이라는 증거다.<br>그래서 `parentRef` 를 배열로 넓히지 않는다. 넓혀도 반쪽이다 — `contents.parent_content_id` 가 단일 `ObjectId` 라 깊이만 맞고 관계는 여전히 잃는다. 진짜 다중 참조가 나오면 스키마부터 다시 본다 |
| G-3 | `platform_key` 를 만들 수 없을 때 | ✅ 확정 | **그 객체를 만들지 않는다. 대신 신호를 `status` 로 보존한다.** 뭉개면 재시도 정책까지 같이 뭉개진다 — 프로세서가 `TARGET_BLOCKED` 를 `ERROR` 에서 가르는 근거와 같다 |
| G-4 | 비객체 URL(검색 · intent · trending · unknown) | ✅ 확정 | **검색: 호출 0회로 "검색어 행 1건" 만 만든다.** 키는 쿼리 정규화값, `text` 는 원문, 나머지는 빈 처리. 통계에는 포함시키고 디테일은 무시한다는 정책이다 — 무료로 부를 방법이 없고(공식 API v2 무료 티어에 검색 없음 · twitterapi.io 는 credit 과금), audit 이 이 링크의 의도를 *"이 밈이 실재한다는 걸 확인해라"* 로 판정했다. 나중에 상세가 필요하면 **백필 배치**로 채운다.<br>TikTok·Instagram 검색도 같은 규칙을 따르되, 그쪽은 유료 fetch 가 이미 가능해 "부를지 말지" 는 소셜별로 갈릴 수 있다. 상세는 [`x.md`](./generators/x.md) §9 (X-10).<br>**intent 도 검색과 똑같이 간다** — 키는 `text` 정규화값, `text.primary` 는 원문, 호출 0회. **정규화 함수는 검색과 공유한다** — CA·URL 을 빼면 템플릿 단위 병합이 되지만 함수가 둘로 갈려, 규칙 하나를 택했다(X-11).<br>**trending(82건)은 살린다** — audit 의 "재현 불가" 판정이 실측으로 반증됐다(id 3개가 각각 다른 특정 스토리로 해석되고 6주 뒤에도 열린다). **id 가 snowflake 라 생성 시각이 외부 호출 0회로 나오고**, 그것만으로 "밈이 토큰보다 앞서는가" 를 판정할 수 있어 껍데기가 아니다. 제목·요약은 리더 프록시(`r.jina.ai`, 키 없이 20 RPM)로 **시도하되 실패를 허용**한다 — x.com 직접 요청은 402 로 막힌다. 상세는 [`x.md`](./generators/x.md) §9 (X-12).<br>**grok(1건)·예약 루트·그 밖의 `unknown` 은 `UNSUPPORTED`.** |
| G-10 | 보조 경로의 실패를 흡수해도 되나 | ✅ 확정 | **된다. 단 "보조" 일 때만이다.** 원칙은 여전히 "fetcher 는 `try/catch` 를 두지 않고 transport 가 분류·재시도·던지기를 한다" 이지만, **대상 쪽 사정이 아니라 우리가 얹은 부가 경로의 실패**는 그 URL 전체를 실패로 만들 이유가 없다. 판정 기준은 하나다 — **그 호출이 실패해도 남는 값이 있는가.** trending 은 snowflake 시각이 남아 성립하고, 남는 게 없으면 그건 보조가 아니라 본 경로다 |
| G-11 | 서드파티 프록시 의존 | ✅ 확정 | **한 종류에만, 실패 허용을 조건으로 허용한다.** 나머지 소셜은 전부 SDK·공식 API 다. 82건(전체의 1.5%)이 걸려 있고 실패 시 안전하게 내려앉으므로 감수한다. **우리 URL 이 제3자를 거치고 기본 캐시·로깅된다**(`x-no-cache` 로 끌 수 있음) — 공개 URL 이라 그대로 둔다. 이 예외를 다른 소셜로 넓히려면 같은 조건(실패해도 남는 값이 있는가)을 다시 통과해야 한다 |
| G-5 | 실패·미지원 판정 어휘 | ✅ 확정 | `FetchStatus` 에 **`SUSPENDED` 를 추가**한다. 정지 계정과 "대상 자체가 없음" 은 재시도 정책이 갈린다 — 정지는 풀리고 없는 핸들은 안 생긴다. 다른 소셜(텔레그램 삭제 채널 등)도 같은 어휘를 쓸 수 있다 |
| G-6 | **껍데기 객체 금지** | ✅ 확정 | 만들 거면 **계약된 필드를 채우는 것을 보장**한다. 못 채우면 ① 추가 호출로 채우거나 ② 아예 만들지 않는다. 근거: `resolveAccount`·`resolveVenue`·`resolveContent` 는 기존 행을 갱신하지 않는다. **이것은 결함이 아니라 설계다** — *수집(축적)* 과 **변하는 값의 갱신**을 분리한 것이고, **통제하지 않는 시점의 갱신은 정보성이 떨어지기 때문**이다.<br>**2026-08-13 — 이 분리가 지표 밖으로 확장됐다.** `handles`·`outbound_urls`·`bio`·`subtype` 도 재관측에서 안 덮고 **주기성 갱신 프로세스**가 맡는 것으로 확정됐다(v5 §7 · schema-refactor-plan 축 ⑤). 즉 재관측 경로의 갱신은 **0건이 최종 동작**이다. 그래서 이 규칙이 여전히 구속력을 갖는다 — **갱신 로직이 나중에 생겨도 애초에 만들지 않은 객체는 채울 대상이 없다** |
| G-7 | `data` 키 이름 규칙 | ✅ 확정 | **소셜 원래 필드명을 그대로 쓴다.** 통일하지 않는다 — 어느 소셜 값인지는 같은 문서의 `platform` 이 말해준다. `ContentMetrics` 가 이미 같은 규칙이고, `ContentDataInput` 의 첫 정의 주체는 X 다 |

| G-8 | `SocialObjectGraph.raw` 의 정체 | ❌ **폐기 (2026-08-07)** | 원래 결론은 *"fetcher 반환값이다(HTTP 응답 원문이 아니다)"* 였다. 그 결론이 맞다는 것이 **폐기 사유가 됐다** — `object_raw` 의 존재 이유가 *"무손실 원본 · 자르는 순간 재해석 가능성이 사라진다"* 인데, 저장되던 값은 SDK 의 transform 을 이미 거친 결과라 **자기 명분을 스스로 위반**하고 있었다. `Mixed` 를 스키마 전체에서 유일하게 허용한 근거도 여기 걸려 있었다.<br>게다가 그 값의 필드가 거의 전부 `ContentInput`·`AccountInput` 으로 가고 있어 **중복 저장**이었고, 값을 못 주는 계약이 8종에 채우기를 강요하며 문서 5곳(G-8 · X-9 · x.md §6 · 루브릭 R-16 · 인터페이스 주석)을 만들어 냈다.<br>→ **`object_raw` 컬렉션 · `SocialObjectGraph.raw` · 관련 배선을 전부 제거했다.** 대체는 G-12 |
| G-12 | 응답 원문을 어디서 보존하나 | 🔵 **방향만 확정 · 미착수** | **SDK 층에서 완결시킨다. 위로 전파하지 않는다.**<br>원문을 실제로 볼 수 있는 유일한 자리가 거기다 — `twitter-api.sdk.ts:88` 에서 `response.data.data` 가 `transformUser()` 로 들어가고 원문은 함수 안에서 사라진다. fetcher 도 generator 도 원문을 **본 적이 없다.** 따라서 "fetcher 에 기록 인터페이스를 만든다" 는 **fetcher 작업이 아니라 SDK 작업**이고, 착수 범위는 `common/twitter-api/` 같은 SDK 8종부터다.<br>⚠️ **되살릴 때 정체성 축이 바뀐다.** 옛 `object_raw` 는 `(object, object_id)` unique 였는데 fetch 시점에는 `object_id` 가 없다(저장 전). URL 이나 `(platform, platform_key)` 로 잡아야 하고 그건 다른 컬렉션이다. **그래서 반쯤 맞는 스키마를 남기지 않고 지금 지웠다** — 데이터가 0건인 지금이 가장 싸다 |

| G-13 | `platform_key` 에 종류 접두사를 붙이나 | ✅ 확정 | **붙이지 않는다.** 한때 `search:`·`trend:` 로 키 공간을 가르려 했으나, 그러면 **`subtype` 이라는 같은 사실이 필드와 키 두 곳에 산다.** 어긋난 행(`platform_key="search:foo"` 인데 `subtype=intent`)을 컴파일러도 DB 도 Writer 도 볼 수 없다 — 이 레포가 반복해서 피해 온 실패 모드다(`social-fetcher.consts.ts:15` *"같은 말을 두 번 … 두 곳이 어긋나도 에러가 안 났다"* · `token-link.model.ts:40` · `social-recording.types.ts:70`).<br>**대가는 키 공간 공유다.** `contents` 조회가 `(platform, platform_key)` 뿐이라(`social-graph.writer.ts:330`) 정규화된 검색어와 intent 문구가 같으면 한 행으로 합쳐지고 `subtype` 은 먼저 온 쪽으로 고정된다. **행을 합치는 것은 인덱스가 아니라 이 조회다** — 인덱스는 unique 도 아니라 애초에 막지 않는다.<br>**관측되면 쓸 카드**: `findContent` 를 `(platform, subtype, platform_key)` 로. 사본이 안 늘고 조회축도 이미 있다. 지금 안 하는 이유는 V-M1 — `subtype` 이 변하는 종류(tg `shell→channel`)에서 같은 대상이 두 행이 된다 |

| G-14 | `classify` 반환 타입의 모양 | ✅ 확정 | **판별 유니온으로 좁힌다** — `{ sourceType: T; sourceKey: string } | { sourceType: 'unknown'; sourceKey: null }`. 지금은 두 필드가 무관하게 선언돼 `{ 'x_profile', null }` 이 **가능해 보이고**, 실제로는 절대 안 나오는데도 생성기가 도달 불가능한 분기를 써야 한다. 좁히면 그 분기가 **컴파일 단계에서 사라진다.** 조건은 각 소셜의 `classify` 가 "키를 못 뽑으면 `unknown`" 을 지키는 것뿐이고, 이미 그렇게 동작한다.<br>`normalizeUrl` 이 `null` 이면 **던진다** — 생성기가 받는 URL 은 라우터가 이미 정규화한 것이라, 재정규화 실패는 입력 문제가 아니라 우리 버그다.<br>⚠️ **`unknown` 의 의미가 하나 넓어진다** — "그 호스트지만 어떤 객체도 아닌 경로" 에 "종류는 알겠는데 키가 없었다" 가 합쳐진다. 둘 다 `UNSUPPORTED` 로 끝나 동작은 같지만 로그에서 구분되지 않는다. 구분이 필요해지면 그때 값을 하나 더 둔다 |

| G-15 | 같은 객체를 만드는 경로가 둘일 때 | ✅ 확정 | **산출이 같아야 한다.** 못 맞추면 ① 덜 주는 쪽에서 추가 호출로 채우거나 ② 보장 안 되는 필드를 양쪽에서 뺀다. 한쪽만 채운 채로 두지 않는다.<br>거의 모든 소셜이 계정을 두 경로로 만든다(X `tweet.author`, TikTok `video.author`, Instagram `post.owner`, Reddit `post.author`, GitHub `repo.owner`). 어기면 **도착 순서가 저장 모양을 가른다.** 그리고 이것은 갱신 로직이 생겨도 안 풀린다 — 갱신은 *"무엇을 덮을지"* 를 정해야 도는데(H-008), 두 경로가 서로 다른 필드 집합을 내면 **어느 쪽이 맞는지 판단할 기준이 없다.** 즉 이 규칙은 갱신을 미뤘기 **때문에** 느슨해지는 것이 아니라, 미룬 로직이 나중에 일할 수 있게 만드는 전제다.<br>**그리고 조용하다** — 날짜가 빠지면 `Invalid Date` 로 mongoose 가 거부하지만 **숫자·문자열이 빠지면 그 필드만 없는 행이 그대로 저장된다.** 규칙은 루브릭 R-18 |

| G-16 | 이미지 자산을 어디에 어떻게 매핑하나 | ✅ 확정 (2026-08-10 · YouTube Y-5) | **fetcher 는 원본을 그대로 다 담고, Generator 가 대표 1개를 고른다.**<br>이름은 fetcher 레벨에서 소셜 원래대로 두고(`profilePicture`·`avatar`·`iconImg`·`thumbnails`·`iconUrl`·`profilePicUrl`), **통일 이름(`avatarUrl`·`mediaUrls`)은 Generator 매핑 시점에만 등장한다** — D-022 와 같은 규칙이다. 해상도·크기 변형이 여럿이면 **큰 것부터 폴백해 1개만** 저장한다.<br>`mediaUrls` 에 같은 그림의 변형을 여러 개 넣지 않는다 — 그 필드의 뜻이 *"콘텐츠에 첨부된 이미지"* 라 **첨부 N건으로 읽힌다.**<br>⚠️ **audit 의 이미지 `remove` 판정은 근거를 확인하고 쓴다.** YouTube 에서 판정이 두 행에서 뒤바뀌어 있었다 — 채널 아바타를 뺀 사유가 *"channelId 로 재구성 가능"* 이었는데 재구성되는 쪽은 영상 썸네일(`i.ytimg.com/vi/{videoId}/…`)이었고 채널 아바타는 불투명 URL(`yt3.ggpht.com/{토큰}=…`)이라 **안 담으면 영영 잃는 쪽**이었다. 판정 자체가 아니라 **그 판정의 사유가 이 필드에 실제로 적용되는지**를 본다(X-8 과 같은 방식) |
| G-17 | 계정 지표를 `metrics` 와 `data` 로 어떻게 가르나 | ✅ 확정 (2026-08-10 · YouTube Y-6) | **팔로워류는 이름이 무엇이든 `metrics.followers` 하나로 통합한다.** 소셜마다 `fans`(TikTok)·`followersCount`(Instagram·Reddit)·`followers`(X·GitHub)·`subscriberCount`(YouTube) 로 갈리는데, 원래 이름을 병존시키면 **같은 뜻인 값을 쿼리에서 매번 `OR` 로 묶어야 한다.**<br>**`ContentMetrics` 와 규칙이 갈리는 것이 의도다** — 그쪽은 `playCount`·`diggCount`·`likesCount` 가 애초에 **뜻이 다른 지표**라 이름을 지켜야 하고, 여기는 **하나의 뜻에 이름만 여럿**이다.<br>그 밖의 계정 수치는 **audit 등급이 자리를 정한다** — `actionable` 이면 `metrics`(조회·정렬·비교의 축), `context` 면 `data`(배경정보). X-13 이 세운 *"숫자라서가 아니라 축인가"* 기준의 적용 규칙이다.<br>🔴 **단 이 등급 기준은 Account·Venue 에만 적용된다**(2026-08-10 정정 · GitHub G-7 에서 발견). **Content 는 기준이 다르다** — `ContentInput.metrics` 는 `SocialGraphWriter:396` 이 통째로 `metric_series` 에 첫 포인트로 넣는 자리이고 `data` 는 아니다. 즉 Content 에서는 **"매매에 쓸모있나"(등급)가 아니라 "변화를 추적할 값인가"** 가 기준이다 — mutable 수치를 `data` 에 넣으면 `resolveContent` 가 기존 행을 갱신하지 않으므로 **첫 관측값만 남고 추세를 영영 못 본다.**<br>GitHub `stars` 가 그 예다. audit 이 `noise` 로 판정한 것은 **절대값의 신뢰도** 얘기이고(*"스타 판매·봇으로 손쉽게 부풀려짐"*), 증가 속도가 무의미하다는 뜻이 아니다 — 오히려 **급증 패턴이 봇 판별 근거**가 된다. `ContentMetrics` 가 이미 `stars`·`forks`·`openIssues` 를 갖고 있고 주석도 *"역할축에 억지로 매핑하지 않는다. 저장만 한다"* 로 그 방향이다.<br>⚠️ **Account·Venue 에는 시계열이 없다**(`metric_series` 는 콘텐츠 전용 · v5 M-M1). 그래서 그쪽 `metrics`/`data` 선택에는 **기능적 차이가 없고** 순수하게 쿼리 모양 문제다. 계정 지표의 추이는 **별도 갱신 로직 소관**이다 — *수집(축적)* 과 변하는 값의 갱신을 분리한 것이고, 통제하지 않는 시점의 갱신은 정보성이 떨어지기 때문이다(H-008 · 2026-08-13 에 `handles`·`bio`·`subtype` 까지 확장 확정). YouTube 채널 누적 `viewCount` 가 `context` 라 `data.viewCount` 로 갔다.<br>⚠️ **같은 이름이 객체마다 다른 구역일 수 있다** — YouTube `viewCount` 는 Content 에서 `metrics`(영상 조회수, actionable), Account 에서 `data`(채널 누적, context) 다.<br>⚠️ **`metrics.followers` 의 정밀도가 소셜마다 다르고, 같은 소셜 안에서도 규모마다 다르다**(2026-08-10 실측 → 실데이터 정정). X 는 정확값을 주는데 **YouTube 는 큰 수만 3자리 유효숫자로 반올림한다** — `574000`·`817000` 은 반올림된 값이지만 **구독자 27명인 채널은 `27` 로 정확히** 왔다. 즉 88.1만에서 88.2만 사이의 변화는 안 보이는데 27명에서 28명이 되는 것은 보인다.<br>**신생 토큰의 홍보 채널은 대개 작은 쪽이라 실무상 손실이 크지 않다.** 통합 자체는 뜻이 같으므로 유지하되, **시계열로 증감을 볼 때 큰 채널에서는 해상도가 떨어진다는 것**을 전제해야 한다.<br>**소셜별 실측 (2026-08-10 · 비용 0)** — YouTube 에서 본 패턴이 **소셜 고유가 아니라 공통**이었다. 물음을 *"이 소셜은 정확한가"* 가 아니라 **"이 값의 자릿수에서 정확한가"** 로 바꿔야 한다.<br>· **TikTok** — 같은 패턴이다. 작은 값은 정확(`38`·`62`·`158`·`266`·`863`·`1042`·`4832`)한데 큰 값은 유효숫자 2~3자리로 끊긴다(`38000`·`50900`·`515500`, `heart` 는 `27000000`·`7400000`). ⚠️ 작은 값만 보고 *"TikTok 은 정확값"* 이라고 판단했다가 큰 값을 보고 뒤집었다 — **표본의 자릿수 분포를 먼저 봐야 한다.**<br>· **Telegram** — 같은 패턴이되 **원인이 다르다.** 작은 채널은 정확(실측 `6`·`8`·`24`·`486`)한데 큰 채널은 HTML 이 아예 축약 문자열로 준다(`"9.95M"`·`"1.2K"`). `toNumber()` 가 펴서 숫자로는 오지만 **정밀도는 복구되지 않는다.** 다른 소셜은 API 가 반올림한 숫자를 주는데 여기는 표시용 문자열을 우리가 되돌리는 것이다.<br>· **X** — 정확값(2026-08-07 실측). 지금까지 유일한 예외다.<br>· **Instagram** — **정확값이다**(2026-08-10, 추가 비용 0). 유료 실측 3건이 전부 끝자리가 살아 있다 — `500404`(kabosumama, 이번 라운드 $0.0027) · `13384199`(druski) · `70114`(gamesradar). **50만·1338만에서도 반올림이 없어** 자릿수 문제가 아니다. `postsCount` 도 같다(`1455`·`510`·`5013`). X 에 이어 두 번째 예외이고, **Apify actor 가 웹 UI 를 긁는데도 정확한 것**이 특징이다 — 표시용 축약(`"9.95M"`)을 쓰는 Telegram 과 대비된다.<br>· **Reddit** — **정확값이다**(2026-08-11 실호출). `upVotes: 44296` · `subredditSubscribers: 25325235` · `totalKarma: 940904` 전부 끝자리가 살아 있다. X · Instagram 에 이어 **세 번째 예외**다. Apify actor 인데도 정확한 것이 Instagram 과 같다.<br>⏳ **GitHub 만 미확인.** 무료라 언제든 잴 수 있다 |

| G-18 | 단축 링크를 어디서 푸나 | ✅ 확정 · ✅ **구현 완료** (2026-08-11) | **`tokenInfo` 경계에서 한 번 풀고, 그 아래로는 canonical 만 흘린다.** 원본 단축 형태는 남기지 않는다.<br>이 자리를 고른 이유는 **아래의 어떤 정책도 안 바뀌기 때문**이다. `normalizeUrl()` 은 동기·순수로 남고, 8소셜 `classify()` 의 *"순수·무료·네트워크 없음"* 계약도 그대로다. 무엇보다 **"정규화 실패 = 버린다"** 정책(`social-record.processor.ts:230` · `token.model.ts:100` · `x.generator.ts:483`)에 네트워크 오류가 섞이지 않는다 — 섞이면 bit.ly 가 잠깐 느린 순간에 그 토큰의 소셜 링크가 통째로 버려지고, **재수집 배치가 없어 영구 유실이다**(H-009).<br>Q5(`qna/tiktok.md`)가 이미 *"상류 수집 단계에서 canonical 정규화, fetcher 단이 아님"* 으로 확정해 둔 것이라 **새 결정이 아니라 밀린 숙제다.** 실측 6/6 이 301 로 풀렸고, 안 풀면 같은 영상이 단축·canonical 두 키로 분기한다.<br>⚠️ **실패하면 버리지 않고 원본을 그대로 통과시킨다.** 못 푼 것은 `tiktok_shortlink` 안전망이 받는다 — 그 타입 주석이 *"정규화 실패 잔여분"* 이라고 적은 그 자리이며, **지금은 전량이 거기로 온다.**<br>🔴 **SSRF 방어가 필수다.** 이 레포가 지금까지 부른 외부 주소는 전부 우리가 만든 것이었고, 단축 해제는 **외부(토큰 발행자)가 정한 주소를 우리 서버가 따라가는 첫 동작**이다. 리다이렉트 목적지가 사설 대역·루프백·링크로컬이면 차단한다. axios 자동 리다이렉트는 **검사할 틈 없이 따라가므로** `maxRedirects: 0` 으로 홉을 직접 돈다. 홉 상한도 둔다 — 순환 방어를 빠뜨려 힙이 터진 전례가 있다(X Generator, 단위 테스트 23개가 통과하는 상태였다).<br>**남는 한계 셋** — ① 커스텀 도메인 단축은 목록으로 못 잡는다(실측: `mrb.gg`). ② `linktr.ee`·`lnk.to` 는 리다이렉트가 아니라 랜딩·스마트 링크라 따라가도 목적지가 안 나온다. ③ 부분 실패가 dedup 을 가른다 — 어떤 때는 풀리고 어떤 때는 안 풀리면 같은 대상이 두 키가 된다(캐시로 줄이되 없앨 수는 없다).<br>✅ **구현 완료 (2026-08-11)** — `common/social-fetcher/shortlink-resolver.ts` 의 `ShortlinkResolver`. `SocialRecordProcessor.routeSocialUrls()` 가 `urlRouter.route()` 직전에 한 번 부른다.<br>**찌를 대상 판정은 네트워크 0 이다** — 8소셜이 이미 선언한 `hosts`/`hostSuffixes` 를 라우터 조회표로 그대로 재사용한다(신뢰 목록을 따로 만들면 그 목록도 잊을 대상이 된다). 실측 분포상 진입 URL 의 약 74%가 여기서 끝난다.<br>⚠️ **예외 판정의 기준은 한 문장이다 — *URL 문자열만으로 대상 id 를 알 수 있는가.*** `youtu.be/{videoId}`·`redd.it/{postId}` 는 id 가 경로에 있어 안 찌르고, `vm.tiktok.com`·`vt.tiktok.com`(호스트)과 `tiktok.com/t/{code}`(경로)는 불투명한 해시라 찌른다. **뒤의 경로형은 설계 때 놓쳤다가 실측에서 잡았다** — 호스트만 보는 규칙의 사각지대였고, TikTok `classify` 자신은 그 모양을 이미 `tiktok_shortlink` 로 분류하고 있었다.<br>🔴 **요청은 정규화 전 원본으로 보낸다.** 정규화는 *저장 키* 규칙이지 HTTP 요청 규칙이 아니다 — `tiktok.com/t/{code}`(www 없음)는 301 로 `www.` 만 붙은 URL 을 주는데, 그걸 정규화하면 www 가 지워져 **출발점과 같아진다.** 홉마다 정규화하던 첫 구현이 이걸 제자리 걸음으로 오판해 진짜 목적지 직전에 끊겼다. 최종 정규화는 호출부(`urlRouter.route()`)가 한다.<br>**종단 실측** — `vm.tiktok.com/ZN8JWHDxn` 을 건 토큰 3건이 `unsupported` → **`ok`** 로 바뀌었고, 셋이 같은 canonical 로 수렴해 **콘텐츠 1행·계정 1행에 링크 3건**이 붙었다(dedup 효과 실증).<br>**스코프는 진입 URL 만이다.** 콘텐츠 본문에서 뽑는 `outbound_urls` 는 이 라운드에 넣지 않는다 — 건당 링크 수가 자릿수 다르고(실측 영상 1건에 11개), 그쪽 단축은 마케팅 추적 링크라 **여는 순간 상대 카운터가 올라간다.** 진입 URL 은 발행자가 메타데이터에 넣은 공유 링크라 그 위험이 작다 |

| G-19 | 루브릭 규칙을 어떻게 **강제**하나 | ✅ 확정 · ✅ **5/5 구현** (2026-08-10) | **불변식으로 쓸 수 있는 규칙은 공용 테스트로 강제하고, 그 테스트는 손으로 유지하는 목록을 쓰지 않는다.**<br>계기는 YouTube 전수 점검이다. R-16 은 **처음부터 루브릭에 있었는데 6소셜 중 4소셜이 위반 중**이었다(YouTube 8 · TikTok 4 · Instagram 1 · Reddit 1). **문서에 적는 것만으로는 안 지켜진다**는 것이 실측으로 나왔다. 지켜지지 않은 이유는 `strictNullChecks: false` 때문에 **컴파일러가 못 막는데**, 소비자(Generator)는 루브릭이 시킨 대로 그 계약을 **믿고** 있어서다(`if (!route.sourceKey)` 분기 금지).<br>**핵심은 레지스트리다.** 검사 대상을 손으로 나열하면 그 목록도 잊을 대상이 된다 — 이 레포가 반복해서 당한 실패 모드다(`SocialFetcherModule.exports` · `SocialGeneratorRouter` 의 배열+`inject`, 둘 다 "빠뜨리면 부팅에서 터진다" 주석이 달렸고 실제로 빠뜨렸다). 그래서 **`Record<SocialPlatform, …>`** 로 선언한다 — 소셜을 더하면 그 자리가 **컴파일 에러**(`TS2741`)가 나서 검사에서 빠질 수가 없다. 실증 완료.<br>**자동 강제 — 5/5 구현 완료.** 파일 셋이다.<br>① `test/unit/social-fetcher/classify-contract.spec.ts` — R-16. `Record<SocialPlatform, …>` 라 소셜을 더하면 컴파일이 깨진다. 각 fetcher 의 **자기 `hosts`** 로 degenerate URL 을 만들고 `router-cases.json` 83건도 함께 훑는다.<br>② `test/unit/social-recording/generator-contract.ts` — R-9(`*Url` 이 정규화 출력과 같은가) · R-13·R-14(갈래. 루브릭이 *"눈으로 확인하는 수밖에 없다"* 고 포기한 `OK`+`graph:null` 포함) · R-1~R-5(사슬 도달성·빈 묶음·`ref` 유일·`parentRef` 대상). **각 소셜 spec 의 `graphOf` 헬퍼에 한 줄 걸면 끝난다.**<br>③ `test/unit/social-recording/wiring-contract.spec.ts` — 배선. **Nest 를 띄우지 않는다** — 띄우면 설정 검증이 걸려 `.env.test` 를 유지해야 하는데, `.env.dev` 에는 호출당 과금되는 키가 있다(`APIFY_TOKEN`·`TWITTER_API_KEY`). 테스트에 실키를 물리면 목킹이 빠진 테스트 하나가 **실제 돈을 쓴다.** 그래서 데코레이터 메타데이터만 읽는다. ⚠️ 경로 별칭 때문에 클래스 동일성이 깨져(`provide === Router` 가 `false`) **이름으로 대조한다.**<br>**넷 다 고의로 어겨서 실제로 잡히는지 확인했다** — exports 누락 · `inject` 불일치 · 정규화 누락 · 도달 불가 묶음.<br>**사람이 해야 하는 것(4)** — R-8(채울 수 있는 필드를 다 채웠나 · fetcher 타입과 매핑표 대조) · R-15 예외 판정(*"실패해도 남는 값이 있는가"*) · R-2·R-6(인용 방향·묶음 가르기) · R-18 의 "두 경로가 무엇인가" 선언.<br>⚠️ **자기 코드는 자기가 못 본다.** YouTube 에서 Y-9 로 `classify` 를 고치면서도 같은 함수의 R-16 위반을 못 봤다 — *"어떤 URL 이 잘못된 종류로 가나"* 를 봤지 *"키가 비는가"* 를 안 봤기 때문이다(축이 달랐다). 독립 검토가 같은 것을 찾았고 **심각도까지 정정**했다(`NOT_FOUND` 가 아니라 `ERROR`). 구현 후 리뷰(프로세스 §2 게이트 2)를 사람이든 별도 검토든 **반드시 거친다** |

| G-20 | **유료 호출의 게이트를 누가 판정하나** | ✅ 확정 · ✅ 구현 (2026-08-10 · Instagram I-1 → TikTok T-1) | **Generator 가 스스로 판정한다. 꺼져 있으면 유료 fetcher 를 아예 부르지 않고 `SKIPPED_PAID`·`attempted:false` 로 끝낸다.**<br>🔴 **발견 당시 아무도 판정하지 않았다.** `enablePaid` 를 읽는 곳은 Processor 가 `FetchOptions` 에 실어 보내는 자리뿐이었고 **그 값을 읽는 Generator 가 하나도 없었다.** Instagram fetcher 주석은 *"유료 게이트도 어댑터 소관"* 이라고 적었지만 `ApifySdk` 에 그런 코드가 없다 — 게이트가 제거되던 시점의 낡은 주석이다. **설정을 꺼도 돈이 나가는 상태**였고, 기본값이 `false` 인 것이 그 설정의 핵심인데 강제하는 코드가 없었다. X 는 커뮤니티에만 게이트를 걸었고 YouTube 는 전량 무료라 **이 구멍이 드러날 자리가 없었다.**<br>⚠️ **게이트는 종류 판정 뒤, 유료 종류에만 건다.** Instagram 에서 한 번 틀린 자리다 — 앞에 두면 `unknown` URL 까지 `SKIPPED_PAID` 로 나가고, 기본값이 `false` 라 **그것이 기본 동작이 된다.** 두 값의 차이는 *"무엇이 바뀌면 성공하나"* 다: `UNSUPPORTED` 는 **우리가 만들면**, `SKIPPED_PAID` 는 **설정을 켜면**이다. 섞으면 영원히 지원할 계획이 없는 URL 이 *"설정만 켜면 수집된다"* 고 기록되고, 어휘를 쪼갠 이유(재수집 판단)가 무너진다. **타입도 Writer 도 못 잡는다** — 둘 다 `attempted:false`·`graph:null` 이라 유효하다.<br>호출 0회인 종류(검색 G-4 · shortlink)는 **게이트 밖**이다. 돈이 안 드는 것을 돈 때문에 막으면 `SKIPPED_PAID` 가 *"비용 때문에 건너뛰었다"* 는 뜻을 잃는다.<br>**두 소셜이 같은 모양으로 구현돼 승격했다** — `InstagramGenerator`·`TiktokGenerator` 의 `paid()` 헬퍼가 같다. Reddit 이 마지막으로 물려받는다 |
| G-21 | 검색어 키를 어떻게 정규화하나 | ✅ 확정 · ✅ 구현 (2026-08-10 · Instagram I-11) | **8소셜이 같은 함수를 쓴다** — `social-fetcher.util.ts` 의 `normalizeFreeText()`(`trim().toLowerCase()` + 연속 공백 접기).<br>검색 URL 은 외부에 물을 대상이 없어 **검색어 자체가 `platform_key` 이자 `text` 다**(G-4). 그래서 이 함수의 출력이 곧 정체성이고, 소셜마다 규칙이 다르면 **같은 밈을 검색해도 소셜별로 다른 행이 된다.** 검색 행을 만드는 이유가 *"이 밈이 여러 소셜에서 동시에 검색되는가"* 를 보는 것이라 **그 비교가 무너진다.**<br>🔴 **실제로 갈라져 있었다.** X 는 공백까지 정리했고 TikTok 은 `toLowerCase()` 만 했다. X-11 의 *"정규화 함수는 검색과 공유한다"* 는 **X 안에서 검색↔intent 공유**를 뜻했고, **소셜 사이는 아무도 맞춘 적이 없다.**<br>**지금 고친 것이 쌌다** — TikTok Generator 가 없어 저장된 검색 행이 **0건**이라 마이그레이션이 없었다. Generator 가 생긴 뒤였다면 키가 갈린 행을 되돌려야 했다.<br>**공백을 죽이지 않는 것이 의도다** — 키가 곧 `text.primary` 라 사람이 읽을 수 있어야 한다. 연속 공백만 하나로 접는다 |

> 진행 중 새 횡단 결정이 나오면 G-22 부터 이어 붙인다.

## 선행 조건

- [x] 미커밋 fetcher 변경 확정 — `1754a9d`(전역 `SocialSourceType` 제거) · `8782105`(`classify` 를 계약에 노출)로 커밋됨
- [x] `FetchStatus.SUSPENDED` 추가 (G-5) — 완료(`social-graph.consts.ts:150`)
- [x] `ContentDataInput` 첫 정의 (G-7) — 완료. `conversationId`·`inReplyToId`·`inReplyToUserId` 셋.
      ⏳ `communityInfo` 는 여전히 **보류** — 남길지 자체가 미정이라 넣지 않았다(X-5)
- [x] `ContentSubtype.TREND` 추가 (G-4 / X-12) — 완료. 같이 `SEARCH`·`INTENT` 를
      "생성 경로 없음" 블록에서 빼내 **비객체 URL** 블록으로 옮겼다(G-4 로 셋 다 행을 만든다)
- [x] **`platform_key` 접두사 규칙** — **붙이지 않는 것으로 확정**(G-13)
- [x] `SocialRoute` 를 판별 유니온으로 (G-14) — 완료(`social-fetcher.types.ts:109`)
- [x] **`AccountDataInput` 에 `following`·`isVerified`·`isBlueVerified` 추가** (X-13) — 완료
- [x] `VenueMetricsInput` 에 `moderatorCount` 추가 (X-13) — 완료.
      `AccountMetricsInput` 은 `followers` 그대로 두고 손대지 않았다 — **G-17 이 그 방침을 확정했다**
- [ ] `fetcher-typing-refactor` T-010(router 축소 · 레거시 제거)과의 경계 정리
- [ ] 남은 소셜(website) fetcher 미완 — 해당 소셜 생성기는 그 뒤.
      telegram fetcher 는 이미 있다(`fetchers/telegram.fetcher.ts`)

> **2026-08-10 정정.** 위 여섯이 `[ ]` 인 채 남아 있었는데 **전부 이미 코드에 들어가 있었다** —
> X 구현에서 함께 처리됐고 체크만 안 됐다. 이 표를 보고 "아직 안 됐다" 로 읽으면
> 같은 작업을 다시 하게 된다.

### YouTube 착수분 (2026-08-10 · [`youtube.md`](./generators/youtube.md) §7)

- [ ] `YoutubeChannel.customUrl` 추가 + 매핑 (Y-7) — raw 타입에는 **이미 선언돼 있다**
- [ ] 평문 URL 추출 + `outboundUrls` 신설 (Y-8) — ⚠️ **레포에 평문 추출 유틸이 없다.**
      X 는 구조화된 `entities` 에서 꺼내 재사용이 안 된다
- [ ] `classify` 예약어 denylist (Y-9)
- [ ] **`AccountDataInput.viewCount` 추가** (Y-6 / G-17) — 입력 계약 · `@prop` · `toData()` **셋 다**
- [ ] `scaffold/youtube.json` 판정 정정 2건 (Y-5 채널 썸네일 · Y-7 `customUrl`)

## 진행 현황

| 소셜 | 문서 | 착수 결정 | 구현 |
|------|------|----------|------|
| X | [`x.md`](./generators/x.md) ✅ 결정 완료 | ✅ | ✅ **완료** — `x/x.generator.ts`(547줄, 커밋 4건). 이미지 매핑까지 반영됨(T-011) |
| YouTube | [`youtube.md`](./generators/youtube.md) ✅ 결정 완료 | ✅ [`youtube-kickoff.html`](../../docs/features/social-generator/youtube-kickoff.html) **10/10** | ✅ **완료** — `youtube/youtube.generator.ts`. 선행 작업 7건 · 실호출 15 unit · Writer 통과 확인 |
| Telegram | [`telegram.md`](./generators/telegram.md) ✅ 결정 완료 | ✅ [`telegram-kickoff.html`](../../docs/features/social-generator/telegram-kickoff.html) **10/10** | ✅ **완료** — `telegram/telegram.generator.ts` · 선행 작업 7/7 |
| Website | [`website.md`](./generators/website.md) ✅ 결정 18건 | ✅ **확정** — 미측정 2건은 규격에 영향 없음 | ✅ **완료** — `website/website.generator.ts` · 테스트 3층. **실호출 검증 완료**(2026-08-11, 개발 DB 23토큰). 그 과정에서 결함 3건이 나왔고 전부 닫혔다 — RSS 대조가 워드프레스 홈과 글을 같은 키로 봤고(W-11), `web_social` 목록이 부르면 나오는 것까지 뭉치고 있었다(W-18) |
| GitHub | [`github.md`](./generators/github.md) ✅ 결정 완료 | ✅ [`github-kickoff.html`](../../docs/features/social-generator/github-kickoff.html) **8/8** | ✅ **완료** — `github/github.generator.ts` · 선행 작업 4/4 |
| TikTok | [`tiktok.md`](./generators/tiktok.md) ✅ 결정 완료 | ✅ [`tiktok-kickoff.html`](../../docs/features/social-generator/tiktok-kickoff.html) **9/9** | ✅ **완료** — `tiktok/tiktok.generator.ts` · 선행 작업 7/7 · 루브릭 전수 점검 · Writer 통과 3건. ⚠️ **직접 실호출 0** — 기존 실측(검색 경로) 재사용 |
| Instagram | [`instagram.md`](./generators/instagram.md) ✅ 결정 완료 | ✅ [`instagram-kickoff.html`](../../docs/features/social-generator/instagram-kickoff.html) **9/9** (I-4 는 실측 후 확정) | ✅ **완료** — `instagram/instagram.generator.ts` · 루브릭 전수 점검 완료 |
| Reddit | [`reddit.md`](./generators/reddit.md) ✅ 결정 완료 | ✅ [`reddit-kickoff.html`](../../docs/features/social-generator/reddit-kickoff.html) **11/11** | ✅ **완료** — `reddit/reddit.generator.ts` · 루브릭 R-1~R-18 전수 점검 |

> ⚠️ **2026-08-14 정정.** 이 행이 "미작성/0-9 대기"로 오래 남아 있었으나 `reddit.md`·`reddit-kickoff.html` 둘 다 이미 완결(2026-08-11)돼 있었고 `reddit.generator.ts` 도 존재한다 — 이 표만 갱신을 놓쳤다.

> 소셜 문서는 **해당 소셜 착수 직전에** 쓴다. 미리 몰아 쓰면 앞 소셜에서 배운 것이 뒤에 반영되지 않는다.

**남은 일은 [`backlog.md`](../../docs/features/social-generator/backlog.md) 가 추적한다**(B-1~B-11). 이 표는 *"어디까지 왔나"* 를,
그쪽은 *"무엇이 안 끝났고 어떤 순서로 닫나"* 를 답한다. 각 소셜 `.md` 의 §8 한계는
**닫을 계획이 없는 것**이라 그 목록에 넣지 않는다.
