# YouTube Generator — 구현 계획

> 상태: **구현 완료**(`youtube/youtube.generator.ts` 394줄). 아래 결정은 2026-08-10
> 착수 문서 [`youtube-kickoff.html`](../../../docs/features/social-generator/youtube-kickoff.html) 에서 10/10 확정됐다.
> 선행 작업(§7) 완료, 실측 확인(§8) 15 unit 실호출로 닫힘.

## 0. 범위

`YoutubeGenerator` 하나. `SocialGenerator` 를 구현하고 `YoutubeFetcher` 를 주입받아
`GenerateResult` 를 돌려준다. 저장·`_id`·링크·토큰은 모른다.

> ⚠️ **구현 전 [`guides/social-generator-rubric.md`](../../social-generator-rubric.md)
> 를 전수 확인한다** (작업 중에는
> [`.html`](../../social-generator-rubric.html)).
> 이 문서는 **YouTube 에만 해당하는 결정**을 담는다 — 8종 공통으로 지켜야 할 R-1~R-18 은 거기 있고,
> 절반은 **어겨도 예외가 나지 않는다.**

**다루는 URL 종류 3개** — `youtube_video`(246건) · `youtube_channel`(427건) ·
`youtube_playlist`(0건). 앞의 둘만 객체를 만들고, playlist 는 `UNSUPPORTED` 로 끝낸다(Y-2).
예약어 경로와 빈 경로도 `UNSUPPORTED` 다(Y-9).

**Venue 가 없다.** 채널이 Creator, 영상이 Content 이고 그 둘뿐이다.

## 1. 왜 YouTube 가 두 번째인가

X 에서 가장 어려웠던 것이 여기엔 전부 없다 — 인용 사슬도, 순환 방어도, `parentRef` 방향도,
유료 게이트도 해당되지 않는다. **묶음이 항상 1개**라 `SocialObjectGraph` 계약의 최소 부하다.

대신 X 가 **공짜로 통과했던 것**이 여기서 처음 걸린다. R-18(같은 객체를 만드는 두 경로의 산출이
같아야 한다)이 X 에서는 트윗의 `author` 가 33키 완전 프로필이라 저절로 성립했는데,
YouTube 는 영상 응답이 채널을 **2필드**로만 준다(§3 Y-1). 즉 **R-18 을 실제로 지켜야 하는
첫 소셜**이다.

그리고 `SocialGeneratorRouter` 가 1/8 에서 2/8 이 되는 첫 증분이라, 등록 배선(providers ·
useFactory 배열 · inject 셋)이 실제로 확장되는지 여기서 확인된다.

## 2. 구조

```ts
@Injectable()
export class YoutubeGenerator implements SocialGenerator {
  readonly platform = SocialPlatform.YOUTUBE;

  constructor(private readonly fetcher: YoutubeFetcher) {}

  async generate(url: string, opts: FetchOptions, observedAt: Date): Promise<GenerateResult>
}
```

**fetcher 하나만 주입받는다.** X 는 셋이었다(`XFetcher` · `XCommunityFetcher` · `XTrendFetcher`).
YouTube 는 `videos.list` 와 `channels.list` 가 같은 fetcher 안에 있어 하나로 끝난다.

`FetchOptions` 는 받되 **읽지 않는다** — `enablePaid` 로 가를 것이 없다. Data API v3 는
전량 무료(일 10,000 units)라 `SKIPPED_PAID` 가 나오는 경로가 없다.

```
generate(url)
  └ normalizeUrl(url) → ParsedUrl
      └ fetcher.classify(parsed) → { sourceType, sourceKey }
          ├ youtube_video    → fromVideo(sourceKey)     // videos.list 1 + channels.list 1
          ├ youtube_channel  → fromChannel(sourceKey)   // channels.list 1~2
          └ youtube_playlist · unknown → UNSUPPORTED
```

## 3. 결정 로그

