# Instagram Generator — 구현 계획

> 상태: **구현 완료 · 루브릭 전수 점검 완료**(§8-b, 2026-08-10). 아래 결정은 2026-08-10
> 착수 문서 [`instagram-kickoff.html`](../../../docs/features/social-generator/instagram-kickoff.html) 에서 **9/9** 확정됐다.
> I-4 는 착수 문서에서 미선택이었고, **그 뒤 유료 실측으로 방향이 바뀌어** 확정됐다(③-b).

## 0. 범위

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

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

**다루는 URL 종류 4개** — `instagram_profile`(390건 · 68%) · `instagram_post`(178건) ·
`instagram_search`(1건) · `instagram_story`(2건).
**넷 다 객체를 만든다.** 단 story 는 콘텐츠가 아니라 **그 계정**을 만든다(I-4).
검색은 **호출 0회**로 행 1건이다(I-10).

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

**🔴 8종 중 처음으로 유료 게이트가 실제로 동작하는 소셜이다**(I-1). 그래서 이 결정은
Instagram 것이 아니라 **횡단 결정**이고 `decisions.md` **G-20** 으로 올라간다.

## 1. 왜 Instagram 이 세 번째인가

남은 것 중 **관측이 가장 많고**(571건 · 5.8%) **실호출이 가장 싸다**($0.0027/run,
TikTok 의 0.57배 · Reddit 의 1/15). 그리고 유료 게이트 판단을 여기서 세우면
TikTok·Reddit 이 그대로 물려받는다 — 셋 합쳐 **1,177건(11.9%)** 이다.

구조는 YouTube 와 같은 Creator·Content 라, 그때 만든 계약 검사가 그대로 적용된다.

**단계 0 의 절반이 이미 끝나 있었다.** audit(2026-07)이 지적한 라우터 결함 5건을
그 뒤 fetcher 타입 리팩터가 전부 고쳤다 — 그중 하나는 게시물을 프로필 필드로 파싱해
**유료 호출에 성공하고도 전부 `null` 로 저장**하던 것이다. audit 문서만 읽고 착수했다면
이미 고쳐진 것을 또 고쳤을 것이다.

## 2. 구조

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

  constructor(private readonly fetcher: InstagramFetcher) {}

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

**`opts` 를 실제로 읽는 첫 Generator 다**(I-1). X 는 `enableCommunity` 만 봤고
YouTube 는 전량 무료라 안 읽었다.

```
generate(url)
  └ normalizeUrl(url) → ParsedUrl
      └ fetcher.classify(parsed) → { sourceType, sourceKey }
          ├ instagram_search      → fromSearch(key)      // 호출 0회 — 게이트 이전
          ├ instagram_profile ┐
          ├ instagram_post    ┴→ 유료 게이트 OFF? → SKIPPED_PAID · 호출 0회   ← I-1
          │                     ON  → fromProfile / fromPost
          ├ instagram_story    → fromStory(parsed)  // segments[1] 로 프로필 1콜
          └ unknown            → UNSUPPORTED
```

## 3. 결정 로그

