# TikTok Generator — 구현 계획

> 상태: **구현 완료 · 루브릭 전수 점검 완료 · 라이브 파이프라인 검증 완료**(§8 · §11).
> 아래 결정은 2026-08-10 착수 문서
> [`tiktok-kickoff.html`](../../../docs/features/social-generator/tiktok-kickoff.html) 에서 **9/9 전부 확정**됐다.
> 착수 뒤 **비용 0의 실측**(§8)이 T-5·T-6·T-7 을 한 번에 닫았다.

## 0. 범위

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

> ⚠️ **구현 전 [`guides/social-generator-rubric.md`](../../social-generator-rubric.md)
> 를 전수 확인한다.** 이 문서는 **TikTok 에만 해당하는 결정**을 담는다.

**다루는 URL 종류 4개** — `tiktok_profile`(273건 · 61%) · `tiktok_video`(118건) ·
`tiktok_search`(57건) · `tiktok_shortlink`(7건).
앞 셋이 객체를 만들고 **`tiktok_shortlink` 는 `UNSUPPORTED` 다**(T-3).
검색은 **호출 0회**로 행 1건이다(T-2).

**Venue 가 없다.** 프로필이 Creator, 게시물이 Content 이고 그 둘뿐이다.

## 1. 왜 TikTok 이 네 번째인가

**착수 상태가 가장 좋다.** 유료 실측이 이미 5건 있고, 무료 shortlink 리다이렉트가
6/6 전부 301 로 풀리는 것도 실측돼 있다. 그리고 지표 6종
(`playCount`·`diggCount`·`commentCount`·`shareCount`·`collectCount`·`repostCount`)이
`ContentMetricsInput` 에 **이미 전부 선언돼 있어 새로 만들 지표 자리가 0개**다.

### 🎉 R-18 이 공짜로 성립한다 — 이번 소셜의 가장 큰 차이

YouTube(Y-1)와 Instagram(I-2)에서 가장 무거웠던 결정 —
**게시물 경유로 만난 계정을 어떻게 채우나** — 이 여기서는 **결정이 아니다.**

TikTok 은 게시물 응답의 `authorMeta` 가 프로필과 **동일 서브스키마**이고,
fetcher 가 **양쪽을 같은 `toProfile()` 로 통과**시킨다. `fetchProfile` 조차 내부적으로
*"최신 영상 1건 + authorMeta"* 를 받아 같은 함수를 쓴다.

즉 **추가 호출 0회로 두 경로의 산출이 문자열까지 같다.** X 와 같은 상황이고,
YouTube Y-1(1 unit 추가) · Instagram I-2($0.48 추가)가 여기선 필요 없다.

### 이미 정해진 횡단 규칙 넷이 따라온다

**G-16**(이미지 매핑) · **G-17**(지표 그릇) · **G-18**(단축 링크) · **G-21**(검색어 정규화).
넷 다 앞선 소셜에서 확정됐으므로 **여기서는 결정이 아니라 적용**이다.
G-21 은 이미 코드에 반영돼 있다 — TikTok 이 `toLowerCase()` 만 하던 것을
Instagram 착수 때 공용 `normalizeFreeText()` 로 바꿨다.

## 2. 구조

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

  constructor(private readonly fetcher: TiktokFetcher) {}

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

```
generate(url)
  └ normalizeUrl(url) → ParsedUrl
      └ fetcher.classify(parsed) → { sourceType, sourceKey }
          ├ tiktok_search    → fromSearch(key)      // 호출 0회 — 게이트 이전
          ├ tiktok_profile ┐
          ├ tiktok_video   ┴→ 유료 게이트 OFF? → SKIPPED_PAID · 호출 0회   ← T-1
          │                   ON  → fromProfile / fromVideo
          ├ tiktok_shortlink → UNSUPPORTED         // T-3 — 게이트 이전
          └ unknown          → UNSUPPORTED
```

⚠️ **게이트는 `switch` 안쪽, 유료 종류에만 건다**(T-1). Instagram 에서 한 번 틀린 자리다 —
게이트를 종류 판정보다 앞에 두면 `unknown` URL 까지 `SKIPPED_PAID` 로 나가고,
`enablePaid` 기본값이 `false` 라 **그것이 기본 동작이 된다.**
`paid()` 헬퍼는 `InstagramGenerator` 의 것과 같은 모양을 쓴다.

## 3. 결정 로그