| ID | 결정 | 근거 |
|----|------|------|
| **Y-1** | **영상 경유로 만나는 채널은 `channels.list` 를 1콜 더 불러 채운다** | 영상 응답은 채널을 `channelId`·`channelTitle` **2필드**로만 준다. 채널 URL 로 들어오면 **7필드**다. R-18 이 정확히 이 상황을 막는다 — `resolveAccount` 가 기존 행을 갱신하지 않아(R-8) **먼저 도착한 경로의 모양이 다음 갱신까지 남고**, 같은 채널인데 어떤 토큰에서 보면 개설일이 있고 다른 토큰에서 보면 없다. 잃는 다섯 중 `publishedAt`(채널 개설일)은 audit 이 **actionable**(*"계정 연령은 러그/사칭 판별 핵심 tell"*)로 판정했고 *"Instagram 엔 없는 축이라 YouTube 의 우위"* 라고 적힌 필드다.<br>**비용이 결정을 가르지 않는다** — `channels.list` 가 1 unit 이고 일 한도가 10,000 units 다. 영상 URL 관측이 **246건**이라 하루치로도 여유가 크다. X-4(커뮤니티 개설자를 1콜 더 부른 것)와 같은 판단이다 |
| **Y-2** | **`youtube_playlist` 는 `UNSUPPORTED`.** 행을 만들지 않는다 | **관측이 0건이다.** X 에서 trending 을 살린 근거가 82건이었던 것과 대비된다 — 0건짜리를 위해 `ContentSubtype` 어휘를 늘리면 값만 늘고 채워지는 행은 없다. `ContentSubtype.UNKNOWN` 주석이 이미 *"YouTube playlist · web — ⚠️ 미지원"* 이라고 적고 있어 **코드 변경이 0**이다.<br>`classify` 는 그대로 둔다 — `youtube_playlist` 로 가르고 `list` 파라미터를 키로 뽑는 것 자체는 옳고(그 분기가 없으면 모든 재생목록 URL 이 리터럴 `'playlist'` 키를 공유하던 옛 버그로 돌아간다), Generator 가 그 종류를 받아 `UNSUPPORTED` 로 끝내면 된다.<br>관측이 생기면 다시 판단한다 — 그때는 X-10·X-11(호출 0회로 행 1건)과 같은 규칙을 쓴다 |
| **Y-3** | **없는·비공개·삭제된 영상은 전부 `NOT_FOUND` 하나로 둔다** | Data API 는 미존재 id 에 **200 + 빈 `items`** 를 준다(fetcher 가 이미 `null` 로 수렴시킨다). 삭제와 비공개를 가르는 신호가 응답에 있는지 **확인되지 않았다.** X-3 에서 `SUSPENDED` 를 신설한 것은 정지 계정이 **별도 응답 쉐입**(`{unavailable:true, …}`)으로 왔기 때문이다 — 신호를 실제로 본 뒤에 어휘를 늘린 것이지 그 반대가 아니다. 구분 신호가 없는데 어휘만 늘리면 **채울 수 없는 값**이 생긴다.<br>확인 자체는 이제 1 unit 이면 된다(키가 생겼다). §8 에 열어 둔다 |
| **Y-4** | **`ContentDataInput` 에 YouTube 키를 더하지 않는다** | audit 이 `categoryId` 를 `remove`, `tags`·`duration` 을 `skip` 으로 판정했다. 그대로 따르면 **더할 키가 없다.** X 가 스레드 3종을 더한 것은 audit 이 그것을 `keep` 으로 판정했기 때문이다(X-6).<br>뒤집으려면 X-8 처럼 **판정 근거가 가치 판단이 아니었다**는 것을 보여야 한다. `duration`(쇼츠/롱폼을 가르는 축) 이 후보지만 지금은 근거가 없다 |
| **Y-5** | **썸네일은 큰 해상도부터 폴백해 <b>1개만</b> 저장한다** (`maxres`→`standard`→`high`→`medium`→`default`). **채널 아바타의 audit `remove` 판정을 뒤집는다** | fetcher 가 5해상도를 전부 보존한 채 넘기고, 코드 주석이 *"어느 것을 대표로 쓸지는 Generator 가 정한다"* 로 결정을 여기까지 미뤄 뒀다. 채널은 `avatarUrl`(문자열 하나), 영상은 `mediaUrls`(배열)로 간다.<br>**폴백 하나로 양쪽이 커버된다** — 실측상 채널 응답에는 `standard`·`maxres` 가 오지 않아 자동으로 `high` 가 되고, 영상은 대개 `maxres` 를 받는다. 전 해상도를 담지 않는 이유는 `mediaUrls` 가 "콘텐츠에 첨부된 이미지" 라는 뜻이어서다 — 같은 그림 5개를 넣으면 **첨부 5건으로 읽힌다.**<br>⚠️ **audit 판정이 두 행에서 뒤바뀌어 있었다.** 채널 아바타를 `remove` 한 사유가 *"channelId 로 언제든 재구성 가능한 파생 성격"* 인데, 실측 URL 을 보면 반대다 — 영상 썸네일은 `i.ytimg.com/vi/{videoId}/hqdefault.jpg` 라 **videoId 로 재구성되고**(그쪽이 `keep`), 채널 아바타는 `yt3.ggpht.com/{불투명 토큰}=s88-c-k-…` 라 **channelId 와 무관하다**(그쪽이 `remove`). 근거가 사실과 어긋나므로 X-8 과 같은 방식으로 뒤집는다.<br>**그래서 담지 않으면 영영 잃는 쪽이 채널 아바타다.** 영상 썸네일은 URL 하나만 남겨도 나머지 해상도를 언제든 만들 수 있다 |
| **Y-6** | **`subscriberCount` → `metrics.followers` 로 통합. 채널 누적 `viewCount` → `data.viewCount`. `AccountMetricsInput` 은 건드리지 않는다** | 배치 기준은 X-13 이 세운 *"독립적인 조회·정렬·비교의 축인가"* 다 — 숫자라서가 아니다. audit 이 `subscriberCount` 를 **actionable**(*"도달 규모 판단축"*), 채널 `viewCount` 를 **context**(*"채널 규모감 정도, 개별 홍보 영상의 즉시 확산력과는 별개"*)로 갈랐고, 그 등급이 그대로 자리가 된다.<br>이름을 `followers` 로 통합하는 이유는 **그릇이 하나이기 때문**이다. 팔로워류가 소셜마다 `fans`(TikTok)·`followersCount`(Instagram·Reddit)·`followers`(X·GitHub)·`subscriberCount`(YouTube) 로 갈리는데, 원래 이름을 병존시키면 *"같은 뜻인데 이름이 다른 값"* 을 쿼리에서 매번 `OR` 로 묶어야 한다. `ContentMetrics` 가 원래 이름을 병존시키는 것과 갈리는데, 그쪽은 **애초에 뜻이 다른 지표들**(`playCount` ≠ `diggCount`)이라 사정이 다르다.<br>⚠️ **횡단 결정이다** — 나머지 5소셜이 같은 축을 만난다. [`decisions.md`](../decisions.md) **G-17** 로 올린다 |
| **Y-7** | **`customUrl` 을 되살려 `handle` 로 쓴다.** audit `remove` 판정을 뒤집는다 | 채널의 핸들을 주는 필드는 `snippet.customUrl`(`@dexerto` 형태) 하나뿐인데 audit 이 `remove` 로 판정해 **fetcher 가 아예 안 돌려준다.** 그러면 `AccountInput.handle` 이 항상 비고, 모델이 `if (input.handle)` 로만 이력을 쌓으므로(`account.model.ts:194`) **`handles` 가 빈 배열로 굳고 `currentHandle()` 이 계속 `null`** 이다 — 갱신 로직이 생겨도 fetcher 가 값을 안 주면 채울 것이 없다. `idx_accounts_handle` 인덱스가 YouTube 계정에는 무용해진다.<br>뒤집는 근거 — `remove` 사유가 *"변경 가능한 mutable alias 라 조인키가 아니다"* 인데 **`handle` 은 조인키로 쓰는 자리가 아니다.** 조인키는 `platformKey`(=`channelId`)이고 `handle` 은 표시·이력용이다. X 도 변경 가능한 `userName` 을 여기에 담는다. 즉 판정은 옳았고 **적용된 자리가 틀렸다.**<br>`RawYtSnippet.customUrl` 은 **이미 선언돼 있다** — 도메인 타입과 매핑만 없다.<br>저장 시 앞의 `@` 를 벗긴다 — X 의 `userName` 이 `@` 없는 형태라 맞춘다 |
| **Y-8** | **채널·영상 `description` 양쪽에서 링크를 추출해 `outboundUrls` 에 담는다** | audit 이 영상 `description` 을 이 소셜에서 **가장 높게** 평가했다 — *"컨트랙트 주소·외부링크 직접 노출 가능성이 있어 현재 커버리지 공백 중 가장 아까운 필드"*. 지금은 `text.body` 에 문자열로만 담기고 링크를 뽑는 주체가 없다.<br>X 에서 똑같은 일이 있었다(X-8) — 바이오 본문은 담으면서 **본문 안의 링크를 버리고 있었고**, 그 링크가 실제로는 `t.me/…` 였다. 계정이 스스로 선언한 타 플랫폼 채널인데 안 보이고 있었다.<br>⚠️ **X 의 방식을 재사용할 수 없다.** X 는 API 가 주는 구조화된 `entities` 에서 꺼냈고(`expandedUrls()`·`profileUrls()`), YouTube `description` 은 **평문**이라 추출을 새로 만들어야 한다. 레포에 평문 URL 추출 유틸이 없다(§7·§8) |
| **Y-9** | **`classify` 의 catch-all 에 예약어 denylist 를 둔다.** 나머지는 채널로 유지한다 | 지금은 매칭되지 않은 **모든** 경로가 마지막 줄에서 `youtube_channel` 로 떨어진다(`youtube.fetcher.ts:176`). `/results?search_query=…`·`/feed/trending` 같은 주소가 채널로 분류돼 **없는 채널을 1 unit 조회한 뒤** `NOT_FOUND` 로 남는다 — 우리 분류 실패가 **정상 관측처럼 보이는** 종류다(R-16 이 막는 것).<br>**전면 `unknown` 은 택하지 않는다** — `youtube.com/{name}` 형태의 레거시 커스텀 URL 이 실제 채널이고 그것까지 막힌다. GitHub 의 예약어 방어와 같은 모양으로 좁힌다.<br>관측 표본에 해당 URL 이 없어 지금 당장의 손실은 0 이지만, 오면 **조용히 틀린 데이터를 만드는** 종류다 — X 에서 `x.com/i/user/{numericId}` 를 관측 0건인데도 먼저 닫은 것과 같은 근거 |
| **Y-10** | **`VALUE_NATURE` 맵을 만들지 않는다** | audit 의 다음 액션에 *"keep 확정 후 `valueNature` 매핑"* 이 있었다. 그런데 코드에는 `TELEGRAM_VALUE_NATURE`·`WEBSITE_VALUE_NATURE` **둘뿐**이고 **그 값을 읽는 코드가 하나도 없다.** 소비처가 없는 선언을 8종으로 늘릴 이유가 없다. 필드별 변동성은 `sources/youtube.html` 의 **변동성** 칸에 문서로 이미 남아 있다.<br>소비처가 생기면 그때 8종을 한 번에 만든다 — 지금 YouTube 만 만들면 선언이 셋이 될 뿐이다 |