| ID | 결정 | 근거 |
|----|------|------|
| **I-1** | **유료 게이트를 Generator 가 판정한다.** 꺼져 있으면 **호출 0회**로 `SKIPPED_PAID` | 🔴 **지금은 아무도 판정하지 않는다.** `enablePaid` 를 읽는 곳은 Processor 가 `FetchOptions` 에 실어 보내는 자리뿐이고 **그 값을 읽는 Generator 가 하나도 없다.** Instagram fetcher 주석은 *"유료 게이트도 어댑터 소관"* 이라고 적지만 `ApifySdk` 에 그런 코드가 없다 — 게이트가 제거되던 시점의 낡은 주석이고, 생성기 계약이 *"유료·미지원 판정을 스스로 한다. 게이트가 사라졌기 때문이다"* 로 그 변경을 명시했다.<br>**이대로 만들면 설정을 꺼도 돈이 나간다.** 기본값이 `false` 인 것이 그 설정의 핵심인데(§4 E-3) 강제하는 코드가 없다. X 는 커뮤니티에만 게이트를 걸었고 YouTube 는 전량 무료라 **이 구멍이 드러날 자리가 없었다.**<br>⚠️ **게이트는 유료 호출이 필요한 종류에만 건다.** 검색(I-10)은 호출 0회라 게이트보다 **앞에서** 끝난다 — 돈이 안 드는 것을 돈 때문에 막으면 `SKIPPED_PAID` 가 *"비용 때문에 건너뛰었다"* 는 뜻을 잃는다.<br>부수 효과로 **오염 URL 2건도 함께 막힌다** — 토큰 메타데이터에 HTML 파편이 경로로 섞여 들어온 것인데, 형태가 정상 핸들과 같아 분류로는 못 거른다.<br>⚠️ **횡단 결정이다** — TikTok·Reddit 이 그대로 물려받는다. `decisions.md` **G-20** 으로 올린다 |
| **I-2** | **게시물 경유로 만나는 계정은 프로필을 1콜 더 불러 채운다** | 게시물 응답은 작성자를 `ownerId`·`ownerUsername` **2필드**로만 주고, 프로필 URL 로 들어오면 **7필드**다. **YouTube Y-1 과 같은 구조**이고 R-18 이 그대로 걸린다 — 기존 행은 갱신되지 않으므로 **도착 순서가 저장 모양을 가른다.** 이것은 갱신 로직이 생겨도 안 풀린다(G-15) — 갱신은 *"무엇을 덮을지"* 를 정해야 도는데, 두 경로가 서로 다른 필드 집합을 내면 **어느 쪽이 맞는지 판단할 기준이 없다.**<br>다른 점은 **비용이다.** YouTube 는 1 unit(사실상 무료)이었지만 여기는 게시물 1건당 **$0.0027** 이 더 나간다. 관측 전량으로 쳐도 **178 × $0.0027 ≈ $0.48** 이라 감수한다.<br>⚠️ 이 추가 호출은 **I-1 게이트 안쪽**이다 — 게이트가 꺼져 있으면 애초에 여기 도달하지 않는다 |
| **I-3** | **`mentions` 를 이번 라운드에 담지 않는다** | 타입이 `unknown[]` 이고 주석이 *"원소 구조 미확인(문자열인지 객체인지 실측이 없다). 확인 전에 타입을 지어내지 않고 원형을 보존한다"* 다. `MentionInput.platformKey` 는 **필수**라 원소가 문자열이면 매핑이 아예 불가능하다.<br>확인에는 유료 실호출 1건($0.0027)이면 되지만 **이번 라운드는 비운다.** 대가는 조율 프로모션의 직접 증거를 잃는 것이고, 기존 행은 갱신되지 않아 나중에 채우려면 **백필 배치**가 필요하다.<br>👉 **이 결정으로 이번 라운드의 새 유료 호출이 0건이 됐다** |
| **I-4** | **스토리 URL 은 그 계정을 만든다.** 콘텐츠는 만들지 않는다 (2026-08-10 실측으로 확정) | ⚠️ **처음엔 `UNSUPPORTED` 로 잠정 기록했는데 실측이 판단을 바꿨다.**<br>원래 근거는 *"24시간 뒤 사라지니 수집 시점에 이미 없을 것"* 이었다. 그래서 *"있으면 쓰면 되지 않나"* 라는 질문이 나왔고, 확인해 보니 **전제 자체가 틀렸다** — actor 는 **story 경로를 통째로 무시하고 그 계정의 프로필을 돌려준다.** 스토리가 살아 있든 없든 같다. 즉 *"살아 있으면 가져온다"* 라는 선택지가 **존재하지 않는다.**<br>실측 2/2 로 재현됐다 — `druski`(2026-07)와 `kabosumama`(2026-08-10, $0.0027). 후자는 25필드가 왔고 그중 **스토리 관련 키가 0개**였다(`highlightReelCount`·`latestIgtvVideos`·`latestPosts` 는 있는데 스토리만 없다).<br>**그래서 빈손이 아니라 다른 것을 얻는다.** 우리가 쓰는 프로필 7필드가 전부 찼다(팔로워 50만 · `verified` · `externalUrls` 3개). `UNSUPPORTED` 로 두면 **얻을 수 있는 계정을 안 얻는** 선택이 된다.<br>**분류는 `instagram_story` 로 유지하고 Generator 가 프로필을 부른다**(③-b). `sourceType` 을 `instagram_profile` 로 바꾸면 키는 맞아떨어지지만 **"스토리 링크였다" 는 사실이 타입에서 사라진다** — 24시간짜리 홍보는 성격이 다르므로 그 자체가 신호일 수 있다. 나중에 스토리를 실제로 가져올 소스가 생기면 **Generator 만 고치면 된다.**<br>⚠️ **대가 셋** — ① 스토리는 콘텐츠인데 Content 행이 안 생겨 콘텐츠 집계에서 빠진다. ② `link_depth: 0` 이 *"토큰이 직접 건 URL 의 대상"* 인데, 건 것은 스토리이고 대상은 계정이라 **"이 토큰이 이 계정을 홍보했다" 로 읽힌다.** ③ 프로필 URL 과 스토리 URL 을 둘 다 걸면 같은 계정에 링크가 2건 생긴다(`token_links` 에 unique 가 없어 허용된다) |
| **I-5** | **프로필 종류 이름을 `instagram` → `instagram_profile` 로 바꾼다** | 다른 소셜은 전부 `{social}_{종류}` 다(`x_profile`·`youtube_channel`·`tiktok_profile`). Instagram 만 프로필이 맨 이름이고 나머지는 `instagram_post`·`instagram_story` 라, **"이 소셜 전체" 와 "이 소셜의 프로필" 이 같은 문자열**이다.<br>**마이그레이션이 없다** — 분류 결과는 저장되지 않는다. `tokens.social_urls[]` 가 분류를 박제하지 않는 이유가 *"전부 `url` 의 순수 파생이라 읽는 시점에 다시 분류한다"* 이기 때문이다 |
| **I-6** | **`mediaUrls` 에 `displayUrl`·`videoUrl` 을 둘 다 담고, `caption` 에서 링크를 추출한다.** 추출 유틸은 **공용으로 승격** | **G-16 의 "변형 1개" 규칙에 걸리지 않는다** — 둘은 같은 그림의 해상도 변형이 아니라 **서로 다른 자산**이다(썸네일 vs 영상 원본).<br>링크 추출은 YouTube Y-8 과 같은 질문이고, 그때 만든 평문 추출 유틸이 `youtube.fetcher.ts` 안에 private 로 있다. YouTube 문서가 *"소비자가 둘이 될 때 올린다"* 고 조건을 적어 뒀고 **지금이 그 시점이다.**<br>⚠️ IG 의 `videoUrl` 은 서명이 붙어 수 시간 뒤 만료된다. 그럼에도 담는 이유는 **관측 시점의 사실**이기 때문이고, 만료 여부는 저장할지의 기준이 아니다 |
| **I-7** | **`verified` 를 fetcher 타입에 추가하고 `data.isVerified` 로 담는다** | **actor 가 이미 주는데 현재 타입에 없다**(liveTests 확인). 자리는 X 가 만들어 뒀다.<br>audit 은 배지를 `noise` 로 봤지만 X 가 *"값이 이미 오므로 담는다"* 로 갔고 같은 판단이다. **안 담으면 채울 대상 자체가 없다**(R-8 · G-6) — 갱신 로직이 나중에 생겨도 애초에 만들지 않은 값은 채울 수 없다 |
| **I-8** | **`subtype` 은 `productType` 으로만 판정한다** | `ContentSubtype` 에 `POST`·`REEL`·`TV` 가 **이미 셋 다 있고** 주석이 *"fetch 후 세분화"* 다. 릴스·IGTV 를 가르는 값이 `productType` 뿐이다. 착수 문서는 *"없으면 `type` 으로"* 라고 적었는데, 구현하며 보니 **`type` 은 형식(`Image`·`Video`·`Sidecar`)이라 종류를 못 가른다** — 그래서 `productType` 이 없으면 그대로 `POST` 로 수렴한다. 스키마 변경 0.<br>⚠️ **R-11** — `subtype` 은 정체성이 아니라 상태다. 조회 키에 넣으면 한 대상이 두 행이 된다 |
| **I-9** | **`videoViewCount`·`videoPlayCount` 를 둘 다 원래 이름으로 담는다** | 둘이 **따로** 오고, `ContentMetricsInput` 주석이 *"어느 쪽이 views 인지 미결"* 이라고 **미결로 적어 뒀다.** 자리는 이미 둘 다 있다.<br>**한쪽을 버리면 되돌릴 수 없다** — 기존 행은 갱신되지 않는다. 쌓인 뒤 둘을 비교하면 어느 쪽이 무엇인지 드러난다 |
| **I-10** | **`instagram_search` 를 신설한다.** `explore/search/keyword/?q=` 를 **호출 0회**로 콘텐츠 행 1건으로 만든다 | **G-4 가 이미 Instagram 을 명시했다** — *"TikTok·Instagram 검색도 같은 규칙을 따르되, 그쪽은 유료 fetch 가 이미 가능해 '부를지 말지' 는 소셜별로 갈릴 수 있다."* 그런데 셋 중 **Instagram 만 검색 종류가 없었다**(X `x_tweet_search` · TikTok `tiktok_search`).<br>종전에는 `explore` 가 `RESERVED_ROOTS` 에 걸려 통째로 `unknown` 이었다. **그 방어는 옳았다** — audit 이 *"explore/search 가 걸러지지 않고 `instagram` 으로 분류되어 무의미한 유료 apify 호출을 시도할 소지"* 라고 지적한 그대로이고, 그때는 `mecha chameleon` 을 **계정으로 유료 조회**할 뻔했다.<br>**G-4 가 그 위험을 없앴다** — 검색은 호출 0회로 행 1건이다. 종류를 만들면 *"이건 검색이다"* 가 타입으로 고정돼 오분류 자체가 불가능해진다. 즉 되살리는 쪽이 **더 안전하다.**<br>관측은 **1건**이다. 그럼에도 만드는 이유는 비용이 0(호출 0회 · `ContentSubtype.SEARCH` 가 이미 있음)이고 작업이 분기 하나이기 때문이다. YouTube playlist(0건)를 `UNSUPPORTED` 로 둔 것과 갈리는 지점은 **거긴 어휘를 새로 만들어야 했다**는 것이다 |
| **I-11** | **검색어 키 정규화를 8소셜 공용 규칙으로 통일한다** — `trim().toLowerCase().replace(/\s+/g,' ')` | 🔴 **이미 갈라져 있었다**(이번에 발견). X 는 공백까지 정리했고 TikTok 은 `toLowerCase()` 만 했다. X-11 의 *"정규화 함수는 검색과 공유한다"* 는 **X 안에서 검색↔intent 공유**를 뜻했고, 소셜 사이는 아무도 맞춘 적이 없다.<br>어긋나면 **검색 행을 만드는 이유 자체가 무너진다** — 같은 밈을 여러 소셜에서 검색하는지 보려는 건데 키가 다르면 그 비교가 안 된다.<br>**지금이 싸다** — TikTok Generator 가 없어 저장된 검색 행이 **0건**이라 마이그레이션이 없다.<br>⚠️ **횡단 결정이다.** `decisions.md` **G-21** 로 올린다 |

