# Reddit Generator — 구현 계획

> 착수 결정: [`reddit-kickoff.html`](../../../docs/features/social-generator/reddit-kickoff.html) (11건 전부 확정 · 2026-08-11)
> 규칙: [`social-generator-rubric.md`](../../social-generator-rubric.md)
> 횡단 결정: [`decisions.md`](../decisions.md)
> 근거(audit): `social-url-structure-audit/sources/reddit.html` · `qna/reddit.md`
> ⚠️ **Reddit 만 `scaffold/reddit.json` 이 없다** — sources HTML 이 수기라 기계 대조가 안 된다.
> 그래서 이 문서의 실측 근거는 **actor 를 직접 호출해 얻은 응답**이다(§9).

## 0. 범위

여섯 번째 소셜. `RedditFetcher` 는 이미 이관돼 있고 여기서는 **Generator 만** 만든다.

- 만드는 것: `RedditGenerator` + spec
- 선행 작업: `ContentDataInput` +7 · `AccountDataInput` +4 (§7)
- 지우는 것: `toCommunity()` · `RedditCommunity` — 도달 불가 코드다(§1)

## 1. 이 소셜의 성격

### 가장 비싸고, 비용의 91% 가 고정비다

| URL | 반환 | 과금 |
|---|---|---|
| `/r/{sub}/comments/{id}` 글 | 1건 | **$0.022** |
| `/r/{sub}` 서브레딧 | 10건 → `maxPostsCount:1` 로 1건 | $0.04 → $0.022 |
| `/user/{u}` 유저 | 21건 → 2건 | $0.062 → $0.024 |

`init $0.02` + `result $0.002 × 건수` 다. **1건만 긁어도 $0.022 가 나간다.**
그래서 YouTube·GitHub 에서 *"1콜 더"* 가 사실상 공짜였던 판단이 **여기서는 뒤집힌다**(V-2).

⚠️ 코드·문서에 오래 있던 `$0.04/run` 은 **관측된 적이 없는 값**이다. audit 표가 내역은
옳게 적고 합계만 틀렸고(`$0.02 + $0.002 = $0.022`), 공교롭게 서브레딧 URL 의 실제 값과
같아 오래 걸리지 않았다. `71b3b50` 에서 정정했다.

### 우리가 관측하는 대상이 **프로젝트의 것이 아니다**

이 소셜의 모든 결정이 여기서 갈린다. 관측 174건이 속한 서브레딧 82개 중
URL 이 2건 이상 모인 곳이다.

```
r/wallstreetbets   URL 15건 · 토큰 63개
r/cats             URL 14건 · 토큰 58개
r/aww              URL  9건 · 토큰 42개
```

**전부 공용 밈 서브레딧이다.** 토큰이 링크한 것은 *"우리 방"* 이 아니라 **밈의 출처**이고,
글을 쓴 사람은 **고양이 사진을 올린 일반 레딧 유저**다. Telegram 채널(프로젝트가 직접 만든 방,
`data` 만 14필드)과 **정반대**이고, 그래서 Venue 도 Account 도 만들지 않는다(V-1 · V-2).

### 단일 메서드 + `kind` 유니온 — D-015 의 유일한 예외

`fetchByUrl(url)` 하나가 `RedditPost | RedditUser | RedditCommunity` 를 낸다.
**입력 종류와 산출 종류가 어긋나기** 때문이다 — 서브레딧 URL 을 넣어도 `dataType:'post'` 가 온다.
인자가 `sourceKey` 가 아니라 URL 인 것도 같은 이유다(R-4.4 예외).

### `RedditCommunity` 는 **도달 불가 코드였다**

`dataType:'community'` 는 **키워드 검색으로만** 나온다. actor input schema 가 명시한다 —
`searchCommunities` = *"Include communities (subreddits) in **keyword search** results"*.
**URL 조회로 커뮤니티를 얻는 경로가 없다.** 서브레딧 URL 을 정면으로 요청해도 글만 온다.