### 질문 없이 둔 가정

- **보조 채널 조회(Y-1)의 실패는 흡수하고 `creator: null` 로 떨어뜨린다.** 단
  `QUOTA_EXHAUSTED`·`CREDENTIAL_REJECTED`·`CREDENTIAL_MISSING`, 그리고
  **`UPSTREAM_CONTRACT_BROKEN`** 은 **다시 던진다** —
  X 의 커뮤니티 개설자 조회와 같은 모양이다(`58a63ea`, R-15 예외 + 단서).
  마지막 하나는 **루브릭 표에 없는데 더한 것**이다(2026-08-10 전수 점검) — 표가 아니라
  판정 문장을 적용했다. 그 코드의 정의가 *"200 인데 계약이 깨졌다, 사람이 고쳐야 한다"* 라
  정의상 "다음 호출에도 똑같이 일어날 성질" 이고, 도달 경로도 실재한다(`fetchChannel` 의
  `requireKey(item.id)`). 흡수하면 그 시간대의 **모든 영상이 `creator: null` 로 굳는다**.<br>
  ⚠️ **`RATE_LIMITED` 는 흡수 쪽에 남겨 뒀다**(루브릭 표 그대로). 다만 YouTube 의 rate limit 은
  대상별이 아니라 **API 키별**이라, 한 번 걸리면 그 구간의 영상들이 함께 채널을 잃는다.
  관측되면 이 판단을 다시 본다
  판정 문장이 통과한다 — *"그 호출이 실패해도 남는 값이 있는가"* → 영상 콘텐츠가 남는다.
  **껍데기 2필드 계정을 만드는 대신 계정을 아예 만들지 않는 쪽**이라 G-6 도 지킨다