### 질문 없이 둔 가정

- **게이트가 꺼진 경우는 `attempted: false` 다.** 부르지 않았으므로 `attempts` 에 세지 않는다(R-14)
- **보조 프로필 조회(I-2)의 실패는 흡수하고 `creator: null` 로 떨어뜨린다.** 단
  `CREDIT_EXHAUSTED`·`QUOTA_EXHAUSTED`·`CREDENTIAL_*`·`UPSTREAM_CONTRACT_BROKEN` 은 **다시 던진다**
  — YouTube 와 같은 목록이다. ⚠️ **Instagram 은 `CREDIT_EXHAUSTED` 가 실제로 나온다** —
  Apify 크레딧을 쓰기 때문이고, Processor 가 그 신호로 **이 이벤트의 남은 유료 URL 을 건너뛴다**(E-4).
  삼키면 **돈이 떨어진 채로 유료 호출이 계속 나간다**
- `outboundUrls` 중 `normalizeUrl()` 이 실패한 값은 **버린다**(v5 규칙 ②)
- `accountCreatedAt` 은 **채우지 않는다** — actor 가 주지 않는다. X·YouTube 에 있던
  러그 판별 축이 이 소셜엔 없다
- `knownWallets` · `data.following` · `badges` · `declaredHandles` 는 채우지 않는다