| ID | 결정 | 근거 |
|----|------|------|
| **T-1** | **유료 게이트를 Generator 가 판정한다.** 꺼져 있으면 **호출 0회**로 `SKIPPED_PAID`. 게이트는 **종류 판정 뒤**, 유료 종류에만 건다 | Instagram I-1 과 같다. TikTok 은 전 종류가 Apify 유료($0.0047/run)라 같은 자리다.<br>**두 소셜이 같은 모양이 되면 그 자체가 횡단 규칙의 근거다** — `decisions.md` **G-20** 으로 승격하고 Reddit 이 마지막으로 물려받는다.<br>⚠️ 검색(T-2)과 shortlink(T-3)는 **호출 0회**라 게이트보다 앞에서 끝난다. 돈이 안 드는 것을 돈 때문에 막으면 `SKIPPED_PAID` 가 *"비용 때문에 건너뛰었다"* 는 뜻을 잃는다 |
| **T-2** | **검색 57건을 호출 0회로 콘텐츠 행 1건**으로 만든다 | G-4 규칙 그대로다. X(196건)·Instagram(1건)과 **산출이 필드 단위로 같다** — `creator: null` · `subtype: SEARCH` · `platformKey = text.primary = 정규화 검색어` · `attempted: false`.<br>`tiktok_search` 분류와 `normalizeFreeText()`(G-21)는 **이미 코드에 있다.** `/search?q=` 와 `/search/video?q=` 가 한 분기로 들어온다.<br>⚠️ 검색 결과를 **유료로 받을 수 있다는 것은 실측됐다**($0.038/10건 · §8). 그럼에도 안 부르는 이유는 재호출마다 결과가 달라 중복이 쌓이고, X·Instagram 과 규칙이 갈리기 때문이다 |
| **T-3** | **`tiktok_shortlink` 는 `UNSUPPORTED` 다.** Generator 가 풀지 않는다 | qna Q5 가 *"상류 수집 단계에서 canonical 정규화(fetcher 단이 아님)"* 로 결론냈고 그것이 **G-18** 이 됐다.<br>⚠️ **G-18 이 구현돼도 이 타입이 비지는 않는다.** G-18 은 *"실패분은 원본 그대로 통과"* 를 명시한다 — 만료·삭제된 shortlink 는 계속 온다. 타입 주석의 *"정규화 실패 잔여분 안전망"* 이 그 뜻이고, 지금은 상류가 없어 **전량이 잔여분**일 뿐이다.<br>Generator 가 임시로 푸는 것은 **Q5 가 명시적으로 반대한 것**이다 — 유료 스킵 엔트리가 영원히 shortlink 로 남아 dedup 이 분기한다 |
| **T-4** | **`subtype` 은 `isSlideshow === true` 일 때만 `PHOTO`, 그 외(`false`·`null`)는 `VIDEO`** | `isSlideshow` 가 **유일한 구분자**다(타입 주석). photo 와 video 가 한 네임스페이스로 병합돼 있고(qna Q4) `ContentSubtype` 에 `PHOTO`·`VIDEO` 가 이미 둘 다 있다. **스키마 변경 0.**<br>관측의 대부분이 영상(104 vs 11)이고 실측 10건에서도 9:1 이다. `null` 을 "모른다" 가 아니라 지배적 기본값으로 흡수한다 — Instagram I-8 과 같은 모양이다. `ContentSubtype.UNKNOWN` 은 *"미지원"* 의미로 쓰이고 있어 쓰면 뜻이 섞인다.<br>⚠️ **R-11** — `subtype` 은 정체성이 아니라 상태다. 조회 키에 넣지 않는다 |
| **T-5** | **`slideshowImageLinks` 만 담는다**(`mediaUrls` ← `tiktokLink`). `mentions`·`detailedMentions`·`subtitleLinks` 는 **비운다** | 착수 시점엔 넷 다 `unknown[]` 이었고 *"실측으로 닫는다"* 를 골랐다. **비용 0으로 닫혔다**(§8) — 다른 세션이 남긴 유료 실측 10건이 있었다.<br>**`slideshowImageLinks` = `{tiktokLink, downloadLink}[]`** 확인. `isSlideshow=true` 인 1건에 15개가 왔고, **같은 건의 `mediaUrls` 원본은 빈 배열**이라 *"photo 게시물의 유일한 이미지 경로"* 가 실증됐다.<br>**`mentions`·`detailedMentions` 는 10/10 빈 배열** — audit 샘플에 이어 두 번째 독립 표본이다. 원소 구조를 모르면 `MentionInput.platformKey`(필수)를 만들 수 없어 **매핑이 불가능**하다. Instagram I-3 과 같이 백필 후보로 남긴다.<br>**`subtitleLinks` 는 구조가 나왔지만 비운다**(`{language, downloadLink, tiktokLink, source, ...}[]`, ASR 자막). 내용이 **링크지 텍스트가 아니라** T-6 과 같은 성격이고, 같은 원칙을 적용한다 |
| **T-6** | **`transcriptionLink` 를 `text.fromMedia` 에 넣지 않는다.** 비워 둔다 | `ContentTextInput.fromMedia` 주석이 *"ig `alt` · tt `transcriptionLink`"* 로 이 자리를 명시하지만, **그것은 링크지 텍스트가 아니다.** IG `alt` 는 응답에 텍스트가 그대로 담겨 있었고 이쪽은 한 번 더 받아와야 한다.<br>**링크를 텍스트 자리에 넣으면 조용히 틀린 데이터**가 된다 — CA 검색이 URL 문자열을 훑고, 사람이 읽을 때 전사가 있는 줄 안다.<br>🔴 **실측이 더 강한 근거를 줬다** — `videoMeta.transcriptionLink` 가 **10/10 `null`** 이다. 링크냐 텍스트냐를 따지기 전에 **채워진 적이 없다.**<br>전사 취득은 만료 정책 검증이 먼저라 별도 결정으로 뺀다 |
| **T-7** | **`verified` 를 fetcher 타입에 추가하고 `data.isVerified` 로 담는다.** `privateAccount` 는 담지 않는다 | audit 이 `verified` 를 `remove` 로 판정했지만 **Instagram I-7 이 정확히 같은 판정을 뒤집었다.** audit 의 사유가 그 필드에 실제로 적용되는지를 따로 보는 것이 X-8 이래의 규칙이고, IG `verified` 와 YouTube `customUrl` 이 둘 다 그렇게 살아났다.<br>**실측 확인** — `authorMeta.verified` 가 bool 로 10/10 존재한다. 자리는 `AccountData.isVerified` 로 X 가 이미 만들어 뒀다. **모델 스키마 변경 0.**<br>⚠️ `privateAccount` 는 audit `skip` 이고, `Account.unavailable` 은 **정지·삭제·비공개를 한 값으로 덮는** 자리라 뜻이 다르다 — 비공개 계정은 살아 있다. 담지 않는다 |
| **T-8** | **`heart` → `data.heart`, `musicId`·`isAd` → `ContentDataInput`. `webVideoUrl` 은 버린다** | **G-17** — Account 는 audit 등급이 자리를 정한다. `heart` 는 audit **`noise`**(*"fans/video 대비 redundant, 별도 정보이득 낮음"*)라 `metrics` 가 아니다. 게다가 **Account 에는 시계열이 없어**(v5 M-M1 — `metric_series` 는 콘텐츠 전용) `metrics` 로 올려도 **기능적 차이가 0**이다. `following`·`view_count` 와 같은 자리로 간다.<br>`musicId` 는 audit 이 **사운드 재사용 조인키**로 판정했다(*"텍스트와 다른 확산 축"*). 숫자 하나라 저장 비용이 0 이다.<br>⚠️ **착수 문서의 *"`ContentDataInput` 신설"* 은 틀렸다** — 그 타입은 X(X-6)가 이미 만들었고 GitHub(G-6)가 확장했다. **필드 둘을 추가**하는 것이다.<br>`webVideoUrl` 은 `postId` 로 언제든 조립되는 파생이고 `postId` 가 이미 dedup 축이다 |
| **T-9** | **`tag`·`music`·`trending` 은 `unknown` 으로 둔다** | 관측이 **0 · 0 · 1** 건이고 열려면 새 어휘가 필요하다. YouTube Y-2 가 playlist(0건)를 `UNSUPPORTED` 로 둔 것과 같은 판단이다.<br>⚠️ **X 만 `/hashtag/{tag}` 를 검색으로 흡수한다**(`x_tweet_search`). 그 규칙은 **아직 X 에만 있다** — 관측이 생기면 TikTok·Instagram 에 같이 적용하는 것이 맞다 |