## 2. 구조

```
generate(url, opts, observedAt)
  └ normalizeUrl → classify → sourceType 분기
      ├ reddit_post   → paid(fetchByUrl) → kind 분기 → 묶음 1개
      ├ reddit_user   → paid(fetchByUrl) → kind 분기 → 묶음 1개
      ├ reddit_search → 호출 0회 → 검색어 Content 1건        (G-4 · R-4)
      ├ reddit        → 호출 0회 → UNSUPPORTED               (V-1)
      ├ reddit_share  → 호출 0회 → UNSUPPORTED               (R-5)
      └ unknown       → 호출 0회 → UNSUPPORTED
```

**묶음은 항상 1개다.** Reddit 에 crosspost 개념이 있으나 fetcher 가 그 관계를 주지 않아
`parentRef` 를 걸 대상이 없다. 사슬 조립·순환 방어가 해당되지 않는다.

**게이트는 종류 판정 뒤, 유료 종류에만 건다**(G-20). 호출 0회인 넷은 게이트 밖이다 —
돈이 안 드는 것을 돈 때문에 막으면 `SKIPPED_PAID` 가 뜻을 잃는다.

## 3. 결정 로그

착수 결정 11건의 요약이다. 근거 전문은 킥오프에 있다.

| # | 결정 | 답 |
|---|---|---|
| **V-1** | Venue 를 만드나 | **만들지 않는다.** 슬롯 9개 중 정보인 것이 3개고 그중 쓸 값이 `members` 하나다. 서브레딧은 *"남의 방"* 이라 조인축 변별력도 없다 |
| **V-2** | `post.author` 로 계정을 만드나 | **만들지 않는다**(R-1 = ③). 경로가 하나만 남아 R-18 대상에서 벗어난다. ①은 +$0.062(월 +$26)이고, 글의 작성자는 대개 프로젝트가 아니다 |
| R-3 | `/user/` 도 `kind` 로 분기하나 | ① **`kind` 로만 분기.** `sourceType` 은 게이트 판정에만 쓴다 |
| R-4 | 검색 | ① **호출 0회로 검색어 행 1건**(G-4) |
| R-5 | share | ① **`UNSUPPORTED` 유지.** IP 차단으로 `shareId` 를 못 푼다 |
| R-6 | 자리 없는 11필드 | ① **전부 `data` 로**(카르마 3종 포함) |
| R-8 | `[deleted]` 계정 | ① **계정을 만들지 않는다** |
| R-9 | classify catch-all | ① **현행 유지.** 유료 게이트 켤 때 다시 본다 |
| R-10 | `crawledAt` | ① **Content 에만.** Account 는 `observedAt` 으로 충분하다 |
| R-11 | 검색 키에 서브레딧 | 🔄 **절충안** — 키는 쿼리(G-21 유지), 서브레딧은 `data.communityName` |

### 질문 없이 둔 가정

- **`platformKey` 는 이름 그대로다.** Reddit username·subreddit slug 는 **변경 불가**(대소문자조차)이고
  삭제 후에도 재사용되지 않는다(qna Q1). TikTok·X 에서 handle 을 키에서 뺀 이유가 해당하지 않는다
- **`communityName` 은 소문자로 정규화한다.** `classify` 가 `segments[1].toLowerCase()` 로 키를 만드는데
  응답의 `parsedCommunityName` 은 원래 대소문자를 유지한다(실측 `r/SmugIdeologyMan`).
  맞추지 않으면 **같은 방이 두 값**이 된다
- **`crawledAt` 은 `publishedAt` 이 아니다.** actor 의 크롤 시각이고, mutable 지표의 as-of 앵커다(D-011)

## 4. URL 종류별 산출

