토큰이 건 링크가 유실되는 문제에 대한 구조. 이 문제와 무관한 필드(텍스트·지표·프로필 상세)는 전부 뺀 최소 모델입니다. · 관련 문서: 링크 도착점 모델 비교 (문제 정의와 후보안 비교) · available-queries.md (쿼리 경로 15개)
unknown 도 객체로 저장한다.platform_key 까지 풀어 기록하고, 불가능하면 URL 로 알 수 있는 데까지만 채운다._id 로 연결한다. 어디에서(object) + 어떤 것(object_id)._id 참조는 우리가 관측한 대상에만 쓴다. 관측하지 않은 언급(트윗의
mentions[] 등)은 _id 로 잇지 않고 값 그대로 저장한다. §5 약점 2 참조.
관측 시점에 해결하지 못한 매핑은 나중에 소급 확정하지 않는다. t3 에 tiktok.com/@doge 를 봤는데
그때 fetch 를 못 했다면, 그 시점 소유자가 누구였는지는 어떤 스키마로도 복원되지 않는다. 관측하지 않은 사실이기 때문이다.
이건 설계 선택지가 아니라 인정하고 가는 한계다.
_id 로 끝나면 → 항상 ObjectId. 우리가 발급하고, 우리 DB 안의 다른 행을 가리킨다 _key 로 끝나면 → 항상 외부 문자열. 남이 발급하고, 우리가 만들지 않는다 _id · creator_id · venue_id · parent_content_id · site_id · object_id ObjectId platform_key '2070140594496626759' 플랫폼 응답이 준 원 id url_key 'foo.com' URL 에서 뽑은 식별자
두 종류를 섞으면 함수 시그니처가 (id: string) 일 때 둘 다 통과해서 조용히 틀립니다.
세션 초반에 "URL 유래 식별자와 응답 유래 식별자는 다르다"고 정리한 것이 이름에 그대로 드러나도록
platform_key·url_key 로 통일합니다.
{
_id ObjectId // 정체성. 링크가 가리키는 값
platform enum ● // x · tt · ig · yt · rd · gh · tg
subtype enum ● // 컬렉션별 어휘 — 아래 표
platform_key string ○ // 응답 유래 원 id. fetch 성공 시에만 채워진다
source_urls object[] ● // 자신을 유래시킨 URL 목록
├ url string ●
├ status enum ● // ok · skipped_paid · error · not_found · blocked · unsupported
└ observed_at Date ●
}
subtype 과 source_urls[] 두 필드가 기존 대비 추가분입니다. 문자열 비즈니스 키
(content_key·account_key·venue_key)는 _id 가 대신하므로 사라집니다.
| 컬렉션 | 답하는 질문 | subtype 어휘 |
|---|---|---|
contents | 이 플랫폼에서 무엇이 주목받나 | tweet · video · post · story · repo · gist · search · intent · shortlink · unknown |
accounts | 누가 | profile · user · owner · channel_owner |
venues | 어디서 | community · subreddit · channel |
contents 에 합친다
search·unknown·shortlink·intent 를 nonobjects 라는
별도 컬렉션에 두는 안을 검토했으나 합치기로 했습니다. 이유는 두 가지입니다.
첫째, 같은 질문에 답하는 좌표입니다. TikTok 에서 유행하는 것은 search 로도
video 로도 표현됩니다. 나누면 "이 소셜에서 무엇이 주목받나"를 보려고 두 컬렉션을 확인해야 하는데,
이 이원화 자체가 draft 가 제기한 문제였습니다.
둘째, unknown 은 모르는 사이트가 아니라 "아는 플랫폼인데 그 안 어디인지 모르는 것"입니다.
라우터는 호스트 조회표에 없는 URL 을 전부 website 로 떨어뜨립니다
(social-fetcher.router.ts:82). 그래서 unknown 이 되는 경우는 URL 파싱 실패이거나,
아는 소셜의 classify 가 경로를 못 알아본 경우뿐입니다. 실측이 이를 뒷받침합니다 —
unknown 398건이 전부 x.com(282)과 tiktok.com(97)이고, 그중 200건이
X 검색·해시태그입니다. 즉 unknown 은 플랫폼 콘텐츠 공간의 미상 좌표입니다.
부수 효과가 더 큽니다. 재분류가 전부 컬렉션 안에서 끝납니다 —
unknown → search, shortlink → video, reddit_share → post 가 모두
subtype 컬럼 갱신 한 번입니다. §4 의 "범위 3군데" 케이스가 분할 때문에 생긴 비용이었습니다.
// 웹사이트를 어떻게 다룰지는 추가 분석이 필요해 보류한다. 지금까지 나온 것만 기록. { _id ObjectId url_key string ● // 등록 도메인(apex) domain_created_at · domain_expiry_at · registrar // RDAP 유래 — 도메인 단위 }
웹 페이지 자체(ogTitle·isNews·server)는 contents 에
platform:web 으로 두고 site_id 로 이 컬렉션을 참조하는 방향까지 논의했으나,
확정하지 않았습니다. 근거와 선행 과제는 §7 결정 1 에 정리했습니다.
{
_id ObjectId
token_address string ●
object enum ● // contents · accounts · venues ← 어디에서
object_id ObjectId ● // ← 어떤 걸
source_url string ● // 관측 시점에 실제로 본 URL. 재할당돼도 남는다
platform enum ● // 객체에서 복사(비정규화). 아래 설명
subtype enum ● // 객체에서 복사(비정규화)
linked_at Date ● // as-of 절단축
observed_at Date ●
}
platform·subtype 복사는 "이 토큰이 TikTok 을 걸었나", "플랫폼별 토큰 노출 집계" 같은
질문을 이 컬렉션 한 번 조회로 끝내기 위한 것입니다. 두 값은 재분류 때만 바뀌고 그 재분류는 이미 링크를
재연결하는 작업이라, 같은 작업 단위에서 함께 고쳐집니다. 기존 설계도 같은 논리로
token_created_at 을 링크에 복사해뒀습니다.
urls 컬렉션을 두지 않는다
모든 URL 을 1행씩 담는 별도 컬렉션을 검토했으나 채택하지 않았습니다. 첫째, URL→객체 조회에 필요하지 않습니다.
URL 을 파싱하면 platform 과 subtype 이 나오고 거기서 어느 컬렉션인지가 정해지므로,
x.com/{handle} 은 accounts 만 보면 됩니다. 둘째, 집계도 감당됩니다 —
"TikTok 노출 전부"는 contents 한 컬렉션이고(판단 기록 0), token_links 에
platform 을 복사해 두면 그마저 필요 없습니다. 셋째, 컬렉션 간 URL 유일성은 애플리케이션이
챙길 수 있습니다. 재분류는 옛 행을 손에 쥔 채 일어나므로(배치가 subtype:unknown 행을 훑으며 다시 분류함)
눈먼 중복이 아니라 의도적인 병합입니다. urls 를 두면 매핑이 두 곳에 생겨 동기화 부담이 영구히
남는데, 그 비용이 얻는 것보다 큽니다.
source_field 를 제거한다
토큰의 어느 칸(website·twitter·telegram·discord)에 URL 이
적혀 있었는지를 담던 필드입니다. 세 가지 이유로 뺐습니다.
첫째, 발행자가 칸을 구분해서 쓰지 않습니다. audit 표본에서 website 칸에 Reddit 18건 ·
GitHub 15건 · TikTok 검색 6건 · X 트윗 3건이 들어 있었습니다. 칸 이름이 내용을 예측하지 못합니다.
둘째, discovered 가 무엇을 긁은 것인지 이 레포에서 확인되지 않습니다. 실물 URL 이
/embed/, utm_source=ig_embed, (이스케이프된 </a>)
형태라 HTML 에서 정규식으로 뽑아낸 것으로 보이지만, 라벨을 붙이는 코드가 sol-alpha-finder-tracker 쪽에
있어 정확한 출처를 모릅니다. 의미를 모르는 값으로 백테스트 게이트를 만들 수는 없습니다.
셋째, adhoc 은 데이터가 아닙니다. social-fetcher.service.ts:67 의 디버깅용
단건 진입점이 넣는 상수이고, audit 실측 7,742건 중 0건입니다.
discovered 의 출처가 tracker 쪽에서 확인되면, "선언 / 발견"을 가르는 축으로 다시 넣는 것이
맞습니다. 선언 링크는 런치 시점에 존재했고 발견 링크는 나중에 찾은 것이라, 섞으면 백테스트에 look-ahead 가 생깁니다.
tokens 안 배열이 아니라 별도 컬렉션으로 둔다
tokens.links[] 배열로 넣는 방안을 검토했습니다. 저장 정보는 같고 배치만 다릅니다.
별도 컬렉션을 택한 이유는 세 가지입니다.
첫째, 읽기 방향이 배열 방향과 반대입니다. 이 시스템의 신호 질문은 "이 계정이 몇 개 토큰에 붙었나",
"이 콘텐츠가 몇 번 재사용됐나" 처럼 객체 기준 역방향이 대부분입니다. 별도 컬렉션이면
{object_id, linked_at} 복합 인덱스만 읽고 끝나지만, 배열이면 multikey 인덱스로 후보를 좁힌 뒤
토큰 문서를 실제로 열어야 합니다. multikey 인덱스는 배열 필드에 대한 쿼리를 커버할 수 없기 때문입니다.
둘째, 재관측이 append 라 배열이 시간에 비례해 자랍니다. 상태가 ok → deleted 로 바뀐
시각이 신호라 덮어쓸 수 없고, 그러면 관측할 때마다 엔트리가 쌓입니다. 다만 이 이점의 크기는 아직
모릅니다 — 재관측 트리거가 미정(v3 §⑭)이고 토큰당 링크 수 실측도 없습니다. 방향만 정해져 있습니다.
셋째, 쓰기 모드가 다릅니다. tokens 는 재관측 시 덮어쓰는 마스터이고 링크 기록은 절대
덮어쓰지 않는 관측 로그입니다. 한 문서에 두 모드를 섞으면 "이 문서는 덮어써도 되는가"의 답이 필드마다
달라집니다. 별도 컬렉션이면 그 리포지토리에 update 메서드를 아예 만들지 않는 것으로 타입 레벨에서
막을 수 있습니다.
배열이 유리한 점도 있습니다. 컬렉션이 하나 줄고, 한 토큰의 링크 여러 개를 한 번의 원자적 쓰기로 넣을 수 있습니다(트랜잭션이 없는 지금 상황에서 실질 이점). 다만 링크가 일부만 남는 것은 덜 기록된 것이지 틀린 기록이 아니라서, 마스터가 반쯤 갱신되는 것보다 피해가 작습니다.
토큰 ABC…pump 의 소셜 필드가 아래와 같고, 관측 시점에 X 와 Telegram 은 fetch 에 성공했으며
Instagram 은 유료 게이트로 실패했다고 하겠습니다.
// 입력 website = foo.com twitter = x.com/kolguy fetch ok → platform_key 555 telegram = t.me/abcchat fetch ok → platform_key 777 discovered = tiktok.com/search?q=abc 비객체 — fetch 대상 아님 discovered = instagram.com/p/Bjnx75lFFQp skipped_paid → platform_key 없음
accounts A1 { platform:x, subtype:profile, platform_key:"555", source_urls:[{ url:"x.com/kolguy", status:ok, observed_at:t1 }] } venues V1 { platform:tg, subtype:channel, platform_key:"777", source_urls:[{ url:"t.me/abcchat", status:ok, observed_at:t1 }] } contents C1 { platform:ig, subtype:post, platform_key:null, source_urls:[{ url:"instagram.com/p/Bjnx75lFFQp", status:skipped_paid, observed_at:t1 }] } ↑ 유료 게이트라 id 를 못 얻었다. 그래도 객체는 존재한다 (전제 1·2) C2 { platform:tt, subtype:search, url_key:"abc", source_urls:[{ url:"tiktok.com/search?q=abc", status:unsupported, observed_at:t1 }] } ↑ 검색도 contents 다 — TikTok 콘텐츠 공간의 좌표이므로 video 와 같은 컬렉션 sites — 미결(§7 결정 1) S1 { url_key:"foo.com" } 웹을 페이지/도메인 2층으로 둘지 결론 안 남
token_links { token:ABC, object:accounts, object_id:A1, platform:x, subtype:profile, source_url:"x.com/kolguy" } { token:ABC, object:venues, object_id:V1, platform:tg, subtype:channel, source_url:"t.me/abcchat" } { token:ABC, object:contents, object_id:C2, platform:tt, subtype:search, source_url:"tiktok.com/search?q=abc" } { token:ABC, object:contents, object_id:C1, platform:ig, subtype:post, source_url:"instagram.com/p/Bjnx75lFFQp" } { token:ABC, … foo.com … } ← 웹 링크가 무엇을 가리킬지는 미결(§7 결정 1)
현행 스키마에서는 이 5건 중 엣지가 생기는 것이 0건입니다. 프로필과 채널은 엣지가 닿을 길이 없고,
검색과 사이트는 tokens.unclassified_links[] 에 문자열로만 남으며, Instagram 은
platform_key 가 없어 content_key 를 조립할 수 없습니다.
이 구조에서는 5건 모두 엣지가 생깁니다.
전제 5 가 말하는 재할당은 실제로 세 가지 형태로 나타나고, 고쳐야 할 범위가 각각 다릅니다. 아래 세 케이스는 모두 audit 실측 또는 fetcher 계약에 근거가 있는 것들입니다.
t1 x.com/search?q=abc → 파싱 실패 contents N9 { platform:x, subtype:unknown, url_key:null } t2 classifier 추가 → x 의 검색 URL 로 해석됨 contents N9 { platform:x, subtype:search, url_key:"abc" } ← 컬럼만 갱신 token_links subtype 복사본만 함께 갱신. object_id 는 그대로
unknown 398건 중 196건이 x.com/search, 4건이
x.com/hashtag/{tag} 입니다. 즉 미분류의 절반은 classifier 만 붙이면 이 형태로 한 번에 정리됩니다.
_id 가 정체성이라 종류가 바뀌어도 링크의 연결 자체는 끊기지 않습니다.
t1 vm.tiktok.com/ZSABC123 → 해석 불가 contents C7 { platform:tt, subtype:shortlink } token_links { object:contents, object_id:C7, source_url:"vm.tiktok.com/ZSABC123" } t2 해석됨 — 영상 7412… 이었다 ① 그 영상의 contents 행이 아직 없다면 범위 1군데 C7 { subtype:video, platform_key:"7412…" } ← 컬럼만 갱신 token_links 는 손대지 않는다 ② 이미 C9 로 존재한다면 범위 3군데 C9.source_urls 에 "vm.tiktok.com/ZSABC123" 추가 token_links 재연결 { object_id:C9, subtype:video, source_url:"vm.tiktok.com/ZSABC123" } ← 그대로 남는다 C7 삭제
contents 에 합친 결과, 이 케이스가 컬렉션 간 이동에서 컬렉션 내 갱신으로 내려왔습니다.
기존 행과 만나는 경우에만 병합이 필요합니다. 어느 쪽이든 링크의 source_url 은 바뀌지 않아
"그때 우리가 본 것은 단축 링크였다"는 관측 사실이 보존되고, 재할당 이력 컬렉션이 따로 필요 없습니다.
t1 instagram.com/p/Bjnx75lFFQp → contents C1 { platform_key:null } t2 instagram.com/{handle}/reel/DZ8NYQ8xnRp → contents C2 { platform_key:null } 둘 다 유료 게이트라 id 가 없어, 같은 게시물인지 알 수 없다 t3 유료 옵션을 켜고 재관측 → 둘 다 platform_key "17954…" 로 밝혀짐 1. C2.source_urls 에 C1 의 URL 들을 합친다 2. token_links 중 object_id=C1 인 것을 전부 C2 로 재연결 3. C1 삭제
skipped_paid 였고, platform_key 없이는 두 URL 이 같은 게시물인지 판정할 근거가
아예 없습니다. 그동안은 두 객체로 남습니다 — 어떤 스키마를 써도 같습니다.
범위 3군데짜리 재할당은 여러 컬렉션을 순차로 고칩니다. Phase 1.6 에서 트랜잭션 처리를 명시적으로 제외했으므로
(be-system-design.md §6), 중간에 프로세스가 죽으면 링크 일부만 재연결된 상태가 남습니다.
다만 그 상태도 링크가 유효한 객체를 가리키고 있다는 점은 유지되므로 조회가 깨지지는 않고,
재분류 배치를 다시 돌리면 수렴합니다.
이 구조가 못 하는 쿼리가 무엇인지, 그 쿼리가 실제로 필요한지를 따졌습니다. 요구사항은
available-queries.md 의 탐색 경로 15개를 그대로 썼습니다. 다만 그 문서가 제안한 스키마는 다릅니다 —
tokens.contentRefs[] · contents.linkedTokenAddresses[] 양방향 배열 구조인데,
판단 기록 3 에서 검토해 채택하지 않은 방식입니다. 그래서 경로 목록만 요구사항으로 사용했습니다.
| 경로 | 쿼리 | 판정 | 근거 |
|---|---|---|---|
| A-1 | 토큰 → 콘텐츠 | 가능 | token_links 1회 + 객체 4회. 병렬로 던지면 라운드트립 2회 |
| A-2 | 콘텐츠 → 토큰 | 가능 | {object_id, linked_at} 복합 인덱스 1회 |
| A-3 | KOL → 콘텐츠 → 토큰 | 가능 | $lookup 체인. from 이 전부 상수라 단일 파이프라인 |
| B-1 | 알파 계정 → 최근 콘텐츠 → 토큰 | 가능 | 구조상 A-3 과 동일. 승률 지표는 파생 계층이라 별도 |
| B-2 | 토큰 → 콘텐츠 → 작성자 → 계정 | 가능 | {$match:{object:'contents'}} 를 앞에 두면 from 이 상수가 됨 |
| B-3 | 계정 지표 급등 → 토큰 | 스코프 밖 | account_metrics 시계열 컬렉션이 아직 없음. 구조와 무관 |
| C-1 | 라벨/키워드 → 토큰 | 불편 | 아래 약점 1 |
| C-2 | 플랫폼별 영향력 비교 | 개선 | 아래 이 구조가 유일하게 고치는 경로 |
| D-1 | 콘텐츠 속도 → 토큰 | 가능 | 스냅샷 → 콘텐츠 → 링크 → 토큰 |
| D-2 | 시세 스파이크 → 시간대 콘텐츠 | 가능 | linked_at 범위 인덱스 |
| D-3 | 언급량-시총 다이버전스 | 스코프 밖 | 토큰 시세 시계열은 tracker 레포 소유. 복사 금지 |
| E-1 | 인용 계보 | 가능 | lineage[] 유지. ObjectId[] 로 타입만 변경 |
| E-2 | 크로스 플랫폼 + 지갑 | 가능 | accounts.known_wallets multikey 그대로 |
| E-3 | 코-멘션 네트워크 | 경계 필요 | 아래 약점 2 |
"틱톡 바이럴로 뜬 토큰과 트위터 인용으로 뜬 토큰 중 어느 플랫폼이 나은가" 라는 질문입니다.
현행 스키마에서는 tiktok_search 57건이 contents 에 들어가지 못해 TikTok 쪽 분모가
틀린 답이 나옵니다. 이 구조에서는 검색도 객체가 되고, token_links.platform 복사본 덕분에
링크 컬렉션 한 번 group 으로 끝납니다. draft 가 제기한 문제가 정확히 이것이었습니다.
토큰 CA 나 키워드가 나오는 곳이 콘텐츠 본문만이 아닙니다. 설계 문서 §1.5 는 venues.description 에 대해
"토큰 CA 가 실제로 여기 노출됨(실측)" 이라고 적고 있고, accounts.bio 도 마찬가지입니다.
따라서 "CA 가 노출된 모든 곳"을 찾으려면 $unionWith 로 세 컬렉션을 합쳐야 하고, 텍스트 인덱스도
세 벌이 필요합니다.
가능성과 효용은 둘 다 높습니다. C-1 은 내러티브 클러스터링의 진입점입니다. 다만 이번 구조가 만든 문제가
아닙니다. contents/accounts/venues 분리는 원래부터 있었고 텍스트가 세 곳에
흩어진 것도 원래부터입니다. 비객체를 contents 에 합쳐도 그 행들에는 텍스트가 없어 대상이 늘지 않습니다.
mentions[] 를 _id 로 참조하면 안 된다 (E-3)
이건 이번 구조가 새로 만드는 문제라 전제 6 으로 올렸습니다. 전제 3(링크는 _id 로 연결)을
mentions[] 에도 적용하면, 트윗에 언급된 계정마다 accounts 에 빈 행을 만들어야 합니다.
x_tweet 만 3,879건이고 트윗 하나에 멘션이 여럿이라 빈 행이 폭증합니다.
그런데 멘션은 관계가 아니라 관측된 텍스트 사실입니다. 우리가 그 계정을 fetch 한 적이 없고 존재 여부도
모릅니다. 그래서 값 그대로 저장하고, 나중에 그 계정을 실제로 관측하면 platform_key 로 이어지게 하는 것이
맞습니다. E-3 의 코-멘션 클러스터링은 platform_key 기준 group 으로 풀립니다.
mentions: [{ platform_key: "1534…", screen_name: "kolguy" }] ← _id 참조 아님
$lookup 다형 조인 제약은 실제로 걸리지 않았다
논의 과정에서 제가 "$lookup 의 from 이 상수여야 해서 다형 조인이 안 된다"를 이 구조의
비용으로 반복해 지적했습니다. 15개 경로를 실제로 훑어보니 걸리는 경로가 없습니다. 실제 쿼리는 대상 종류를
지목하기 때문입니다 — "이 토큰의 콘텐츠", "이 토큰의 계정". {$match: {object: 'contents'}} 를
앞에 붙이면 from 이 상수가 됩니다. 종류를 안 가리고 조인해야 하는 경우는 "이 토큰이 건 모든 것의
상세를 한 번에" 정도인데, 그건 앱에서 4회 조회로 하는 편이 자연스럽습니다.
1. token_links.find({ token_address: ABC }) → 1회 2. object 별로 묶어 각 컬렉션에 _id $in 조회 → 최대 4회 (병렬) ───────── 순차 5회 · 병렬 2 라운드트립 토큰 100개를 한 번에 뿌려도 쿼리 수는 그대로다 — $in 으로 묶이므로 N+1 이 생기지 않는다. 플랫폼·종류만 필요하면 token_links 1회로 끝난다 (비정규화 복사).
15개 경로 중 구조 때문에 못 하는 것은 0개입니다. 불편한 것이 2개(C-1 텍스트 분산, E-3 멘션 참조), 스코프 밖이 2개(B-3·D-3)이며, 나머지 11개는 인덱스로 바로 갑니다. 그리고 C-2 는 현행에서 틀린 답이 나오던 것이 이 구조에서 고쳐집니다.
| 대상 | 변경 | 내용 |
|---|---|---|
contents | 필드 추가·교체 | subtype, source_urls[] 추가. content_key(문자열 키) 제거 후 platform_key 로 대체 — 정체성은 _id 가 맡는다 |
accounts | 필드 추가·교체 | 동일. account_key → platform_key |
venues | 필드 추가·교체 | 동일. venue_key → platform_key |
contents subtype | 어휘 확장 | search·intent·shortlink·unknown 을 흡수한다 (판단 기록 0). 별도 비객체 컬렉션을 두지 않는다 |
sites | 미결 | 웹 처리 방식이 정해지면 결정. §7 결정 1 |
content_token_links | 구조 변경 | token_links 로. content_key → object + object_id + source_url. platform·subtype 비정규화 추가 |
…links.source_field | 제거 | 판단 기록 2 |
tokens.unclassified_links[] | 제거 | 모든 URL 이 객체가 되므로 역할이 사라진다 |
tokens.web{} | 미결 | 웹을 페이지/도메인 2층으로 두면 지문이 그쪽으로 옮겨간다. §7 결정 1 |
contents.mentions[] | 유지 | _id 참조로 바꾸지 않는다 (전제 6). {platform_key, screen_name} 값 저장 유지 |
lineage[] | 타입 변경 | 문자열 배열 → ObjectId 배열. 조상 객체가 먼저 존재해야 하는데, 전제 1·2 가 그것을 보장한다 |
content_metric_snapshots | 참조 변경 | content_key → object_id |
| 인덱스 | 미정 | source_urls.url multikey · {object_id, linked_at} · {token_address} 까지만 확정. 나머지는 별도 설계 |
컬렉션 추가가 하나뿐이고 기존 마스터는 필드 두 개를 더하는 선에서 끝납니다. 아직 실데이터가 없어 마이그레이션 자체가 없습니다.
subtype 어휘를 무엇으로 확정할 것인가. 특히 Telegram 의 portal·shell 은
URL 만으로는 판정할 수 없고 프리뷰를 받아야 갈립니다 (실측 24건: 포털 54% · 빈껍데기 25% · 활성 4%).
이것을 subtype 으로 볼지, 관측 결과 필드로 내릴지 정해야 합니다.
source_urls[] 항목에 구간을 넣을 것인가. TikTok 은 핸들을 한 달 주기로 회전시키고 옛 핸들을
재할당합니다. 같은 URL 이 시기별로 다른 계정을 가리킬 수 있으므로, 항목에
first_seen/last_seen 을 넣으면 as-of 로 풀 수 있습니다.
accounts.handles[] 가 이미 같은 방식이라 새 개념은 아닙니다.
status 를 어디에 둘 것인가. 지금 source_urls[].status 와 링크 쪽 상태가 둘 다
존재할 수 있습니다. 링크는 append-only 라 상태 이력을 담고 객체 쪽은 최신값이 되는데, 둘의 역할 분담을
명시해야 합니다.
contents.text 만 볼지, venues.description·
accounts.bio 까지 $unionWith 로 묶을지 (§5 약점 1).
1. 웹 URL 도 contents 에 넣는다. platform='web' · subtype='site'
2. tokens.web{} · fingerprints[] 는 현행 그대로 둔다
3. contents(web) 에 도메인 층 필드를 만들지 않는다 (site_id 도 아직 없음)
2번이 핵심입니다. 잠정 기간에도 도메인 공유 카운트가 기존 경로로 계속 작동합니다 —
tokens.count({ fingerprints: "domain:foo.com", createdAt: {$lt: T} }).
검증된 주력 신호(knowyourmeme.com 22건 rug 100%)가 끊기지 않습니다.
왜 이 순서가 안전한가. "귀속"을 기본값으로 두면 나중의 "분리"가 전부 덧붙이기입니다 —
sites 컬렉션을 만들고 contents(web) 행에 site_id 를 채우는 백필이면 끝이고,
링크는 손대지 않습니다. 반대 순서였다면 파괴적입니다. 도메인 단위로 먼저 만들면 나중에 페이지로 쪼갤 때
과거 관측을 되살릴 수 없습니다.
공짜는 아닙니다. 최종 결정이 "링크가 페이지가 아니라 도메인을 가리켜야 한다" 로 나오면 재연결이 필요합니다.
token_links.source_url 이 관측 URL 을 보존하므로 복구는 가능합니다. 다만 토큰이 실제로 건 것은
페이지이고 도메인은 그 상위 개념이라, 링크의 도착점은 페이지가 맞다고 봅니다.
웹은 지문의 출처부터 두 층으로 갈려 있습니다. 필드 인벤토리(sources/website-image.html)를
소스별로 정리하면 이렇습니다.
| 소스 | 필드 | 채움% | 무엇의 속성인가 |
|---|---|---|---|
| RDAP | domainCreatedDate · domainExpiryDate · registrar | 81.7 · 81.6 · 82.2% | apex 도메인 — rdap.org/domain/{apex} 로 조회 |
| HTML/OG | isNews · ogTitle · ogSiteName | 76.0 · 46.0 · 25.8% | 개별 페이지 — 그 URL 을 GET 해서 파싱. ogTitle 실측 예시가 "Grand Theft Auto VI - Rockstar" |
| urlscan | server · ip · asn · techCount · certValidFrom | 49~57% | 개별 URL — 그 URL 의 스캔 기록 |
그리고 검증된 신호가 두 층에 갈려 있습니다. 도메인 층에는 도메인 나이 731일+(rug 85/84/91%)와
도메인 공유 카운트(knowyourmeme.com 22건 rug 100% · axiom.trade 21건 90% ·
orynth.dev 18건 0%)가 있고, 페이지 층에는 isNews = true(rug 80/89/100%)와
technologies 4개+(rug 83/87/98%)가 있습니다. 도메인으로만 접으면 페이지 층이 뭉개지고,
페이지로만 두면 도메인 지문이 페이지마다 복사됩니다.
contents { platform:web, subtype:page|article, url_key:"정규화 URL", og_title · is_news · server · tech_count, // 페이지 층 site_id → sites._id } sites { url_key:"apex 도메인", domain_created_at · domain_expiry_at · registrar } // 도메인 층 도메인 공유 카운트 = site_id 가 같은 contents 들의 링크 수 페이지 카운트 = url_key 별
이 방향이면 token_links.object 는 contents · accounts · venues 세 값을 유지하고,
sites 는 링크가 직접 가리키지 않는 도메인 층 앵커가 됩니다.
website URL 중 apex 만인 비율을 모릅니다. 대부분이 apex 라면 contents(web) 과
sites 가 거의 1:1 이 되어 2층이 과설계입니다. audit 샘플에 website 파일이 없어
(samples/ 에 소셜 13종만 있음) 지금은 측정할 수 없습니다.
접는 것(페이지 행을 도메인으로 병합)은 나중에도 가능하지만 펴는 것은 관측 이력이 없어 불가능하므로, 측정 전이라면 2층으로 시작하는 쪽이 안전합니다.
1. normalize() 가 실제로는 정규화를 하지 않습니다.
social-fetcher.router.ts:110 의 주석은 "소문자 host · www 제거 · trailing 정리" 라고 하지만,
host 변수만 www. 를 떼고 반환값 url 은 u.toString() 원본입니다.
그래서 www.foo.com/promo · foo.com/promo · foo.com/promo?utm_source=x ·
foo.com/promo/ 가 전부 다른 문자열입니다. 소셜은 키를 segments[] 에서 뽑아 문제가
없었지만, 웹 페이지는 URL 자체가 키라 바로 갈라집니다.
2. apexDomain() 이 마지막 두 라벨만 봅니다. 같은 파일의
labels.slice(-2).join('.') 라 shop.foo.co.uk 가 co.uk 가 됩니다.
즉 모든 .co.uk 사이트가 한 도메인으로 접힙니다. 지금은 sourceKey 로만 쓰여 티가 안 났지만,
sites.url_key 가 되는 순간 검증된 주력 신호인 도메인 공유 카운트가 오염됩니다.
Public Suffix List 기반으로 바꿔야 합니다. 영향 규모는 website 표본이 없어 아직 모릅니다.
웹 URL 정규화 규칙 초안 — 소셜과 달리 URL 자체가 키라서 명시가 필요합니다.
| 항목 | 규칙 | 이유 |
|---|---|---|
| 스킴 | 제거 | http/https 가 같은 페이지를 가름 |
www. | 제거 | 같은 페이지 |
| 호스트 대소문자 | 소문자 | DNS 가 대소문자를 구분하지 않음 |
| 서브도메인 | 유지 | app.foo.com 과 foo.com 은 다른 페이지. 단 site_id 는 같음 |
| 경로 대소문자 | 유지 | 서버가 구분함 |
trailing / | 제거 | 같은 페이지 |
fragment # | 제거 | 서버에 전송되지 않음 |
| 추적 파라미터 | 제거 | utm_* · fbclid · ref · gclid |
| 그 외 파라미터 | 유지 + 키 순 정렬 | ?id=123 이 페이지를 가르는 사이트가 있음 |
이 문서에 없는 것 — 텍스트·지표·프로필 등 상세 필드, 인덱스 설계, 그리고 draft 의 나머지 항목 (공통 필드 최소화, Creator 재정의, 최적화용/공용/개별 필드의 문서 표기 구분). 전부 위 결정들이 확정된 뒤에 붙습니다.