### 질문 없이 둔 가정

- **게이트가 꺼진 경우는 `attempted: false` 다.** 부르지 않았으므로 `attempts` 에 세지 않는다(R-14)
- `outboundUrls` 중 `normalizeUrl()` 이 실패한 값은 **버린다**(v5 규칙 ②)
- **평문 URL 추출은 `extractUrls()` 를 쓴다** — YouTube Y-8 · Instagram I-6 에서 이미 공용으로
  승격된 함수다. `text`(caption) · `signature`(bio) 둘 다에 적용한다
- **`hashtags` 는 fetcher 가 이미 문자열로 변환한다.** actor 는 `{id, name, title, cover}[]`
  를 주고(실측) fetcher 의 `hashtags()` 가 `name` 만 뽑는다 — Generator 는 `string[]` 을 받는다
- `knownWallets` · `badges` · `declaredHandles` · `accountType` · `sourceRef` 는 채우지 않는다
- **`parentRef` 를 걸 대상이 없다.** TikTok 에 duet·stitch 개념이 있으나 fetcher 가 그 관계를
  주지 않는다. 따라서 사슬도 순환도 없고 `link_depth` 는 전부 0 이다

## 4. URL 종류별 산출

| 진입 | 외부 호출 | 묶음 | `rootRef` | status |
|------|-----------|------|-----------|--------|
| **유료 게이트 OFF** — `profile`·`video` | **0** | 없음 | — | `SKIPPED_PAID` (T-1) |
| `tiktok_profile` (정상) | `run` 1 | `{ creator }` 1개 | 그 묶음 | `OK` |
| `tiktok_profile` (없음) | `run` 1 | 없음 | — | `NOT_FOUND` |
| `tiktok_video` (정상) | `run` 1 | `{ content, creator }` 1개 | 그 묶음 | `OK` — **추가 호출 0**(§1) |
| `tiktok_video` (없음) | `run` 1 | 없음 | — | `NOT_FOUND` |
| `tiktok_search` | **0** | `{ content(search) }` 1개 | 그 묶음 | `OK` · `attempted: false` (T-2) — **게이트와 무관** |
| `tiktok_shortlink` | **0** | 없음 | — | `UNSUPPORTED` (T-3) — **게이트와 무관** |
| `unknown` (oembed · tag · music · trending · 빈 경로) | **0** | 없음 | — | `UNSUPPORTED` |