| `sourceType` | 관측 <br><small>고유/링크</small> | 호출 | 산출 | `status` |
|---|---|---|---|---|
| `reddit_post` | 153 / 620 | 유료 1회 | **Content 만** — `creatorId` 빔 | `OK` |
| `reddit_user` | 1 / 12 | 유료 1회 | **Account 만** | `OK` |
| `reddit_search` | 0 / 0 | **0회** | Content 1건(`subtype=SEARCH`) | `OK` · `attempted:false` |
| `reddit` | 6 / 40 | **0회** | 없음 | `UNSUPPORTED` |
| `reddit_share` | 14 / 59 | **0회** | 없음 | `UNSUPPORTED` |
| `unknown` | — | **0회** | 없음 | `UNSUPPORTED` |

`UNSUPPORTED` 를 고른 근거는 G-20 의 판정 문장이다 — *"무엇이 바뀌면 성공하나"* 에
답이 **"우리가 만들면"** 이다. `SKIPPED_PAID` 가 아닌 이유는 **호출 0회라 게이트 밖**이기 때문이다.

**"만들지 않는다" 는 객체 얘기다.** URL 자체는 status 와 무관하게
`tokens.social_urls[]` 에 남는다(`collectOne` 이 언제나 `TokenSocialUrlInput` 을 반환).

### `kind` 가 `sourceType` 과 어긋나면

**응답을 따른다**(R-3 ①). 분기가 한 곳이라 규칙이 단순하고, `sourceType` 은 게이트 판정에만 쓴다.
실측상 어긋나지 않았지만(`/user/` → `user_profile` 첫 건) **순서는 actor 의 계약이 아니다.**

`kind: 'community'` 가 오면 — **묶음을 만들지 않고 `UNSUPPORTED`** 다. V-1 로 Venue 를
만들지 않기로 했으므로 담을 자리가 없다. 실측상 오지 않는 값이지만 컴파일러가 분기를 요구한다.

## 5. 필드 매핑

### Content ← `RedditPost`

| 자리 | Reddit | 비고 |
|---|---|---|
| `platform` · `subtype` | `reddit` · `POST` | enum 에 있다 |
| `platformKey` | `parsedId` | |
| `publishedAt` | `createdAt` | |
| `text.primary` · `text.body` | `title` · `body` | `body` 는 audit 에 빠져 있던 필드(qna 가 발견) — CA·티커·내러티브가 담긴다 |
| `outboundUrls` · `mediaUrls` | `contentUrl` · `images[]` | **`domain` 이 가른다** — §5.1. `thumbnail` 은 어느 쪽에도 안 담는다(G-16) |
| `tags` · `mentions` | — | 비운다 |
| `creatorId` · `venueId` | — | 비운다. `creatorId` 는 **V-2 로 영구히 빈다** |
| `metrics` | `upVotes` · `score` · `upvoteRatio` · `commentsCount` · `numCrossposts` | 자리가 이미 다 있다 |
| `data` | `domain` · `outboundUrlHost` · `removedByCategory` · `removedBy` · `bannedBy` · `subredditSubscribers` · `crawledAt` | **7개 신설**(R-6 · §7) |

`communityName` 은 `data.communityName` 으로 가되 **소문자로 정규화**한다.

### 5.1 `contentUrl` 이 세 가지 뜻이다 (2026-08-11 실데이터)

`fixture` 로는 절대 안 드러나는 종류다. **두 번에 걸쳐 발견했다** — 처음 실호출에서 이미지/링크
두 모양을 봤고, 실제 토큰 수집에서 **갤러리**가 나왔다.

| 글 종류 | `domain` | `contentUrl` | `images` |
|---|---|---|---|
| 이미지 | `i.redd.it` | **이미지 원본**(서명 없음) | 같은 파일의 preview 변형 |
| 갤러리 | `reddit.com` | **갤러리 페이지**(이미지가 아니다) | `null` → `galleryImages` 폴백 |
| 본문 | `self.{sub}` | `null` | `[]` |
| 링크 | 외부 도메인 | **진짜 외부 링크** | 그 링크의 썸네일 |

판정이 둘로 갈린다.

