# Social URL Entry — 작업 계획

- 작성 2026-08-12 · 상태 **✅ 구현 완료 (2026-08-12)** — E-5 만 철회
- 파이프라인 전경: [`social-recording-refactor/url-pipeline.html`](./url-pipeline.html)
- 결정 ID 는 `E-*`. `D-*`(schema-v5) · `G-*`(generator) · `U-*`(url-dedupe) · `W-*`(website) 는 이미 쓰인다.

---

## 0. 한 줄 목표

**URL 을 «고칠» 수 있는 곳을 유입 경계로 몰아넣고, 그 아래로는 아무도 URL 을 조용히 고치지
않는다.**

지금은 규칙이 세 군데(`route()` · Generator 8종 · fetcher)에 흩어져 있고, `route()` 는 비정규
URL 이 와도 **말없이 고쳐서** 돌려준다. 그래서 위반이 드러나지 않고, 경로마다 처리가 갈린다.

## 1. 진단 (2026-08-12)

### 1.1 진입점 — 넷 중 셋은 이미 같다

| 진입점 | 호출 | |
|---|---|---|
| `token-social-record.handler` (실시간) | `record()` | 기준 |
| `social-reconcile.reconcileByRange` (backfill) | `record()` | ✅ 동일 |
| `social-record-test.controller` (수동) | `record()` | ✅ 동일 |
| `social-reconcile.retryUnconverged` (**retry**) | `recordOneUrl()` | ❌ 다름 |

| 단계 | record | recordOneUrl | |
|---|---|---|---|
| 토큰 멱등 판정 | ✓ | ✗ | **의도적** — 태우면 재시도가 조기 종료(§3.2) |
| 단축 해제(G-18) | ✓ | **✗** | 🔴 갭 1 |
| 정규화·라우팅 | ✓ | ✓ | 동일 |
| URL 재사용(U-*) | ✓ | **✗** | 🟠 갭 2 |
| 상태 쓰기 | `upsertByAddress` 전체 | 원소 1개 | **의도적** — H-015 |

### 1.2 🔴 갭 1 — 고칠 방법이 없는데 재시도 목록에 영구히 남는다

```
["unsupported"]  https://tiktok.com/t/ZTAyXeVxv
["unsupported"]  https://vt.tiktok.com/ZS4585VPR
```

1. `unsupported` 는 재시도 대상이다
2. retry 가 `route()` → **`tt` 로 간다**(tiktok fetcher 가 `vt.tiktok.com` 을 소유 — 실측 확인)
3. Generator 가 video id 를 못 뽑는다 → 다시 `unsupported`
4. `unsupported` 는 `attempted:false` 라 `attempts` 가 안 올라 **`exhausted` 에 영영 도달 못 한다**
   (`resolveFinalStatus` 의 `if (!attempted) return status;`)
5. → 2 로

**비용은 0이다**(외부 호출이 안 나간다). 깨진 것은 수렴 모델이다.

### 1.3 🟠 갭 2 — 남이 이미 푼 URL 을 다시 부른다

재시도 대상 URL 28건 중 1건이 다른 토큰에서 이미 해결돼 있다.

```
["ok","unsupported"]  https://truthsocial.com/@realDonaldTrump/116981273030570747
```

이쪽은 **실제 호출이 나간다** = 돈.

### 1.4 실측 — 저장된 URL 은 이미 100% canonical 이다

```
── 식별용 (토큰에 연결된 URL · 조인과 대조에 쓴다)
tokens.social_urls[].url   160건 → canonical 160 · 아님 0
token_links.entry_url      111건 → canonical 111 · 아님 0
contents.outbound_urls      62건 → canonical  62 · 아님 0

── 표시용 (fetcher 가 준 이미지·영상 주소)
accounts.avatar_url         80건 → canonical  80 · 아님 0
contents.media_urls         97건 → canonical  94 · 아님 3   ← 전부 web
```