- `outboundUrls` 중 `normalizeUrl()` 이 실패한 값은 **버린다** — 라우터의 URL 정책과 같다(v5 규칙 ②)
- `knownWallets` · `data.accountType` · `data.sourceRef` · `data.declaredHandles` 는
  YouTube 에서 채우지 않는다(§5)
- `subscriberCount` 가 비공개인 채널은 `metrics.followers` 를 **비운다** — fetch 실패가 아니라
  소스 자체의 데이터 갭이다(공식 API 문서가 인정하는 케이스)
- `unavailable` 은 채우지 않는다 — 채우는 경로가 없는 것은 8종 공통 한계다(H-008)

## 4. URL 종류별 산출

| 진입 | 외부 호출 | 묶음 | `rootRef` | status |
|------|-----------|------|-----------|--------|
| `youtube_video` (정상) | `videos.list` 1 + `channels.list` 1 | `{ content, creator }` 1개 | 그 묶음 | `OK` |
| `youtube_video` (채널 조회 실패) | `videos.list` 1 + `channels.list` 1 | `{ content }` 1개 | 그 묶음 | `OK` (흡수 · 위 가정) |
| `youtube_video` (없음·비공개·삭제) | `videos.list` 1 | 없음 | — | `NOT_FOUND` (Y-3) |
| `youtube_channel` (`UCxxx`) | `channels.list` 1 | `{ creator }` 1개 | 그 묶음 | `OK` |
| `youtube_channel` (handle · `/user` · `/c`) | `channels.list` 1~2 | `{ creator }` 1개 | 그 묶음 | `OK` |
| `youtube_channel` (없음) | `channels.list` 1~2 | 없음 | — | `NOT_FOUND` |
| `youtube_playlist` | **0** | 없음 | — | `UNSUPPORTED` (Y-2) |
| 예약어 경로 · 빈 경로 · `unknown` | **0** | 없음 | — | `UNSUPPORTED` (Y-9) |

**묶음은 항상 1개다.** 인용·리트윗에 해당하는 개념이 YouTube 에 없어 `parentRef` 를 걸 대상이
없고, 따라서 사슬도 순환도 생기지 않는다. `rootRef` 는 언제나 그 하나의 묶음이다.

```
묶음 A: content(video) + creator(channel)     ← venue 는 없다
rootRef = A
parentRef = null
```

`link_depth` 는 전부 0 이다 — Writer 가 사슬에서의 자리로 계산하는데(`social-graph.writer.ts:103`)
사슬 길이가 1 이기 때문이다. X 에서 문제가 됐던 `capDepth()` 의 깎임(X-1)이 여기선 해당 없다.

## 5. 필드 매핑

### AccountInput ← `YoutubeChannel`

| 대상 | 출처 | 비고 |
|------|------|------|
| `platform` | 상수 `YOUTUBE` | |
| `platformKey` | `channelId` | 어느 URL 형태로 들어와도 응답 `item.id` 로 **수렴**한다 |
| `handle` | `customUrl` 에서 앞 `@` 제거 | 🆕 Y-7. audit `remove` 를 뒤집었다 |
| `displayName` | `title` | |
| `accountCreatedAt` | `publishedAt` | audit: **actionable** — 러그리스크 tell. Instagram 엔 없는 축 |
| `bio` | `description` | audit: context |
| `outboundUrls` | `description` 평문에서 추출 → 각각 `normalizeUrl()`, 실패분 버림 | 🆕 Y-8. **단축 주소는 풀지 않고 그대로 담는다**(G-18 스코프 밖) |
| `avatarUrl` | `thumbnails` 폴백 1개 → `normalizeUrl()` | 🆕 Y-5. 실측 확인 — 채널은 `high` 가 최대다 |
| `metrics.followers` | `subscriberCount` | Y-6. 이름을 통합한다. ⚠️ **큰 수만 반올림돼 온다** — 작은 채널은 정확값이다(G-17) |
| `data.contentCount` | `videoCount` | X 의 `statusesCount` 와 같은 자리 |
| `data.viewCount` | `viewCount` (채널 누적) | 🆕 Y-6. 조회축이 아니라 배경정보(audit: context) |
| `observedAt` | 인자 | |
| ~~`data.isVerified`~~ | — | **Data API 가 주지 않는다.** apify(미채택)에만 `isChannelVerified` 가 있다 |
| ~~`data.following` · `badges` · `accountType` · `sourceRef` · `declaredHandles`~~ | — | YouTube 응답에 해당 개념이 없다 |
| ~~`knownWallets`~~ | — | 응답에 없음 |
| ~~`unavailable`~~ | — | 채우는 경로가 없다(H-008) |