**묶음은 항상 1개다.**

⚠️ **`SUSPENDED` 를 내는 경로가 없다.** X 는 정지 계정이 `kind: 'unavailable'` 로 와서 가를 수
있었지만, TikTok fetcher 는 그런 어휘를 갖고 있지 않고 **audit 에도 관측이 없다**(§8-b).

## 5. 필드 매핑

### AccountInput ← `TiktokProfile`

| `AccountInput` | TikTok | 비고 |
|---|---|---|
| `platform` | — | `SocialPlatform.TIKTOK` |
| `platformKey` | `authorId` | 🔑 핸들은 30일마다 변경 + **재할당까지 된다**(qna Q1) |
| `handle` | `handle` | 표시·이력용. 조인키 아님 |
| `accountCreatedAt` | `createdAt` | 러그 tell 1순위. ⚠️ §8-b — 검색 경로에서 10/10 `null` 이었다 |
| `bio` | `signature` | CA·지갑이 담기는 자리 |
| `outboundUrls` | `bioLink` + `extractUrls(signature)` | 정규화 실패분은 버린다 |
| `avatarUrl` | `avatar` | **G-16** — 이미 확정된 규칙 |
| `metrics.followers` | `fans` | **G-17** — 팔로워류는 이름이 무엇이든 그릇이 하나다 |
| `data.contentCount` | `videoCount` | X `statusesCount` 와 같은 자리 |
| `data.heart` | `heart` | **T-8** — audit `noise`. `metrics` 아님 |
| `data.isVerified` | `verified` | **T-7** — fetcher 타입에 추가 필요 |
| `displayName` | — | 채우지 않는다. `nickName` 은 audit `skip` |
| `unavailable` | — | 채우는 경로가 없다(§8-b) |

### ContentInput ← `TiktokVideo`