**불변식은 «식별용» 에만 세운다.** 표시용은 파싱을 타지 않아 `parseCanonical` 의 대상이
아니다. 다만 표시용 3건이 새어나온 **원인**은 이번 작업이 함께 고친다(E-3).

**이 측정이 계획의 근거를 두 번 바꾼다.**

① Generator 8종이 `generate()` 첫 줄에서 도는 재정규화는 **아무것도 안 고치고 있다.**
   하는 일은 *분해* 뿐이다 → 변형과 읽기를 쪼갤 수 있다(E-3).

② **저장된 단축 URL 2건은 «비정규» 가 아니라 «안 풀린» 것이다.**
   `https://vt.tiktok.com/ZS4585VPR` 은 형태가 이미 canonical 이다. 두 성질을 구분해야
   판단이 안 흐려진다 — 이 구분 때문에 백필이 선행 조건에서 빠졌고(§5) 결국 철회됐다(E-5).

| | 뜻 | 현황 |
|---|---|---|
| **canonical**(형태) | `normalizeUrl(u).url === u` | ✅ 100% |
| **resolved**(의미) | 단축이 풀렸는가 | ❌ 2건 미성립 |

---

## 2. 결정

### E-1 · 창구 — 주소를 넣으면 «쓸 수 있는 프로필» 이 나온다

업스트림 조회 + 단축 해제 + canonical 화 + 중복 제거를 **한 서비스가 끝낸다.**
호출부는 `TokenInfo` 를 모르고, 받은 URL 이 정규화됐는지 판단하지 않는다.

지금 그 지식은 Processor 에 흩어져 있다 — `SOCIAL_FIELDS` 순회, 쓰레기 값(`"none"`·`"TBA"`)
버리기, `seen` 중복 제거가 전부 `routeSocialUrls` 안에 있다. 창구로 옮긴다.

### E-2 · 창구는 `getTokenByAddress` 경로에만 단다

범위 스캔이 돌려주는 `TrackedToken` 에는 **URL 이 아예 없다.**

```ts
interface TrackedToken {
  tokenAddress; tokenSymbol; investorCount; firstTransferAt; lastTransferAt;
}
```

*"모든 업스트림 조회는 창구를 지난다"* 로 뭉뚱그리면 **스캔한 수백 건 전부에 HEAD 요청이
나간다** — 실제로는 기존 토큰을 뺀 소수만 처리하는데도.

### E-3 · `normalizeUrl` 을 **변형**과 **읽기**로 쪼갠다 — 이 작업의 축

```ts
/** 변형 — **통제 밖에서 온 URL 을 canonical 로 만든다.** 여기서만 URL 이 바뀐다. */
toCanonical(rawUrl: string): string | null

/**
 * 읽기 — canonical 을 **전제하고** 분해만 한다.
 * ⚠️ canonical 이 아니면 **고치지 않고 던진다.**
 */
parseCanonical(url: string): ParsedUrl   // throws
```

**위반에 던지는 것이 이 결정의 전부다**(구 Q5 = A). 조용히 고치면 위반이 안 보인다.
그리고 이것은 새 철학이 아니라 **이미 있는 것의 확장**이다 — R-17 이 Generator 경계에서
같은 말을 하고 있다.

> `const parsed = normalizeUrl(url);`
> *R-17. 받는 URL 은 라우터가 이미 정규화한 값이라, 재정규화 실패는 입력 문제가 아니라
> 우리 버그다. `unsupported` 로 묻으면 정규화가 깨진 것을 아무도 모른다.* → **throw**

R-17 은 지금 *"파싱 불가"* 만 잡는다. `parseCanonical` 은 *"파싱은 되는데 canonical 이 아님"*
까지 잡으므로 **엄격하게 강해진다.**

#### 변형이 남는 곳 = 유입 경계 3종뿐

| 경계 | 왜 통제 밖인가 |
|---|---|
| **창구**(E-1) | 업스트림이 준 자유 텍스트 |
| **fetcher 응답** | HTML·API 에서 뽑은 값 — `outbound_urls` · telegram `external_links` · website RSS/extract |
| **단축 해제기** | 리다이렉트 체인의 `Location` 헤더 |