## 4. URL 종류별 산출

| 진입 | 외부 호출 | 묶음 | `rootRef` | status |
|------|-----------|------|-----------|--------|
| **유료 게이트 OFF** — `profile`·`post`·`story` | **0** | 없음 | — | `SKIPPED_PAID` (I-1) |
| `instagram_profile` (정상) | `scrape` 1 | `{ creator }` 1개 | 그 묶음 | `OK` |
| `instagram_profile` (없음) | `scrape` 1 | 없음 | — | `NOT_FOUND` |
| `instagram_post` (정상) | `scrape` 1 + `scrape` 1 | `{ content, creator }` 1개 | 그 묶음 | `OK` |
| `instagram_post` (프로필 조회 실패) | `scrape` 1 + `scrape` 1 | `{ content }` 1개 | 그 묶음 | `OK` (흡수) |
| `instagram_post` (없음) | `scrape` 1 | 없음 | — | `NOT_FOUND` |
| `instagram_search` | **0** | `{ content(search) }` 1개 | 그 묶음 | `OK` · `attempted: false` (I-10) — **게이트와 무관** |
| `instagram_story` (정상) | `scrape` 1 | `{ creator }` 1개 | 그 묶음 | `OK` — 계정만(I-4) |
| `instagram_story` (핸들 없음) | **0** | 없음 | — | `UNSUPPORTED` |
| `unknown` (highlights · explore · 빈 경로) | **0** | 없음 | — | `UNSUPPORTED` |