| `ContentInput` | TikTok | 비고 |
|---|---|---|
| `platform` | — | `SocialPlatform.TIKTOK` |
| `subtype` | `isSlideshow` | **T-4** — `true` 면 `PHOTO`, 그 외 `VIDEO` |
| `platformKey` | `postId` | 🔑 **authoritative** — 틀린 handle + 살아있는 postId 로 실측 검증됨 |
| `publishedAt` | `createdAt` | |
| `text.primary` | `text` | CA 가 담기는 자리 |
| `text.fromMedia` | — | **T-6** — 비운다. 10/10 `null` 이기도 하다 |
| `outboundUrls` | `extractUrls(text)` | Y-8 · I-6 과 같은 규칙 |
| `mediaUrls` | `slideshowImageLinks[].tiktokLink` | **T-5** — photo 의 유일한 이미지 경로 |
| `tags` | `hashtags` | fetcher 가 이미 `string[]` 로 준다 |
| `mentions` | — | **T-5** — 비운다. 두 표본 모두 빈 배열 |
| `metrics.*` | 동명 6종 | `playCount`·`diggCount`·`commentCount`·`shareCount`·`collectCount`·`repostCount`. **새로 만들 자리 0** |
| `data.musicId` | `musicId` | **T-8** — 사운드 재사용 조인키 |
| `data.isAd` | `isAd` | **T-8** |
| — | `webVideoUrl` | **T-8** — 버린다. `postId` 의 파생 |
| `creatorId` | `author` → Creator | **추가 호출 0**(§1) |

## 6. 실패 표현

X·YouTube·Instagram 과 같다.

- `fetchProfile`/`fetchVideo` 가 `null` → `NOT_FOUND`
- `requireKey` 가 던지는 `UPSTREAM_CONTRACT_BROKEN`(`id`·`authorMeta.id` 부재) → **삼키지 않는다**
- `CREDIT_EXHAUSTED`·`QUOTA_EXHAUSTED`·`CREDENTIAL_*` → **다시 던진다.**
  ⚠️ TikTok 도 Apify 라 `CREDIT_EXHAUSTED` 가 실제로 나온다 — Processor 가 그 신호로
  이 이벤트의 남은 유료 URL 을 건너뛴다. 삼키면 **돈이 떨어진 채로 유료 호출이 계속 나간다**
- **보조 호출이 없어** Instagram I-2 같은 "흡수하고 `creator: null`" 경로 자체가 없다

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

| # | 무엇 | 파일 | 근거 |
|---|------|------|------|
| 1 | `RawTiktokItem.authorMeta.verified?: boolean` 추가 | `apify/apify.types.ts` | T-7 |
| 2 | `TiktokProfile.verified: boolean \| null` 추가 + `toProfile()` 매핑 | `tiktok/tiktok.types.ts` · `tiktok.fetcher.ts` | T-7 |
| 3 | `AccountDataInput.heart?: number` + `AccountData.heart` + `Account.toData()` | `social-graph.types.ts` · `objects/account.model.ts` | T-8 |
| 4 | `ContentDataInput.musicId?: string` · `isAd?: boolean` + `ContentData` + `Content.toData()` | 〃 · `objects/content.model.ts` | T-8 — **신설이 아니라 필드 추가** |
| 5 | `TiktokVideo.slideshowImageLinks` 를 `unknown[]` → `{ tiktokLink: string; downloadLink: string }[]` | `tiktok.types.ts` · `apify.types.ts` | T-5 · §8 실측 |
| 6 | `TiktokGenerator` 를 `social-recording.module.ts` 의 `providers` + 라우터 `inject` 에, `TiktokFetcher` 를 `SocialFetcherModule.exports` 에 등록 | `social-recording.module.ts` · `social-fetcher.module.ts` | ⚠️ 빠지면 **부팅도 되고 예외도 없이** 그 소셜이 통째로 미해석. `wiring-contract.spec.ts` 가 **둘 다 실제로 잡았다**(§11) |
| 7 | `decisions.md` 에 **G-20**(유료 게이트 판정 주체) 승격 | `decisions.md` | T-1 |

⚠️ **`mentions`·`detailedMentions`·`subtitleLinks` 의 `unknown[]` 은 그대로 둔다.**
값을 안 담기로 했으므로 타입을 바꿀 이유가 없고, `unknown[]` 이 *"아직 모른다"* 를
정확히 말하고 있다.

## 8. 확인 필요 항목

### ✅ 실측으로 닫았다 (비용 $0 — 기존 증거 재사용)

근거: [`docs/features/fetcher-typing-refactor/evidence/tiktok-search-live-2026-08-03.json`](../../../docs/features/fetcher-typing-refactor/evidence/tiktok-search-live-2026-08-03.json)
— 다른 세션이 T-012(검색 fetch 가능성 확인) 때 남긴 **유료 응답 10건 원문**이다.
`$0.038` 이 이미 지출됐고 이번 라운드의 **새 지출은 0**이다.