```ts
outboundUrls: isRedditOwned(domain)      ? [] : normalized([contentUrl]),
mediaUrls   : isRedditMediaHost(contentUrl) ? normalized([contentUrl]) : normalized(images),
```

1. **`outboundUrls`** — `domain` 이 Reddit 소유(`self.` · `*.redd.it` · `reddit.com`)면 비운다.
   그 필드의 용도가 *"이 콘텐츠가 토큰의 사이트를 가리키는가"* + 드레이너 도메인 탐지라
   `i.redd.it/x.jpeg` · `reddit.com/gallery/x` 가 쌓이면 노이즈다. **관측 대부분이 밈 글이라
   그 노이즈가 다수가 된다.**
2. **`mediaUrls`** — `contentUrl` 의 호스트가 `i.redd.it`·`v.redd.it` 면 그것이 원본이라
   G-16 의 *"원본 위치를 쓴다"* 에 맞고, 아니면 `images` 가 실제 자산이다.
   갤러리 페이지 URL 이 여기 들어가면 화면이 **깨진 이미지**를 그린다.

⚠️ **갤러리를 처음에 놓쳤다.** `r/pics`·`r/technology` 표본 11건이 전부 단일 이미지라
*"갤러리 미관측"* 으로 §8 에 적었는데, **토큰이 실제로 링크한 글 3건 중 2건이 갤러리**였다.
표본을 고른 서브레딧이 우리 관측 분포와 달랐다. 교훈은 *"표본을 어디서 뽑는가가
결론을 정한다"* 다.

⚠️ **갤러리 이미지는 서명 URL 뿐이다**(`preview.redd.it/…?s=<sig>`). 일반 이미지 글은
`contentUrl` 에 서명 없는 원본이 오는데 갤러리는 그 자리가 페이지 URL 이라 대응물이 없다.
만료 가능성을 안고 간다.

### Account ← `RedditUser`

| 자리 | Reddit | 비고 |
|---|---|---|
| `platform` · `platformKey` · `handle` | `reddit` · `username` · `username` | 둘 다 같은 값 — 변경 불가라 이름이 곧 키 |
| `accountCreatedAt` | `createdAt` | **러그리스크 핵심** — 런칭 며칠 전 급조 판별 |
| `bio` | `bio` | |
| `avatarUrl` | `iconImg` | G-16 |
| `metrics.followers` | `followersCount` | G-17 통합 규칙 |
| `displayName` | — | 비운다. Reddit 은 표시명 개념이 약하다 |
| `outboundUrls` · `knownWallets` | — | 빈 배열 |
| `observedAt` | (인자) | |
| `data` | `totalKarma` · `linkKarma` · `commentKarma` | **3개 신설**(R-6). 계정 나이와 함께 봇 판별 축 |

**`crawledAt` 은 Account 에 안 담는다**(R-10 ①) — `observedAt` 이 같은 일을 한다.
Content 에 담는 이유는 그쪽 `metrics` 가 `metric_series` 로 시계열이 되어 **as-of 앵커가 필요**하기 때문이다.

### Content ← 검색 (호출 0회)

```ts
{ platform: REDDIT, subtype: SEARCH,
  platformKey: normalizeFreeText(q),      // G-21 · 8소셜 공통
  text: { primary: q },
  outboundUrls: [], tags: [], mentions: [],
  data: { communityName: sub } }          // R-11 절충안
```

### 버리는 것

| 값 | 왜 |
|---|---|
| `ageHours` · `scorePerHour` · `engagementTotal` · `commentsPerHour` | actor 의 `crawledAt` 기준 파생값이다. 그대로 저장하면 **look-ahead 누수** — raw 를 담고 질의 시점에 as-of 로 재계산한다(D-011) |
| `thumbnail` | `images[0]` 의 축소판일 수 있다. G-16 이 *"같은 그림의 변형을 여러 개 넣지 않는다"* 를 명시한다 |
| `stickied` · `distinguished` · `postType` · `isSelf` | audit remove |
| `parsedAuthorId` · `communityId` | audit 미채택 — 이름이 불변이라 보조키가 불필요하다 |
| `edited` · `editedAt` · `bodyHtml` | audit 미채택 · `body` 의 중복본 |
| `RedditCommunity` 전체 | **도달 불가**(§1) |