> **규칙 한 줄: URL 을 고칠 수 있는 곳은 그 URL 이 우리 시스템에 처음 들어오는 지점뿐이다.**

#### 왜 «완전 제거» 가 아닌가

`classify` 가 `host`·`segments`·`params` 를 필요로 한다. 누군가는 URL 문자열을 구조로 풀어야
하고, 그것은 없앨 수 없다. 없앨 수 있는 것은 **고치는 권한**이다.

#### 같은 함수가 **14벌** 복제돼 있다 — 그래서 구멍이 생겼다

Generator 마다 사설 헬퍼를 하나씩 갖고 있고 내용은 전부 `normalizeUrl(u)?.url` + 실패 버리기다.

```
telegram   normalized     normalizeOne        instagram  normalizeAll   normalizeOne
github     normalized     normalizedOne  ←    youtube    normalizeAll   normalizeOne
reddit     normalized     normalizedOne  ←    tiktok     normalizeAll   normalizeOne
x          normalizeAll   normalizeOne        website    (없음)          (없음)  ←❗
```

이름조차 갈려 있고(`normalizeOne` / `normalizedOne`), **website 는 아예 없다.**
`mediaUrls: f.mediaUrl.value ? [f.mediaUrl.value] : []` 로 원본을 그대로 넣는다 — §1.4 의
비정규 3건이 정확히 여기서 나왔다. **사설 헬퍼라 아무것도 강제하지 않았고, 새 소셜을 붙일
때마다 같은 구멍이 열린다.**

그래서 변형 도구를 공용으로 내보내고 복제본 14개를 지운다.

```ts
export function toCanonical(raw: string): string | null;
/** 배열용. **실패한 원소는 버린다** — R-9 가 소셜마다 따로 적던 규칙을 한 곳으로 모은다. */
export function toCanonicalAll(raws: (string | null | undefined)[]): string[];
```

⚠️ **얻는 것은 «변형을 안 하게 되는 것» 이 아니다.** fetcher 유래 URL 은 여전히 통제 밖이라
`toCanonical`(변형)을 쓴다. 얻는 것은 **모두가 같은 변형을 쓰는 것**이고, 안 쓰면 눈에 띈다는 것이다.

#### 따라 나오는 규칙 — 없는 케이스에 분기를 두지 않는다

«조용히 고치지 않는다» 의 다른 얼굴이다. 일어날 수 없는 상황에 `if` 를 세우면 **읽는 사람이
그 상황이 실제로 있다고 믿게 되고**, 그 분기가 무엇을 방어하는지 아무도 다시 확인하지 않는다.
방어 분기는 버그를 막는 대신 **버그를 정상 경로로 위장한다.**

불변식은 코드가 아니라 문서와 실측으로 세우고, 어긋나면 **드러나게** 둔다. `parseCanonical`
이 던지는 것과 같은 판단이며, 이 문서에서 «없다» 고 적은 케이스는 전부 이 규칙을 따른다.

### E-4 · `route()` 는 분류만 돌려준다 — URL 을 고치지도 돌려주지도 않는다

지금 `route()` 는 변형과 판정을 함께 하고, **호출부는 route 가 고쳐 준 문자열을 저장한다.**
라우터가 저장 값의 출처가 되는 것이 어색할 뿐 아니라, E-3 의 규칙을 정면으로 어긴다.

```ts
route(rawUrl): RoutedUrl | null      →      platformOf(url): SocialPlatform | null
```

`owner(host)` 는 그대로 둔다 — `ShortlinkResolver` 가 쓴다.

> 📌 `ParsedUrl` 주석의 *"router 가 한 번 만들어 각 소셜 `classify` 로 넘긴다"* 는 **이미
> 사실이 아니다.** 라우터는 `ParsedUrl` 을 넘기지 않고 각 Generator 가 자기 것을 만든다.
> 이번에 함께 고친다.