| 열린 질문 | 결과 |
|---|---|
| `slideshowImageLinks` 원소 구조 | **`{tiktokLink, downloadLink}`** — `isSlideshow=true` 1건에 15개 |
| 같은 건의 `mediaUrls` 원본 | **빈 배열** — *"유일한 이미지 경로"* 실증 |
| `subtitleLinks` 원소 구조 | **`{language, downloadLink, tiktokLink, source, sourceUnabbreviated, version}`** · `source: "ASR"` · 3/10 |
| `mentions`·`detailedMentions` 원소 구조 | **10/10 빈 배열** — 못 닫았다. 표본 둘이 모두 비어 추가 지출 판단이 섰다 |
| `transcriptionLink` 실제 값 | **10/10 `null`** — T-6 을 강화 |
| `authorMeta.verified` 존재·타입 | **bool · 10/10 존재**(전부 `false`) — T-7 구현 가능 |
| `isSlideshow` 분포 | 9 `false` / 1 `true` · `null` 없음 — T-4 의 null 흡수는 안전판 |
| `hashtags` 원소 구조 | **`{id, name, title, cover}`** — fetcher 가 `name` 만 뽑아 `string[]` 로 준다 |
| 검색이 fetch 가능한가 | **가능**(`searchSection` `""`/`"/video"`/`"/user"` 가 관측 URL 과 1:1). 그럼에도 T-2 는 호출 0회 |

### ✅ 라이브 파이프라인으로 닫았다 (2026-08-10 · 유료 6런)

근거: [`evidence/pipeline-live-2026-08-10.json`](../../../docs/features/social-generator/evidence/pipeline-live-2026-08-10.json).
`POST /api/v1/internal/social-record-test/sync` 로 토큰 10건을 실제 경로에 태웠다 —
락·멱등·URL 루프·마커·Writer 가 전부 실전 그대로 돈다.

| 열려 있던 것 | 결과 |
|---|---|
| 🔴 **`accountCreatedAt` 이 `null` 인가** | **아니다 — 6/6 채워졌다**(2018~2026). 검색 경로 표본의 10/10 `null` 은 **그 경로만의 성질**이었다 |
| `data.heart` 도착 | ✅ 450 ~ 82,200,000 |
| `data.isVerified` 도착 | ✅ (표본은 전부 `false`) |
| `data.musicId`·`isAd` 도착 | ✅ 4/4 |
| 지표 6종 | ✅ 4/4 전부 |
| `subtype` | video 4 · search 2 |
| 검색이 호출 0회인가 | ✅ `att=0` · `platformKey` = 정규화 검색어 · `creator: null` |
| shortlink 이 `UNSUPPORTED` 인가 | ✅ 2/2 `att=0` — T-3 그대로 |
| `text.from_media` 비었나 | ✅ |
| `mentions` 비었나 | ✅ |
| `outboundUrls` | bio 링크가 있는 계정에서 정상 추출 |

⚠️ **`slideshowImageLinks` 는 여전히 미검증이다.** 표본 4건이 전부 `isSlideshow=false` 였고
report 가 photo 를 `tiktok_video` 로만 분류해 **URL 로 골라낼 방법이 없다.**
`media_urls` 가 4/4 빈 배열인 것은 정상이지만, T-5 의 매핑이 실제로 도는지는 못 봤다.

### ⏳ 이번 라운드에 안 하는 것

- `mentions`·`detailedMentions` — 백필 후보. 채우려면 **백필 배치**가 필요하다
  (기존 행은 갱신되지 않는다)
- `subtitleLinks` 로 자막 텍스트 취득 — T-6 과 같이 별도 결정
- `transcriptionLink` 로 전사 취득 — 만료 정책 검증이 먼저
- `tag`·`music`·`trending` 어휘 — T-9

## 8-b. 알려진 한계

- **`unavailable` 을 채우는 경로가 없다.** `Account.unavailable` 은 *"살아있는 계정만"* 이라는
  실제 쿼리 축인데, **audit 에 정지·비공개 어휘가 없다**(`privateAccount` 는 `skip` 이고
  뜻도 다르다). Apify actor 가 정지 계정에 무엇을 돌려주는지 **관측 자체가 없어**
  지금 결정할 근거가 없다. `FetchStatus.SUSPENDED` 를 내는 경로도 없다