## 6. 실패 표현

| 상황 | `status` | `attempted` |
|---|---|---|
| 정상 | `OK` | `true` |
| 검색 | `OK` | **`false`** — 안 불렀다 |
| 응답 0건 | `NOT_FOUND` | `true` |
| 유료 게이트 꺼짐 | `SKIPPED_PAID` | `false` |
| subreddit · share · unknown · `kind:'community'` | `UNSUPPORTED` | `false` |
| `authorName === '[deleted]'` | — | 계정을 안 만들 뿐, Content 는 남는다(R-8) |

**`try/catch` 를 두지 않는다**(R-15). 실패 처리는 `ApifySdk` 가 대표로 갖는다(R-3.6 예외 · `RETRY_NONE`).
보조 호출이 없으므로 R-15 의 흡수 예외가 적용될 자리도 없다.

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

R-6 이 선행 작업의 전부다. **각각 세 곳**을 고친다 — 타입 · 모델 스키마 · 모델 `toData()`.

### `ContentDataInput` +7

```ts
// ── Reddit (RedditPost) — R-6. 키 이름은 소셜 원래 필드명 그대로(G-7)
domain?: string;              // 드레이너·사칭 도메인 tell
outboundUrlHost?: string;
removedByCategory?: string;   // 모더 삭제 3종 = 스캠/스팸 tell
removedBy?: string;
bannedBy?: string;
communityName?: string;       // 소문자 정규화. Venue 를 안 만드는 대신 여기 있다(V-1)
subredditSubscribers?: number;// 관측 시점 스냅샷 — Venue 의 유일한 대체물
crawledAt?: Date;             // mutable 지표의 as-of 앵커(D-011 · R-10)
```

### `AccountDataInput` +3

```ts
// ── Reddit (RedditUser) — R-6
totalKarma?: number;
linkKarma?: number;
commentKarma?: number;
```

⚠️ **`Content.toData()` 와 `Account.toData()` 에 필드 단위로 추가한다**(R-10 규칙).
스프레드로 넘기면 **초과 속성 검사가 안 걸리고 mongoose strict 가 조용히 버린다.**

## 8. 알려진 한계 (design §9 로 올린다)

1. **이 소셜에는 Account 축이 사실상 없다.** V-2 로 계정 경로가 `/user/` 하나인데
   관측 고유 1건이다. 산출이 **Content 153 + Account 1** 이 된다.
   **반대 근거가 남아 있다** — `r/dogecoin`·`r/shiba` 같은 코인 서브레딧의 글은 프로젝트가
   직접 썼을 수 있다. 서브레딧별로 가르는 안은 **판정 기준을 지어내야 해서** 채택하지 않았다.
   관측이 쌓이면 다시 본다
2. **서브레딧 개설일을 영영 못 얻는다.** 다른 소셜에서 *"런칭 며칠 전 급조"* 를 잡는 축인데
   커뮤니티 응답 경로가 없다(§1). V-1 이 감수한 유일하게 아픈 손실이다
3. **삭제 계정은 링크가 끊긴다.** `authorName` 이 `'[deleted]'` 로 치환된다 —
   V-2 로 어차피 post 에서 계정을 안 만들므로 **이번 스코프에서는 드러나지 않는다**
4. **share 링크 14건(고유)을 못 푼다.** Reddit 이 우리 IP 를 전 경로 차단(403)해
   무료 HEAD 로 해석이 안 된다. 유료 경유 해석은 R-5 ①로 보류