⚠️ **`viewCount` 라는 이름이 두 객체에서 다른 구역으로 간다** — Content 에서는 `metrics.viewCount`
(영상 조회수, **actionable**), Account 에서는 `data.viewCount`(채널 누적, **context**)다.
같은 단어지만 audit 등급이 갈렸고, 그 등급이 자리를 정한다(Y-6).

### ContentInput ← `YoutubeVideo`

| 대상 | 출처 | 비고 |
|------|------|------|
| `platform` · `subtype` | `YOUTUBE` · `ContentSubtype.VIDEO` | enum 에 이미 있다 |
| `platformKey` | `videoId` | **대소문자를 구분한다** — 소문자화하면 다른 영상이 된다 |
| `publishedAt` | `publishedAt` | audit: actionable — 신선도·카피캣 |
| `text.primary` | `title` | |
| `text.body` | `description` | audit: **actionable** — CA 가 담기는 자리 |
| `outboundUrls` | `description` 평문에서 추출 → `normalizeUrl()` | 🆕 Y-8. 실측 영상 2건에서 **11개·5개** 나왔다. 단축은 안 푼다(G-18 스코프 밖) |
| `mediaUrls` | `thumbnails` 폴백 1개 → `[url]` | 🆕 Y-5. 원소 **1개**다. 실측상 영상은 `maxres` 가 온다 |
| `metrics` | `viewCount` · `likeCount` · `commentCount` — **3종 전부 자리가 있다** | `ContentMetricsInput` 주석이 *"YouTube 와 공용"* 이라고 미리 적어 뒀다 |
| `tags` | `[]` | `snippet.tags` 는 audit `skip` |
| `mentions` | `[]` | YouTube 에 멘션 개념이 없다 |
| `data` | **없음** | Y-4 |
| `parentContentId` · `parentRelation` | **없음** | 인용·리트윗 개념이 없다 |
| `creatorId` · `venueId` | **비운다** | Writer 가 저장 후 채운다(R-12) |
| ~~`text.fromMedia`~~ | — | 해당 없음 |
| ~~`channelTitle`~~ | — | **일부러 안 쓴다.** Y-1 이 채널을 1콜 더 부르므로 정상 경로에서는 `channel.title` 과 중복이고, 실패 경로에서 이것만 담으면 Y-1 이 금지한 **2필드 껍데기 계정**이 된다(G-6) |

⚠️ 지표는 **필드 단위로 명시 매핑**한다. 스프레드로 넘기면 초과 속성 검사가 안 걸리고
mongoose strict 가 모르는 키를 조용히 버린다(R-10).

## 6. 실패 표현

- **대상 없음** → `NOT_FOUND` · `graph: null` · `attempted: true`
  (Data API 는 **200 + 빈 `items`** 를 주고 fetcher 가 `null` 로 수렴시킨다)
- **호출 실패** → **삼키지 않고 그대로 던진다.** Processor 가 `ExternalFetchError` 를 코드별로
  처리하고 URL 단위로 격리한다
- **보조 채널 조회 실패**(Y-1) → **흡수**하고 `creator: null`.
  단 `QUOTA_EXHAUSTED`·`CREDENTIAL_REJECTED`·`CREDENTIAL_MISSING` 은 **다시 던진다**
- **playlist · 예약어 · `unknown`** → `UNSUPPORTED` · `attempted: false`
- **`SKIPPED_PAID` 는 나오지 않는다** — Data API v3 가 전량 무료다
- **`SUSPENDED` 도 나오지 않는다** — 구분 신호가 없다(Y-3)

`attempted` 는 **외부를 실제로 불렀는가**다. `attempts` 집계의 유일한 근거라 추정하지 않는다.

`mapYoutubeError()` 가 403 세 갈래를 이미 가른다(`youtube.fetcher.ts:51`) —
할당량 소진 · rate limit · 키 거부. Generator 는 그 분류를 소비만 한다.

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

> 상태(2026-08-10): **전부 완료.** `tsc`·`nest build`·전체 단위테스트 **653건**·`lint` 0 errors.

1. ✅ **`YoutubeChannel.customUrl` 추가 + 매핑** (Y-7) — `RawYtSnippet.customUrl` 은
   **이미 선언돼 있어서** 도메인 타입 필드와 `fetchChannel()` 매핑 한 줄만 더했다.
   fetcher 레벨은 **원본 이름 그대로**(`customUrl`), 통일 이름(`handle`)과 `@` 벗기기는
   Generator 에서만 한다(`image-asset-fields/design.md` §4.5 · D-022)
2. ✅ **평문 URL 추출 + `outboundUrls` 신설** (Y-8) — `YoutubeChannel.outboundUrls` ·
   `YoutubeVideo.outboundUrls` 와 `descriptionUrls()`.
   **X 의 방식을 재사용할 수 없었다** — `expandedUrls()`·`profileUrls()` 는 API 가 주는
   구조화된 `entities` 에서 꺼내는데 YouTube 설명란은 평문이다.
   **공용 자리로 올리지 않았다** — 텔레그램도 평문이 아니라 HTML `href` 구조에서 뽑고 있어
   실제 소비자가 아직 하나다(§8 의 판단 그대로). 둘이 되면 그때 올린다.
   중복 제거 · 문장부호 제거 · 괄호 제외를 하고, **단축은 풀지 않는다**(G-18)