- **`oembed?url=` 40건은 결손이지 미분류가 아니다.** audit 이
  *"url= 파라미터가 비어 식별 불가, 데이터 결손 · 40건 전부 동일 URL"* 로 실측했다.
  대상이 없으므로 `unknown` 이 **정상**이다
- **shortlink 7건은 계속 유실된다** — G-18 이 구현될 때까지(T-3)
- **`heart` 는 갱신되지 않는다.** 기존 계정 행은 재관측 시 갱신하지 않는 것이 설계다
  (수집과 지표 갱신의 분리) — 첫 관측값이 그대로 남는다

## 9. 구현 순서

1. §7 선행 작업 1~5 (타입·스키마)
2. `TiktokGenerator` — `fromSearch` → `fromProfile` → `fromVideo` 순
   (뒤로 갈수록 무겁다. `fromSearch` 는 호출이 없어 테스트가 가장 싸다)
3. §7 선행 작업 6 (모듈 등록) — **`wiring-contract.spec.ts` 가 빨개진 것을 확인하고 고친다**
4. 단위 테스트
5. §7 선행 작업 7 (G-20 승격)
6. **루브릭 전수 점검** — R-1~R-18

## 10. 테스트

- `generator-contract.ts` 의 `expectGeneratorContract` 를 그대로 건다
  (`expectUrlsNormalized` · `expectResultContract` · `expectGraphShape`)
- **R-18 검증이 이 소셜의 핵심이다** — `tiktok_profile` 경로와 `tiktok_video` 경로가 만드는
  `AccountInput` 이 **객체 통째로 같은지** 비교한다(필드별이 아니라). §1 의 주장이 코드에서
  실제로 성립하는지가 여기서만 확인된다
- 게이트 OFF 에서 **`unknown`·`search`·`shortlink` 가 `SKIPPED_PAID` 로 나가지 않는지**
  — Instagram 에서 실제로 밟은 자리다
- `isSlideshow` 가 `true`/`false`/`null` 셋 다인 픽스처로 T-4 확인
- `classify-contract.spec.ts` 는 이미 TikTok 을 포함한다(R-16)

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

R-1 ~ R-18 을 전부 확인했다. **자동 검사가 가로챈 것 1건, 사람이 찾은 것 1건**이 나왔다.

### 🔴 발견 1 — `classify` 가 숫자 아닌 postId 를 통과시켰다 (R-16 인접)

`/@user/video/<쓰레기>` 가 `tiktok_video` 로 확정돼 **유료 호출을 쏘고 반드시 빈손으로
돌아왔다.** 바로 위 `/embed/v2/<id>` 분기는 **처음부터 숫자를 검사했는데** 여기만 안 봐서,
같은 함수 안에서 같은 값에 대한 판정이 갈려 있었다.

⚠️ **거르기만 했더니 더 나빠졌다.** 숫자 검사만 붙이자 그 URL 이 아래 `@` 분기로 흘러
**그 계정의 프로필**이 됐다 — 게시물 URL 이 조용히 계정이 되는 것은
YouTube(`RESERVED_PATHS`) · Instagram(`RESERVED_ROOTS`)에서 **실제로 밟은 버그와 같은 종류**다.
그래서 *"게시물 네임스페이스에 들어온 이상 판정은 거기서 끝난다"* 로 고쳤다.

- R-16 이 잡는 것은 **빈 키**지 **엉뚱한 키**가 아니다. 이 종류는 아직 자동 검사가 없다
- 관측 115건은 전부 숫자였으므로 **현재 유실은 0**이다. 미래 방어다
- 회귀 테스트 3건 추가(`숫자 아닌 postId` · `id 없는 video/photo 경로`)

### 🟠 발견 2 — `SocialFetcherModule.exports` 에 `TiktokFetcher` 가 없었다

`wiring-contract.spec.ts`(G-19)가 **등록하자마자 잡았다.** 순서까지 설계대로였다 —
Generator 파일을 만들자 *"파일은 6개인데 등록은 5개"* 로 먼저 빨개졌고, 등록하자
*"`TiktokFetcher` 가 exports 에 없다"* 로 다시 빨개졌다. **부팅에서야 터졌을 것**이다.

덤으로 그 모듈 주석의 *"현재 4/8"* 이 실제(GitHub 포함 5/8)와 어긋나 있어 같이 고쳤다.