**묶음은 항상 1개다.** Instagram 에 remix 개념이 있으나 fetcher 가 그 관계를 주지 않아
`parentRef` 를 걸 대상이 없다. 따라서 사슬도 순환도 생기지 않고 `link_depth` 는 전부 0 이다.

## 5. 필드 매핑

### AccountInput ← `InstagramProfile`

| 대상 | 출처 | 비고 |
|------|------|------|
| `platform` | 상수 `INSTAGRAM` | |
| `platformKey` | `id` | **숫자 id** — `username` 은 변경 가능하다(qna Q7) |
| `handle` | `username` | 표시·이력용 |
| `bio` | `biography` | CA 가 담기는 자리 |
| `outboundUrls` | `externalUrls` | 이미 배열로 평탄화돼 있다. `normalizeUrl()` 을 건다(R-9) |
| `avatarUrl` | `profilePicUrl` | 🆕 G-16 |
| `metrics.followers` | `followersCount` | G-17 |
| `data.contentCount` | `postsCount` | X 의 `statusesCount` 와 같은 자리 |
| `data.isVerified` | `verified` | 🆕 I-7. **fetcher 타입에 추가해야 한다** |
| `observedAt` | 인자 | |
| ~~`accountCreatedAt`~~ | — | **actor 가 주지 않는다.** X·YouTube 의 러그 tell 이 이 소셜엔 없다 |
| ~~`displayName`~~ | — | 응답에 없다 |
| ~~`knownWallets` · `data.following` · `badges`~~ | — | 해당 없음 |

### ContentInput ← `InstagramPost`

| 대상 | 출처 | 비고 |
|------|------|------|
| `platform` · `subtype` | `INSTAGRAM` · **`productType` 만** 본다 | I-8. `clips`→`REEL` · `igtv`→`TV` · 나머지→`POST` |
| `platformKey` | `shortCode` | **대소문자 구분** |
| `publishedAt` | `postedAt` | |
| `text.primary` | `caption` | CA 가 담기는 자리 |
| `text.fromMedia` | `alt` | **시각 콘텐츠의 유일한 무료 진입점.** 계약 주석이 이 자리를 IG `alt` 로 명시한다 |
| `outboundUrls` | `caption` 평문에서 추출 → `normalizeUrl()` | 🆕 I-6 |
| `mediaUrls` | `displayUrl` + `videoUrl` | 🆕 I-6. **둘 다** — 같은 그림의 변형이 아니다 |
| `tags` | `hashtags` | caption 과 별개로 파싱돼 온다 |
| `metrics` | `likesCount`·`commentsCount`·`videoViewCount`·`videoPlayCount` | I-9. **4종 전부 자리가 있다** |
| `mentions` | `[]` | I-3 — 원소 구조 미확인이라 비운다 |
| `data` | **없음** | 담을 키가 없다 |
| `creatorId` · `venueId` · `parentContentId` | **비운다** | R-12 |
| ~~`ownerId`~~ | — | **일부러 안 쓴다.** 게시물이 주는 계정 조인키지만, I-2 가 프로필을 1콜 더 불러 그쪽 `id` 를 쓴다. 조회가 실패했을 때 이것만으로 계정을 만들면 **G-6 이 금지한 껍데기**가 된다.<br>⚠️ **대가가 YouTube 보다 크다** — 보조 조회가 유료이고 차단 위험도 있어 **이 경로를 훨씬 자주 밟는다.** 그때 그 게시물 행은 작성자와 **끊긴 채로 남는다** — 갱신은 별도 소관이고(H-008), 애초에 만들지 않은 계정은 갱신도 채울 대상이 없다 |
| ~~`type`~~ | — | **일부러 안 쓴다.** `Image`·`Video`·`Sidecar` 는 **형식**이지 종류가 아니라 `REEL`·`TV` 를 못 가른다. `productType` 이 없으면 그대로 `POST` 로 수렴한다.<br>⚠️ **그래서 캐러셀 여부(`Sidecar`)를 담을 자리가 없다.** `data` 도 비우기로 했으므로 담을 자리가 없다. 되살리려면 `ContentDataInput` 에 `type` 을 더해야 하고(G-7 대로 원래 이름), 그건 스키마 변경이다 — **관측이 필요하다고 판단되면 그때 연다** |
| ~~`mentions`~~ | — | I-3 — 원소 구조 미확인. `MentionInput.platformKey` 가 필수라 매핑 불가. 백필 후보 |