5. **전역 검색 `reddit.com/search?q=x` 가 검색으로 안 잡힌다.** `classify` 가
   `segments.indexOf('search') > 1` 이라 catch-all 로 흘러 `reddit / 'search'` 라는
   가짜 서브레딧 키가 된다. R-9 ① 범위 안이라 지금은 두고, **R-9 를 열 때 같이 본다**
6. ~~**관측 수치가 두 집계에서 어긋난다.**~~ → **닫음(2026-08-11).** audit(2026-07-29 · 총 161건)과
   report(07-25~08-04 · `--min-af-count 3` · 고유 174건)는 **모집단이 달라 둘 다 맞는 값**이다.
   대조해 보니 **네 결정(V-1 · V-2 · R-4 · R-5) 모두 건수와 무관**했다 — 고른 답이 전부
   *호출 0회이거나 추가 호출 없음*이라, 비용이 건수에 비례하는 자리가 생기지 않았다.
   **앞으로 인용 기준은 report** 다(프로덕션에도 같은 필터가 걸린다). 대조표는 킥오프 문서에 있다
7. **갤러리 이미지가 서명 URL 뿐이라 만료될 수 있다**(§5.1). 원본 대응물이 없다
8. **actor 응답 순서가 계약이 아니다.** `items[0]` 만 쓰는데 `maxPostsCount:1` 로
   모호함을 줄였을 뿐, actor 가 정렬을 바꾸면 조용히 깨진다

## 9. 실측 기록 (2026-08-11 · $0.124)

`scaffold/reddit.json` 이 없어 **actor 를 직접 호출해 근거를 만들었다.**

| # | 입력 | 결과 | 과금 | 무엇을 정했나 |
|---|---|---|---|---|
| 1 | 글 URL | 1건 · `post` | $0.022 | `$0.04` 이 틀린 값임을 확인 |
| 2 | `/r/solana` | **10건 · 전부 `post`** | $0.04 | V-1 근거 ③ · `maxPostsCount` 필요성 |
| 3 | `/user/spez` | **21건** · `user_profile` + 댓글 20 | $0.062 | 🔴 `dataType` 이 `'user'` 가 아님을 발견 |
| 4 | `/r/solana` + `maxPostsCount:1` | 1건 | $0.022 | 상한이 듣는다 |
| 5 | `/user/spez` + 상한 | 2건 | $0.024 | 댓글이 사라진다 |

**부수 발견 2건** — 둘 다 착수 전에 고쳤다(`71b3b50` · `dd85bd2`).

- `dataType: 'user_profile'` 인데 코드는 `'user'` 만 봤다 → **유저 URL 전량이 예외**였다
- `maxItems` · `skipComments` 가 안 듣는다 → 실효 수단은 `maxPostsCount` · `maxCommentsCount`

**서브레딧 URL 이 주는 첫 글의 정체** (V-2 근거 ③):

```
title       "Welcome to /r/Solana - Read This To Get Started"
authorName  ansi09        ← 모더레이터
createdAt   2025-03-29    ← 16개월 전
stickied    true
```

## 10. 전수 점검 (구현 후 · R-1~R-18 대조)

**3건이 나왔고 전부 고쳤다.** 셋 다 계약 테스트가 못 잡는 종류였다.

| # | 규칙 | 무엇이 틀렸나 | 왜 안 잡혔나 |
|---|---|---|---|
| 1 | **R-9** | `mediaUrls` 에 `post.images` 를 **raw 로** 넣었다. 5소셜은 전부 정규화한다 | fixture 의 URL 이 이미 정규형이라 `expectUrlsNormalized` 가 통과했다 |
| 2 | **R-9** | `avatarUrl` 도 raw 였다 | 같은 이유 |
| 3 | **R-8** | `authorName` 을 **말없이 버렸다** — §5 의 "버리는 것" 표에도 없다 | 의미 판단이라 기계가 못 본다(계약 파일이 스스로 그렇게 적고 있다) |

### 3번이 왜 R-8 인가