### 통과 확인한 항목

| | 근거 |
|---|---|
| **R-1 ~ R-5** | 묶음이 항상 1개다. `expectGraphShape` 가 자동 검사 |
| **R-6** | `parentRef` 를 걸 대상이 없다 — fetcher 가 duet·stitch 관계를 주지 않는다 |
| **R-7** | 계정 `authorId` · 콘텐츠 `postId` · 검색 정규화 검색어. 셋 다 R-16 이 비어 있지 않음을 보장 |
| **R-8** | `TiktokProfile` 10필드 **전부** 매핑했다. `TiktokVideo` 에서 안 담는 것은 넷이고 전부 결정 근거가 있다 — `mentions`·`detailedMentions`(T-5, 구조 미확인) · `subtitleLinks`·`transcriptionLink`(T-6, 링크지 텍스트가 아님) · `webVideoUrl`(T-8, `postId` 의 파생). `authorHandle` 은 `ContentInput` 에 자리가 없고 `author.handle` 로 이미 담긴다 |
| **R-9** | `outboundUrls`·`mediaUrls`·`avatarUrl` 전부 `normalizeAll`/`normalizeOne` 경유. `expectUrlsNormalized` 가 `/Urls?$/` 로 자동 검사 |
| **R-10** | 스프레드 없음 — 지표 6종·`data` 2종 전부 필드 단위 명시 |
| **R-11** | `subtype` 은 `isSlideshow` 로 fetch 후 판정. 조회 키에 넣지 않는다 |
| **R-12** | `creatorId`·`venueId`·`parentContentId` 를 세 곳 어디서도 채우지 않는다 |
| **R-13 · R-14** | `expectResultContract` 가 자동 검사. 게이트 OFF 는 `attempted: false` |
| **R-15** | **보조 호출이 없어 삼킬 실패가 없다.** try/catch 도 `UNABSORBABLE` 목록도 이 파일에 없다 — fetcher 가 던지는 것은 전부 그대로 위로 간다. 이 소셜에서만 성립하는 단순함이다 |
| **R-17** | `normalizeUrl()` 이 `null` 이면 던진다 |
| **R-18** | **전용 테스트가 있다** — `tiktok_profile` 경로와 `tiktok_video` 경로의 `AccountInput` 을 **객체 통째로** 비교한다. §1 의 *"추가 호출 0회로 두 경로가 같다"* 는 주장이 코드에서 참인지 여기서만 확인된다 |

### ✅ Writer 통과 3건 — 사보타주로 검증

`test/unit/social-recording.spec.ts` 의 `TiktokGenerator → SocialGraphWriter 통과`.
YouTube·Telegram·Instagram·GitHub 이 갖고 있던 것과 같은 자리이고, **유료도 인프라도
필요 없다**(모델은 목이고 `plan()`·`Content.of()`·`Account.of()` 는 실물이다).

- 게시물 그래프 → 링크 2건 · `link_depth` 전부 0 · `subtype=PHOTO` · `media_urls` 1개
- 계정의 `heart`·`isVerified` 가 모델까지 도착 · `metrics.followers`
- 검색 그래프 → 링크 1건(`CONTENTS`). 셋 다 `null` 이면 Writer 가 *'빈 그룹'* 으로 던진다(R-4)

🔴 **이 검사만 잡는 실패 모드가 있다.** `Content.toData()`·`Account.toData()` 는 필드를
**하나씩 나열**하는 함수라, `ContentDataInput`/`AccountDataInput` 에 넣어도 거기 안 적으면
모델에 도착하지 않는다. 초과 속성 검사가 변수 대입에는 적용되지 않아 **컴파일이 통과하고**,
저장에서 mongoose strict 가 조용히 버린다. Generator 단위 테스트는 `input` 만 보므로 못 잡는다.

**고의로 어겨서 확인했다** — `toData()` 에서 `musicId` 를 빼자 ①이, `heart` 를 빼자 ②가
빨개졌다. 이번에 새 필드를 넷 추가했기 때문에 그만큼 이 자리가 위험했다.

### 이번 소셜이 검사에 기여한 것

게이트가 **호출 0회 종류까지 막지 않는지**를 `it.each` 4건으로 확인한다
(검색 · shortlink · oembed · tag). Instagram 에서 실제로 밟은 자리라 TikTok 은
**처음부터 그 회귀 테스트를 갖고 시작**했다.