⚠️ 지표는 **필드 단위 명시 매핑**이다(R-10).

## 6. 실패 표현

- **게이트 OFF** → `SKIPPED_PAID` · `attempted: false` · 호출 0회 (I-1)
- **대상 없음** → `NOT_FOUND` · `attempted: true`
- **호출 실패** → 삼키지 않고 던진다
- **보조 프로필 조회 실패**(I-2) → 흡수하고 `creator: null`.
  단 `CREDIT_EXHAUSTED`·`QUOTA_EXHAUSTED`·`CREDENTIAL_*`·`UPSTREAM_CONTRACT_BROKEN` 은 재던짐
- **highlights · explore · 빈 경로 · 핸들 없는 story** → `UNSUPPORTED` · `attempted: false`
- **`SUSPENDED` 는 나오지 않는다** — 정지 계정을 구분하는 응답 모양이 확인된 바 없다

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

> 상태: **1~5 완료.** `6`(G-20 승격)만 남았다.
> `tsc`·전체 단위테스트 **697건** 통과(telegram 은 별도 세션 진행 중이라 제외).

1. ✅ **`classify` 의 `'instagram'` → `'instagram_profile'`** (I-5) —
   `InstagramSourceType` · `classify` · 테스트. 마이그레이션 없음
2. ✅ **fetcher 타입에 `verified` 추가 + 매핑** (I-7) — `RawInstagramItem`·`InstagramProfile`·`toProfile`
3. ✅ **평문 링크 추출 유틸을 공용으로 승격** (I-6) — `youtube.fetcher.ts` 의 private
   `descriptionUrls()` → `social-fetcher.util.ts`. **소비자가 둘이 되는 시점이다**
4. ✅ **`InstagramPost.outboundUrls` 신설** (I-6) — `caption` 에서 추출
5. ✅ **Router 등록** — `SocialRecordingModule` 의 `providers`·`useFactory`·`inject` **셋 다** +
   `SocialFetcherModule` 의 **`exports`**. 라우터가 **3/8** 이 됐다.
   ⚠️ 후자가 두 번 밟은 함정인데 **이번엔 `wiring-contract.spec.ts` 가 지켜봤다**(G-19)
6. **G-20 승격** — 유료 게이트 판정 주체(I-1). TikTok·Reddit 이 그대로 쓴다

## 8. 확인 필요 항목

### ✅ 이미 닫힌 것

- [x] **게시물 32필드** — audit 유료 실호출로 전수 확인. `alt`·`displayUrl`·`videoUrl`·
      `hashtags` 가 그때 발견됐다(이전 덤프에서 잘려 있던 자리)
- [x] **프로필 6필드** — `gamesradar` 실호출로 전부 채워짐 확인
- [x] **actor 의 다형 응답** — 프로필과 게시물이 같은 배열·같은 스키마로 오고 구분자가
      `shortCode` 유무다. fetcher 가 이미 양쪽에 가드를 둔다
- [x] **audit coverageGaps 5건** — 타입 리팩터가 전부 닫았다(§1)

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

- [ ] **`mentions` 원소 구조** (I-3) — 유료 1건이면 닫히지만 이번엔 비운다. 백필 후보
- [ ] **`videoViewCount` vs `videoPlayCount` 의 의미** (I-9) — 데이터가 쌓이면 비교로 드러난다
- [x] **story actor 동작** (I-4) — **정정, 2026-08-11**: 부르지 **않기로** 했던 것이 그 뒤
      뒤집혔다. actor 는 story 경로를 무시하고 그 계정의 프로필을 돌려주는 것으로 실측
      확인됐고(druski · kabosumama, 2/2), 그래서 `fromStory` 가 **실제로 프로필을 부른다**
      (③-b). 2026-08-10 라이브 실측에서도 재현됨