### E-5 · ~~백필~~ — **철회 (2026-08-12)**

저장된 단축 URL 2건을 현행 규칙으로 다시 쓰는 마이그레이션을 계획했으나 **하지 않는다.**
dev 데이터라 보존 가치가 없고, 곧 재생성될 값에 마이그레이션 도구를 만드는 비용이 맞지 않는다.

**갭 1 의 «앞으로» 는 E-1 이 닫는다** — 창구가 저장 전에 해제하므로 단축 URL 이
`social_urls` 에 새로 들어올 경로가 없다. 남는 것은 **기존 2건뿐이고 방치한다.**

> ⚠️ prod 에 같은 잔재가 있다면 이 결정은 다시 봐야 한다. 그때 필요한 것은
> `toCanonical(await resolve(url))` 로 `social_urls[].url` 을 제자리 교체하는 일회성 작업이며,
> **`recordSocialUrlResult` 로는 못 한다**(그쪽은 url 로 원소를 *찾는* 연산이라 키를 못 바꾼다).
> 방치되는 URL 은 비용 0이지만 재시도 목록을 영구 점유한다(§1.2).

### E-6 · retry 에 재사용 게이트를 붙인다

갭 2 를 닫는다. 게이트 질의가 **자기 토큰을 후보로 돌려주지만**, 자기 entry 는
`error`·`unsupported` 라 `REUSABLE_FETCH_STATUSES` 필터에서 자동으로 빠진다.
안전하지만 **테스트로 못박아야 하는 지점**이다.

### E-7 · 분류(`platform`)는 창구에 넣지 않는다

v5 T-M1 이 *"분류를 박제하면 분류 버그를 고쳐도 옛 판정으로 재시도한다"* 고 못박았다.
창구는 **해제 + canonical 화까지만**, 분류는 읽는 쪽이 그때그때 부른다.

---

## 3. 시그니처 (승인 대상)

### 3.1 URL 유틸 — 쪼갠다

자리: `src/common/social-fetcher/url-normalizer.ts`

```ts
/**
 * 통제 밖에서 온 URL → canonical 문자열. 파싱 불가면 `null`
 * (업스트림이 `"none"`·`"TBA"` 같은 자유 텍스트를 준다 — 우리 버그가 아니다).
 *
 * **이 함수를 부를 수 있는 곳은 유입 경계 3종뿐이다**(E-3).
 */
export function toCanonical(rawUrl: string): string | null;

/**
 * canonical URL → 분해. **고치지 않는다.**
 *
 * @throws 입력이 canonical 이 아닐 때. 우리 시스템 안을 도는 URL 은 항상 canonical 이라는
 *   것이 불변식이고, 어긋났다면 그것은 입력 문제가 아니라 **우리 버그다**(R-17 확장).
 *   조용히 고치면 위반이 영영 안 보인다.
 */
export function parseCanonical(url: string): ParsedUrl;
```

`normalizeUrl` 은 제거한다. 기존 호출부는 둘 중 하나로 간다(§4).

### 3.2 창구 — 신규

자리: `src/modules/social-recording/token-profile.reader.ts`

```ts
/**
 * 주소 하나 → 우리 도메인 프로필. **업스트림 타입(`TokenInfo`)은 이 아래로 안 샌다.**
 *
 * URL 은 전부 **해제 → canonical → 중복 제거** 를 마친 값이다. 호출부는 «이게 정규화된
 * 값인가» 를 판단하지 않는다 — 그 판단이 흩어진 것이 이 작업의 출발점이다.
 */
export interface TokenProfile {
  address: string;
  symbol: string;
  /** canonical 화만 한다. 단축 해제는 하지 않는다(Q2). */
  imageUrl: string | null;
  /** canonical · 중복 제거 · `SOCIAL_FIELDS` 순서 유지. 쓰레기 값은 버려진다. */
  socialUrls: string[];
}

@Injectable()
export class TokenProfileReader {
  constructor(
    @Inject(SOL_TRACKER_API_SDK) private readonly sdk: SolTrackerApiSdk,
    private readonly shortlinks: ShortlinkResolver,
  ) {}

  /** 업스트림에 없는 주소는 실패가 아니라 정상 결과다 → `null`. */
  async read(address: string): Promise<TokenProfile | null>;
}
```