3. ✅ **`classify` 예약어 denylist** (Y-9) — `RESERVED_PATHS` 23개.
   `results`·`feed`·`hashtag`·`post`·`about` 등을 `unknown` 으로 보낸다.
   **화이트리스트로 좁히지 않았다** — `youtube.com/{name}` 레거시 커스텀 URL 이 살아야 한다
4. ✅ **`AccountDataInput.viewCount` 추가** (Y-6) — 세 곳을 함께 고쳤다.
   빠뜨리면 **mongoose strict 가 조용히 버린다**(프로세스 §5)
   - `social-graph.types.ts` → `AccountDataInput.viewCount?: number`
   - `account.model.ts` → `AccountData` 에 `@prop view_count?: number`
   - `account.model.ts` → `Account.toData()` 에 `view_count: source.viewCount`
5. ✅ **audit 판정 정정** — `scaffold/youtube.json` 의 두 행을 `remove` → `keep` 으로 뒤집고
   사유를 함께 남겼다(Y-5 채널 썸네일 · Y-7 `customUrl`). `liveTests` 에 실호출 3건도 기록했다.
   ⚠️ JSON 이 원천이고 `sources/youtube.html` 은 재렌더 전까지 뒤처진다
6. ✅ **Router 등록** — `SocialRecordingModule` 의 `providers` · `useFactory` 배열 · `inject`
   **셋 다** 더했다. 라우터가 2/8 이 됐다.<br>
   🔴 **여기서 한 곳을 더 고쳐야 했다** — `SocialFetcherModule` 의 **`exports` 에 `YoutubeFetcher`
   를 더하는 것**이다. 모듈 주석이 *"빠뜨리면 컴파일은 통과하고 **부팅에서** `UnknownDependencies`
   로 터진다 — `XGenerator` 가 정확히 그렇게 터졌다"* 고 경고해 둔 자리이고, 실제로 같은 자리였다.
   `tsc`·`nest build` 로는 안 잡힌다
7. ✅ **횡단 결정 승격** — [`decisions.md`](../decisions.md) 에 **G-16**(이미지 자산 매핑) ·
   **G-17**(계정 지표 그릇) · **G-18**(단축 링크 해제)을 올렸다.
   G-18 은 이 라운드 스코프 밖이라 **확정 · 미구현**으로 남겼다

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

> **키가 있다.** X 때와 달리 실호출로 닫을 수 있다 — `channels.list`·`videos.list` 가
> 각 **1 unit** 이고 일 한도가 10,000 units 다. 아래 넷을 **한 번에** 확인할 수 있다.

### ✅ 실호출로 닫았다 (2026-08-10 · 15 unit)

- [x] **`snippet.customUrl` 이 실제로 오는가** (Y-7 의 전제) — **4/4 전부 왔다.**
      `@mrbeast` · `@pewdiepie` · `@knowyourmeme` · `@dexerto`. 전부 `@` 접두 형태라
      벗기는 처리가 맞다. 조회 경로 3종(`forHandle`·`forUsername`·`id`) 어디로 들어와도 온다
- [x] **삭제·비공개 영상의 응답 모양** (Y-3) — **HTTP 200 + `items: []`.**
      구분 신호가 없다. Y-3(NOT_FOUND 하나)이 실측으로 확정됐다.<br>
      ⚠️ **id 를 여러 개 요청하면 없는 id 가 응답에서 조용히 빠진다** — 2개 요청에 1개만 왔다.
      지금은 단건 조회라 안전하지만, **배치 조회로 바꾸면 요청 순서와 응답 순서를 인덱스로
      맞추는 순간 조용히 틀린다.** 그때는 `item.id` 로 되짚어야 한다
- [x] **`subscriberCount` 비공개 채널의 응답** — `hiddenSubscriberCount` 는 **항상 오고
      값이 boolean** 이다(관측분은 전부 `false`). 부재가 아니라 `false` 다.
      `true` 인 채널은 아직 못 봤으므로 `subscriberCount` 결측 처리는 여전히 대비 코드로 둔다
- [x] **audit 이 문서 지식으로만 적은 필드의 실제 존재** — 매핑 대상 전부가 응답에 있었다.
      `favoriteCount` 는 audit 주장대로 **둘 다 0** 이었고, `dislikeCount` 는 키 자체가 없다.
      audit 이 스스로 최대 한계로 적은 *"Data API v3 라이브 미검증"* 이 닫혔다
- [x] **썸네일 해상도 분포** (Y-5 폴백의 근거) — 채널은 `default·high·medium` **셋**,
      영상은 `default·high·maxres·medium·standard` **다섯**. 폴백이 채널에서 자동으로
      `high` 가 되고 영상에서 `maxres` 가 된다
- [x] **채널 아바타 URL 이 불투명한가** (Y-5 뒤집기의 근거) — **그렇다.**
      `yt3.ggpht.com/nxYrc_1_2f77…=s88-c-k-c0x00ffffff-no-rj` 형태로 channelId 와 무관하다.
      영상 썸네일은 `i.ytimg.com/vi/{videoId}/maxresdefault.jpg` 라 videoId 로 재구성된다.
      **audit 이 `remove` 사유로 쓴 "재구성 가능" 이 반대쪽에 적용돼 있었다**