V-2 로 Account 를 만들지 않으므로 작성자 정보가 **통째로 사라지고 있었다.**
그런데 **문자열을 담는 것은 간선이 아니다** — V-2 가 막으려 한 것은
*"서브레딧 모더레이터가 토큰에 연결된 계정으로 보이는 것"* 이고, 조인되지 않는 이름은
그 문제를 만들지 않는다. 반대로 이것이 없으면 **V-2 를 되돌릴 수 없다** — 나중에
계정을 만들기로 해도 백필할 근거가 없다.

GitHub 이 `ownerLogin` 을 같은 자리에 두는 것이 선례다(*"표시·대조용. 조인은 `creatorId` 로"*).
→ `ContentDataInput` 에 `authorName` 을 더했다(총 +9).

### 딸려 나온 다른 소셜의 위반 1건

**GitHub 의 `avatarUrl` 도 raw 였다**(R-9). Reddit 과 같은 자리·같은 규칙이라 함께 고쳤고
회귀 테스트를 붙였다. X·TikTok·Instagram 은 이미 `normalizeOne` 을 거치고 있었다.

### 이번에 배운 것

**`*Url`·`*Urls` 자리는 "본문에서 뽑았나" 와 무관하게 전부 같은 계약이다.**
루브릭 R-9 의 서술이 `outboundUrls` 를 예로 들어 *"본문에서 뽑은 값이라"* 로 시작하는데,
그 이유절이 **적용 범위처럼 읽혔다.** 실제 계약은 이름으로 표현돼 있고
(`generator-contract.ts` 의 `isUrlKey` 가 `/Urls?$/`), 그 파일이 **모든** `*Url` 키를 훑는다.
`mediaUrls`·`avatarUrl` 은 API 가 준 값이라 정규형일 때가 많아 **테스트가 통과해 버린다.**

## 11. 전 구간 실호출 (2026-08-11 · 최종 검증)

**실제 actor → fetcher → generator → 실 Mongo → 다시 읽기.** 지금까지의 테스트는 fetcher
응답을 fixture 로 목킹했으므로, *"actor 가 실제로 주는 모양이 우리 타입과 맞는가"* 는
한 번도 코드로 확인된 적이 없었다. 실 관측 URL(토큰 26개가 링크한 밈 글)로 닫았다.

| | 결과 |
|---|---|
| 글 URL | 1건 · Content 만 · `creator`/`venue` 둘 다 `null` · **data 키 유실 0** |
| 유저 URL | 2건 · Account 만 · 카르마 3종이 스네이크 케이스로 왕복 |
| 서브레딧 | `attempted: false` — 호출이 나가지 않았다 |

`maxPostsCount` · 댓글 상한이 실호출에서도 듣는다. mongoose `strict` 유실 0.

**여기서 §5.1 을 발견했다.** 나머지는 전부 설계대로였다.

### 딸려 나온 것 — G-17 의 미확인 항목이 닫혔다

`decisions.md` G-17 에 *"GitHub·Reddit 미확인"* 으로 남아 있던 지표 정밀도 —
**Reddit 은 정확값이다.** `upVotes: 44296` · `subredditSubscribers: 25325235` ·
`totalKarma: 940904` 전부 끝자리가 살아 있다. X · Instagram 에 이어 **세 번째 예외**다.

## 12. 구현 순서

1. **선행** — `ContentDataInput` +7 · `AccountDataInput` +3, 각각 타입·스키마·`toData()` 세 곳
2. **정리** — `toCommunity()` · `RedditCommunity` 제거, `RedditItem` 유니온 축소
3. `RedditGenerator` — `generate` 분기 → `fromPost` · `fromUser` · `fromSearch`
4. spec — `expectGeneratorContract` 를 걸고, URL 종류 6개 · 매핑 · 적대적 입력
5. 배선 — `SocialRecordingModule` 에 등록 (⚠️ 빠뜨리면 **컴파일은 통과하고 부팅에서 터진다**)
6. 전수 점검 — 루브릭 R-1~R-18 대조