### 3.3 라우터 — 시그니처 축소

```ts
/**
 * 이 URL 이 어느 소셜 소관인가. **URL 을 고치지도 돌려주지도 않는다**(E-4).
 * 담당 소셜이 없으면 `web` 으로 떨어진다 — 지금 `route()` 와 같다.
 */
platformOf(url: string): SocialPlatform;
```

---

## 4. 변경 지점

### 신규

| 파일 | |
|---|---|
| `src/modules/social-recording/token-profile.reader.ts` | 창구(E-1) |
| `test/unit/social-recording/token-profile.reader.spec.ts` | 해제·canonical·중복 제거·쓰레기 버리기 |

### 수정 — 핵심

| 파일 | 무엇 |
|---|---|
| `url-normalizer.ts` | `normalizeUrl` → `toCanonical` + `parseCanonical`(throws) |
| `social-fetcher.router.ts` | `route()` → `platformOf()` · URL 반환 제거 |
| `social-fetcher.types.ts` | **`ParsedUrl` 의 낡은 주석 교정** · `RoutedUrl` 정리 |
| `social-record.processor.ts` | `routeSocialUrls` 제거 · 창구 주입 · `TokenInfo` import 제거 · `recordOneUrl` 에 게이트 추가(E-6) |
| `social-recording.module.ts` | 창구 provider 등록 |

### 수정 — 기계적 (호출부를 둘 중 하나로 배분)

| 대상 | → `parseCanonical` (읽기) | → `toCanonical` / `toCanonicalAll` (변형) |
|---|---|---|
| Generator 8종 | `generate()` 첫 줄 각 1곳 | **사설 헬퍼 14개 제거** 후 공용으로 치환 |
| `website.generator.ts` | — | **`mediaUrls` 에 새로 태운다** — 지금 안 걸려 있다 |
| `website.rss.ts` · `website.extract.ts` | — | fetcher 유래 URL |
| `shortlink-resolver.ts` | — | 내부 3곳 (raw 를 다룬다) + 주석 교정 |
| `token-inspector.controller.ts` | `route(url)?.platform` → `platformOf(url)` | — |

### 건드리지 않는 것 (백필 대상 아님)

**기존 `media_urls` 비정규 3건.** 표시용이라 식별에 안 쓰이고, 원인(E-3 공용화)이 고쳐지면
다음 재수집 때 새 코드로 다시 써진다. 값어치 대비 백필 비용이 맞지 않는다.

### 건드리지 않는 것

- `getTokensByRange` 경로(E-2)
- `recordSocialUrlResult` 의 원소 단위 쓰기(H-015)
- retry 의 토큰 멱등 제외(§3.2)
- `owner(host)` — `ShortlinkResolver` 가 쓴다

---

## 5. 실행 결과

| | | |
|---|---|---|
| E-1 창구 | ✅ | `TokenProfileReader` — `TokenInfo` 가 Processor 밖으로 안 샌다 |
| E-2 범위 스캔 제외 | ✅ | 창구는 `getTokenByAddress` 경로에만 |
| E-3 변형/읽기 분리 | ✅ | `toCanonical` / `parseCanonical` · 사설 헬퍼 14벌 → 0벌 |
| E-4 `platformOf` | ✅ | 라우터가 URL 을 안 돌려준다 |
| E-5 백필 | ⛔ | **철회** — dev 데이터라 대상 없음 |
| E-6 retry 게이트 | ✅ | 자기 토큰이 후보로 와도 상태 필터에서 자동 제외 |
| E-7 분류는 창구 밖 | ✅ | T-M1 유지 |