- [x] **`description` 에 링크가 실제로 있는가** (Y-8 의 근거) — **영상 쪽이 강하다.**
      영상 2건이 11개·5개, 채널 3건이 3개·0개·0개였다. 건진 것이 정확히 X-8 패턴이다 —
      `x.com/DTFA` · `instagram.com/DTFA` · `facebook.com/…` · `linkedin.com/…`

### 🔴 실측이 새로 연 것

- [x] **`subscriberCount` 는 큰 수만 반올림돼 온다** — 실호출에서 `512000000`·`110000000`·
      `528000`·`881000` 이 전부 3자리라 "항상 반올림" 으로 적었는데, **실데이터가 그것을
      좁혔다** — 구독자 27명인 채널이 `27` 로 정확히 왔다.
      즉 큰 채널에서만 해상도가 떨어진다. **신생 토큰의 홍보 채널은 대개 작은 쪽이라
      실무상 손실이 크지 않다.** Y-6(통합)은 유지한다 — 뜻이 같기 때문이다.
      횡단 각주로 G-17 에 올렸다.<br>
      ⚠️ **표본이 대형 채널로 치우쳐 있었다는 것이 교훈이다** — 실호출 대상을 유명 채널로만
      고르면 그 계층의 성질이 전체 성질로 기록된다
- [ ] **`description` 링크에 단축 주소가 섞인다** (Y-8 의 한계) — 실측에서
      `bit.ly/…` · `RickAstley.lnk.to/…` · `linktr.ee/…` · `mrb.gg/…` 가 나왔다.
      **이번 라운드는 풀지 않고 원본 그대로 담는다.** 해제 정책은 **G-18** 이 갖고,
      그 스코프는 진입 URL 뿐이다 — 본문 링크는 건당 수가 자릿수 다르고(영상 1건에 11개)
      마케팅 추적 링크라 **여는 순간 상대 카운터가 올라간다.**
      → `outbound_urls` 에 단축이 섞인 채로 남고, `tokens.social_urls[].url` 과의
      문자열 대조(`content.model.ts:141`)가 그만큼 덜 맞는다. **알려진 한계로 둔다**

### ⏳ 구현 중 판단

- [x] **평문 URL 추출 유틸을 어디에 두나** (Y-8) — **공용으로 승격 완료**(정정, 2026-08-11).
      `social-fetcher.util.ts` 의 `extractUrls()` 가 그것이다. Instagram Generator 착수 때
      소비자가 둘(YouTube·Instagram)이 되면서 예정대로 올라갔고, 이후 TikTok 도 같은 함수를
      쓴다 — **지금은 소비자 3개.**
- [x] **예약어 denylist 의 정확한 목록** (Y-9) — **23개로 확정 완료**(정정, 2026-08-11).
      `youtube.fetcher.ts` 의 `RESERVED_PATHS` — `results`·`feed`·`hashtag`·`post`·`source`·
      `redirect`·`attribution_link`·`account`·`reporthistory`·`upload`·`oops`·`error`·
      `signin`·`logout`·`verify_age`·`timedtext`·`about`·`howyoutubeworks`·`creators`·`ads`·
      `t`·`new`·`premium`. 못 잡은 예약어는 기존 동작(채널 조회 1회 후 `NOT_FOUND`)으로
      떨어져 안전하다는 원래 설계 그대로 유지

### ✅ 이미 닫힌 것

- [x] **`thumbnails` 구조** — 2026-08-08 실호출(T-006). 채널·영상 둘 다
      `{ default·medium·high(·standard·maxres) : { url, width, height } }`.
      **채널에는 `standard`·`maxres` 가 오지 않는다** — Y-5 폴백의 근거
- [x] **채널 alias resolve** — `resolveChannel()` 이 `forHandle` → `forUsername` 폴백으로
      해결한다. audit §2.2 가 채널 5형태를 `wrong` 으로 판정했던 것은 **그 이전 스냅샷**이다
- [x] **`ContentMetricsInput` 에 3종 자리** — `viewCount`·`likeCount`·`commentCount` 가
      이미 있고 주석이 *"YouTube 와 공용"* 이라고 적고 있다. **새로 만들 지표 자리가 0개다**
- [x] **host 커버리지** — `music.youtube.com`·`youtube-nocookie.com` 포함 5개가 등록돼 있다.
      audit 이 갭으로 지적했던 것은 닫혔다

## 9. 구현 순서

1. §7 의 `1`~`4` (fetcher · classify · 스키마) — 각각 별도 커밋
2. §8 의 실호출 4건 — 한 번에 돌리고 결과를 `scaffold/youtube.json` 에 기록
3. `YoutubeGenerator` — `fromChannel` 먼저(단순), `fromVideo` 다음(보조 호출 포함)
4. Router 등록(§7 `6`) — 여기서 2/8 이 된다
5. §7 `5`·`7` (문서 정정 · 횡단 결정 승격)
6. 루브릭 Part 3 체크리스트 23항목 전수 + 적대적 입력

## 10. 테스트