## 8-b. 알려진 한계 (루브릭 전수 점검 2026-08-10)

고칠 수 있는 것은 고쳤고, 아래는 **알면서 감수하는 것**이다.

- **서명된 CDN URL 이 정규화로 재인코딩된다.** `normalizeUrl()` 이 쿼리를 다시 조립하면서
  `oh=00_AYA+x/y` → `oh=00_AYA+x%2Fy`, `efg=…Inh=` → `efg=…Inh%3D` 로 바뀐다.
  Instagram CDN 서명(`oh`·`oe`·`efg`)이 base64 계열이라 `/`·`+`·`=` 가 들어오기 때문이다.
  R-9 를 따른 결과라 규칙 위반은 아니지만, I-6 이 `videoUrl` 을 담는 근거로 든
  *"관측 시점의 사실"* 이 **원본과 바이트 동일하지 않은 문자열**로 남는다.
  ⚠️ **YouTube 에서는 드러날 자리가 없었다** — 썸네일 URL 에 쿼리가 없다. Instagram 이 처음이다.
  서명이 바이트 단위로 검증되면 저장된 URL 이 열리지 않을 수 있다. **관측되면 그때 본다.**
- **빈 문자열이 `??` 를 통과한다.** `biography: ''` → `bio: ''`, `caption: ''` → `text.primary: ''`.
  `handle` 은 모델의 `if (input.handle)` 가 걸러 무해하고 나머지는 빈 문자열이 저장될 뿐이라
  피해는 없다. `null` 은 전부 `undefined` 로 떨어지고 `verified: false`·`likesCount: 0` 은
  살아남는다(`??` 라서).
- **`fromStory` 의 `if (!handle)` 은 도달 불가능하다.** `classify` 가 `segments[2]` 로
  `routeOf` 를 부르므로 `instagram_story` 면 `segments[1]` 이 반드시 있다.
  방어선으로 남겨 두되, 그 경로를 타는 URL 은 **classify 단계에서 `unknown`** 이 된다.

## 9. 구현 순서

1. §7 의 `1`~`4` (classify · fetcher 타입 · 유틸 승격) — 각각 별도 커밋
2. `InstagramGenerator` — `fromProfile` 먼저(단순), `fromPost` 다음(보조 호출 포함)
3. Router 등록(§7 `5`) — 여기서 3/8 이 된다
4. §7 `6` (G-20 승격)
5. 루브릭 Part 3 체크리스트 23항목 전수 + 적대적 입력

## 10. 테스트

**공용 계약을 먼저 건다** — `expectGeneratorContract` 한 줄이면 R-9·R-13·R-14·R-1~R-5 가
자동으로 검사된다(G-19). 배선은 `wiring-contract.spec.ts` 가 이미 잡는다.

✅ **실물 Writer 통과 4건** — `social-recording.spec.ts`. Instagram 은 **한 소셜 안에서
묶음 모양이 셋으로 갈리는 유일한 소셜**이라 이 검사의 값어치가 크다:
계정만(프로필·스토리) · 콘텐츠+계정(게시물) · **콘텐츠만**(검색).
마지막이 R-4(셋 다 null 금지)의 경계에 가장 가깝다.

**이 소셜에만 있는 것**
- **게이트 OFF 에서 호출이 0회인가** — `SKIPPED_PAID` · `attempted: false`.
  ⚠️ **fetcher 를 한 번도 안 부르는 것까지** 본다. 부르고 나서 버리면 돈이 나간다
- 게시물 경유와 프로필 경유가 **같은 계정 객체**를 낸다 — 통째로 비교(R-18)
- `subtype` 판정 — `clips`·`igtv`·`feed`·`carousel_container`, 그리고 `productType` 부재
- `mediaUrls` 에 `displayUrl`·`videoUrl` 이 **둘 다** 들어간다
- `caption` 링크 추출 — 중복·문장부호·괄호

**결손·적대적**
- `videoUrl` 만 있고 `displayUrl` 이 없는 경우, 그 반대
- `caption` 이 `null` · 링크 0개
- 보조 프로필 조회가 `CREDIT_EXHAUSTED` → **다시 던지는가**
- 오염 URL(HTML 파편) → 게이트 OFF 면 호출 0회