**검증** — 단위 1080건 통과(창구 8 · 재시도 게이트 4 신규). 사보타주 3건(창구 canonical 화
생략 · 중복 제거 생략 · 재시도 게이트 미사용) 전부 red 확인. 실데이터 종단 확인 완료.

**착수 전 적어 둔 위험 둘의 결말** — ① `route()` 호출부 13군데는 창구를 먼저 넣자 «URL 은
창구가 준 값» 으로 자명해져 기계적으로 끝났다. ② Processor 테스트는 창구를 목으로 바꾸는
대신 **실물 창구에 스텁 SDK 를 물려** 기존 1068건이 하나도 안 깨졌고 커버리지도 안 빠졌다.

## 6. 실행 순서 — §1.4 가 순서를 하나 뒤집었다

```
E-3 유틸 분리        ← 모든 것의 토대
  ▼
E-1 창구             ← toCanonical 을 쓴다
  ▼
E-4 platformOf       ← parseCanonical 이 있어야 한다
  ▼
E-6 retry 게이트     ← 독립
```

**초안은 «백필이 platformOf 의 전제» 라고 적었는데 틀렸다.** 그 근거는 *"저장된 URL 이
비정규일 수 있다"* 였는데, §1.4 측정이 **100% canonical** 임을 보였다. `platformOf` 는
canonical 여부만 따지고 해제 여부는 안 따지므로, 단축 URL 2건은 `platformOf` 전후로 **동작이
같다**(둘 다 `tt` 로 간다).

그 «독립 항목» 이던 백필은 이후 **철회됐다**(E-5) — dev 데이터라 마이그레이션 대상이 없다.
그래서 실행 순서에 선행 조건이 하나도 남지 않았다.

## 7. 위험

| | 내용 | 완화 |
|---|---|---|
| **8소셜 동시 회귀** | `parseCanonical` 이 던지므로 Generator 전부가 영향권 | 실측상 터질 입력이 **현재 0건**. 각 Generator 는 한 줄 교체 |
| retry 게이트의 자기 참조 | 게이트가 자기 토큰을 후보로 돌려준다 | 자기 entry 는 재사용 불가 상태라 자동 제외 — **테스트로 못박는다** |

## 8. 미결 · 확정 · 미룬 것

### 확정

| | | |
|---|---|---|
| `parseCanonical` 의 위반 반응 | **throw** | 이 작업의 목적. R-17 이 이미 같은 판단 |
| `imageUrl` 단축 해제 | **안 한다** | CDN URL 이라 `entry_url` 이 되지 않는다. canonical 화만 |
| 저장 URL 마이그레이션 | **안 한다**(E-5 철회) | dev 데이터라 대상이 없다. 앞으로는 E-1 이 막는다 |

### 미룬 것 → [`backlog.md`](../../docs/features/social-url-entry/backlog.md)

**B-1 · `tokens.first_transfer_at` 을 실제로 채운다.** 실측 106건 중 0건. 창구가 부르는
`by-addresses` 응답에 그 필드가 **없어서**(범위 응답에만 있다) 창구로는 구조적으로 못 채운다.
축이 다른 작업이라 분리했다.

### 남은 미결

**없다.** E-5 철회로 마지막 미결(백필 실행 위치)이 함께 사라졌다.

## 9. 완료 조건

- 세 경로(실시간·backfill·retry)가 **같은 URL 형태**를 보고 **같은 중복 판정**을 한다
- `toCanonical` 호출부가 **유입 경계 3종에만** 있다
- 재시도 대상 중 «다른 토큰이 이미 푼» URL 에 외부 호출 **0회** (현재 1건)
- `TokenInfo` 가 `src/modules/social-recording/` 밖에서만 보인다
- `normalizeUrl` 이라는 이름이 레포에 없다 ✅
- Generator 안에 정규화 사설 헬퍼가 0개 ✅ (14개 → 0)
- `TokenInfo` 가 `token-profile.reader.ts` 밖에서 안 보인다 ✅
- Generator 안에 **정규화 사설 헬퍼가 0개** (현재 14개) — grep 으로 확인된다