X 에서 단위 테스트 23개가 전부 통과하는 상태로 **자기 인용 트윗에 힙이 터졌다.** 정상 입력만
넣었기 때문이다. 그래서 아래는 정상 경로와 **결손·적대적 입력**을 나눈다.

**정상 경로**
- URL 종류마다 묶음 개수와 `rootRef` (§4 의 8행 전부)
- 영상 경유와 채널 경유가 **같은 채널 객체를 낸다** — 필드를 하나씩 비교하지 말고
  **객체를 통째로 비교한다**(R-18. 하나씩 보면 나중에 추가되는 필드에서 다시 갈린다)
- `thumbnails` 폴백 — `maxres` 있음 / `high` 까지만 있음(채널) / 전부 없음 세 경우
- `customUrl` 의 `@` 제거

**결손·적대적**
- `thumbnails` 가 통째로 `null`
- `description` 이 빈 문자열 · 링크 0개 · 정규화 실패 URL 만 있음
- `subscriberCount` 결측 → `metrics.followers` 가 비는가 (0 이 아니라)
- 보조 채널 조회가 `NOT_FOUND` → `creator: null` 로 떨어지고 **content 는 남는가**
- 보조 채널 조회가 `QUOTA_EXHAUSTED` → **다시 던지는가**
- playlist · 예약어 URL → `UNSUPPORTED` · `attempted: false`
- 실물 `SocialGraphWriter` 에 산출을 통과시킨다 — R-1·R-3·R-4·R-5 는 Writer 가 런타임에
  던지는 규칙이라 단위 테스트로는 안 잡힌다
  ✅ `test/unit/social-recording.spec.ts` 의 `YoutubeGenerator → SocialGraphWriter 통과` 3건

## 11. 루브릭 전수 점검 (2026-08-10)

Part 3 체크리스트 23항목을 훑었다. **자기 코드는 자기가 못 보므로** 독립 감사를 함께 돌려
두 결과를 대조했다. **위반 1건 · 개선 3건**이 나왔고 전부 닫았다.

### 🔴 R-16 위반 — `classify` 가 키 없이 구체 종류를 답했다

여덟 경로가 `{ sourceType: 구체값, sourceKey: null | '' }` 을 냈다 —
`watch`(v 없음/빈 값) · `youtu.be/` · `@` 단독 · `playlist`(list 없음) ·
`shorts/` · `channel/` · `user`. `strictNullChecks: false` 라 **전부 컴파일을 통과했다.**

**무슨 일이 났는지가 핵심이다.** Generator 는 R-16 을 믿고 `if (!route.sourceKey)` 분기를
두지 않는다(루브릭이 그 분기를 금지한다). 그래서 `fetchVideo(null)` 이 그대로 나가고,
axios 가 `null` 파라미터를 드롭해 **필수 필터 없는 요청**이 Data API 로 간다.
응답은 **400** 이고 그것은 `UPSTREAM_CONTRACT_BROKEN` 으로 분류돼 `ExternalFetchError` 로
올라간다. 결과적으로 그 URL 이 **`ERROR` 로 기록된다.**

즉 **우리 분류 실패가 "수집 실패" 로 둔갑한다.** 재수집 배치가 생기면 영원히 성공할 수 없는
URL 을 계속 두드리게 된다. 정답은 `UNSUPPORTED` · 호출 0회다.

고친 자리는 **fetcher** 다 — R-16 이 *"지켜야 하는 쪽은 `classify()` 다"* 라고 못박았고,
Generator 에 가드를 넣는 것은 루브릭이 금지한 도달-불가 분기를 만드는 일이다.
`route(sourceType, key)` 헬퍼가 키가 비면 `unknown` 을 돌려주도록 해 **구조적으로** 닫았다.

부수 효과로 `/shorts` `/channel` `/c` `/user` 의 **id 없는 형태가 채널명으로 오분류되던 것**도
함께 닫혔다(종전: `youtube.com/shorts` → 채널 `"shorts"` 조회로 최대 2 unit 낭비).
`&& segments[1]` 가드를 걷어내고 `route()` 에 맡겼기 때문이다.

### 개선 3건

- **R-15 단서 — `UNABSORBABLE` 에 `UPSTREAM_CONTRACT_BROKEN` 추가.** 근거는 §3 가정 목록에
- **§5 매핑표에 `~~channelTitle~~` 행 추가** — "빠뜨린 것" 과 "일부러 뺀 것" 이 표에서
  구분되지 않았다. `YoutubeVideo` 11필드 중 유일하게 안 쓰는 값이다
- **관측성** — `fetchChannel` 이 `null` 을 줄 때 로그가 없어 *"채널이 삭제됐다"* 와
  *"보조 호출을 안 했다"* 가 구분되지 않았다. `warn` 을 추가했다

### 통과 확인한 항목

R-1·R-2·R-3·R-5·R-6(묶음 1개라 구조적 성립) · R-4 · R-7 · R-8(fetcher 필드 전수 대조,
`channelTitle` 하나만 의도적 제외) · R-9(`*Url` 4곳 전부 정규화) · R-10(스프레드 0) ·
R-11 · R-12 · R-13(`OK` + `graph: null` 조합 없음) · R-14(보조 호출만 하고 본 호출을 안 한
경로 없음) · R-15 본체 · R-17 · R-18(두 경로가 같은 `fetchChannel` → 같은 `toAccount`) ·
배선(`exports` · `providers` · `useFactory` · `inject` 넷 다).
