"""덤프 4개를 문서 번호 기반 그래프 파일로 바꾼다.
사용법: python -I scripts/preprocess.py <덤프 폴더> <출력 폴더>
출력
titles.tsv : 줄마다 "번호<TAB>page_id<TAB>제목". 번호는 0부터 빈틈없이 매긴다.
links.txt : 줄마다 "출발번호 대상번호 대상번호 ...". 출발번호 순서이고, 대상번호는 오름차순이며 겹치지 않는다.
나가는 링크가 없는 문서도 번호만 있는 줄로 남긴다.
"""
import gzip
import re
import sys
import time
from array import array
from pathlib import Path
STR = rb"'((?:[^'\\]|\\.)*)'"
OPT = rb"(NULL|'(?:[^'\\]|\\.)*')"
RX_PAGE = re.compile(rb"\((\d+),(-?\d+)," + STR + rb",(\d),", re.S)
RX_REDIRECT = re.compile(rb"\((\d+),(-?\d+)," + STR + rb"," + OPT + rb",", re.S)
RX_LINKTARGET = re.compile(rb"\((\d+),(-?\d+)," + STR + rb"\)", re.S)
RX_PAGELINKS = re.compile(rb"\((\d+),(-?\d+),(\d+)\)")
def unescape(raw):
return re.sub(rb"\\(.)", lambda m: m[1], raw, flags=re.S)
def rows(path, rx):
"""INSERT 아래의 행(한 줄에 한 행)을 읽는다. 해석 못 하는 줄이 있으면 바로 멈춘다."""
in_insert = False
with gzip.open(path, "rb") as f:
for line in f:
if in_insert and line[:1] == b"(":
m = rx.match(line)
if m is None:
raise ValueError(f"해석 실패 {path}: {line[:200]!r}")
yield m
continue
in_insert = line.startswith(b"INSERT INTO")
def main(raw_dir, out_dir):
t0 = time.time()
log = lambda msg: print(f"[{time.time() - t0:7.1f}s] {msg}", flush=True)
raw, out = Path(raw_dir), Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
# 1. page: 일반 문서(공간 0)의 제목과 넘겨주기 여부
id_of, title_of, redirect_pages = {}, {}, set()
for m in rows(raw / "kowiki-latest-page.sql.gz", RX_PAGE):
if m[2] == b"0":
pid, title = int(m[1]), unescape(m[3])
id_of[title] = pid
title_of[pid] = title
if m[4] == b"1":
redirect_pages.add(pid)
log(f"page: 일반 문서 {len(title_of):,}개, 넘겨주기 {len(redirect_pages):,}개")
# 2. redirect: 넘겨주기 문서 -> 진짜 문서
rd = {}
for m in rows(raw / "kowiki-latest-redirect.sql.gz", RX_REDIRECT):
frm = int(m[1])
if frm in title_of:
rd[frm] = (int(m[2]), unescape(m[3]), m[4] not in (b"NULL", b"''"))
def resolve(pid):
seen = {pid}
while True:
t = rd.get(pid)
if t is None or t[2] or t[0] != 0:
return None
nxt = id_of.get(t[1])
if nxt is None:
return None
if nxt not in redirect_pages:
return nxt
if nxt in seen:
return None
seen.add(nxt)
pid = nxt
final = {p: resolve(p) for p in redirect_pages}
log(f"redirect: 풀린 넘겨주기 {sum(v is not None for v in final.values()):,}개")
# 3. 넘겨주기가 아닌 문서에 0부터 새 번호를 매긴다 (page_id 오름차순)
articles = sorted(p for p in title_of if p not in redirect_pages)
new_id = {p: i for i, p in enumerate(articles)}
n = len(articles)
with open(out / "titles.tsv", "w", encoding="utf-8", newline="\n") as f:
for i, p in enumerate(articles):
f.write(f"{i}\t{p}\t{title_of[p].decode('utf-8', 'replace')}\n")
log(f"titles.tsv: {n:,}줄")
# 4. linktarget: lt_id -> 새 번호 (문서로 이어지는 대상만)
lt = {}
for m in rows(raw / "kowiki-latest-linktarget.sql.gz", RX_LINKTARGET):
if m[2] != b"0":
continue
pid = id_of.get(unescape(m[3]))
if pid is None:
continue
dest = final.get(pid) if pid in redirect_pages else pid
if dest is not None:
lt[int(m[1])] = new_id[dest]
log(f"linktarget: 문서로 이어지는 대상 {len(lt):,}개")
del id_of, title_of, redirect_pages, rd, final
# 5. pagelinks: (출발, 대상) 쌍으로 바꾼다. pl_from 순서가 아니므로 모아서 정렬한다.
packed = array("Q")
for m in rows(raw / "kowiki-latest-pagelinks.sql.gz", RX_PAGELINKS):
if m[2] != b"0":
continue
src = new_id.get(int(m[1]))
if src is None:
continue
dst = lt.get(int(m[3]))
if dst is None or dst == src:
continue
packed.append(src << 32 | dst)
log(f"pagelinks: 후보 {len(packed):,}개, 정렬 시작")
del lt, new_id
ordered = sorted(packed)
del packed
log("정렬 끝, links.txt 쓰는 중")
# 6. 겹치는 쌍을 지우며 한 줄에 한 문서씩 쓴다
edges = 0
with open(out / "links.txt", "w", encoding="ascii", newline="\n", buffering=1 << 20) as f:
cur, targets, last = 0, [], None
for v in ordered:
if v == last:
continue
last = v
src = v >> 32
while cur < src:
f.write(" ".join([str(cur), *targets]) + "\n")
cur, targets = cur + 1, []
targets.append(str(v & 0xFFFFFFFF))
edges += 1
while cur < n:
f.write(" ".join([str(cur), *targets]) + "\n")
cur, targets = cur + 1, []
log(f"links.txt: {n:,}줄, 간선 {edges:,}개")
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])
노잼 얘기들 (덤프 구조, 연결 방법, 전처리 순서)
파일 4개
압축을 풀면 다 합쳐서 약 2.4GB예요. 가장 큰 파일은 pagelinks이고, 제목은 page, linktarget, redirect에 나뉘어 있어요.
파일
담긴 것
그래프에서 맡는 일
행 수
압축
압축 해제
page
문서 번호, 제목, 넘겨주기 여부
점
3,632,898
119.1 MB
426.4 MB
pagelinks
어느 문서가 어느 대상 번호로 링크하는지
화살표
85,418,621
303.3 MB
1,680.9 MB
linktarget
대상 번호와 제목의 대응표
번호를 제목으로 바꾸기
6,615,703
79.9 MB
262.2 MB
redirect
넘겨주기 문서가 가리키는 제목
별명 문서를 진짜 문서로 합치기
945,105
10.2 MB
39.8 MB
연결 방법
링크 행에는 대상 문서의 번호가 없어요. pl_target_id는 linktarget의 번호라서, 먼저 제목으로 바꾼 다음 page에서 문서를 찾아야 해요.
파란 실선은 링크 한 줄을 문서로 바꾸는 길이고, 회색 점선은 넘겨주기를 푸는 길이에요.
순서
이 값으로
여기서 찾아요
얻는 것
①
pagelinks.pl_from
page.page_id
링크를 건 문서
②
pagelinks.pl_target_id
linktarget.lt_id
대상의 공간 번호와 제목
③
lt_namespace, lt_title
page_namespace, page_title
대상 문서. 없으면 아직 안 쓰인 문서
④
redirect.rd_from
page.page_id
넘겨주기 문서
⑤
rd_namespace, rd_title
page_namespace, page_title
넘겨주기가 가리키는 진짜 문서
테이블 구조
파란색 열만 쓰면 돼요. 예시 행은 덤프에 있는 실제 값이고, 일반 문서만 골랐어요.
page
문서 하나당 한 행
열
형식
뜻
page_id
int
문서 번호. 중간에 빈 번호가 많아요
page_namespace
int
공간 번호. 일반 문서는 0
page_title
varbinary
제목. 띄어쓰기는 밑줄
page_is_redirect
tinyint
넘겨주기 문서면 1
page_id
namespace
page_title
is_redirect
5
0
지미_카터
0
9
0
수학
0
389
0
스타크래프트
0
1368866
0
The_New_York_Times
1
나머지 8개 열은 쓰지 않아요: page_is_new, page_random, page_touched, page_links_updated, page_latest, page_len, page_content_model, page_lang
pagelinks
링크 하나당 한 행
열
형식
뜻
pl_from
int
링크를 건 문서의 page_id
pl_from_namespace
int
링크를 건 문서의 공간 번호
pl_target_id
bigint
대상의 lt_id. 문서 번호가 아니에요
pl_from
from_namespace
pl_target_id
710049
0
8031
710049
0
297729
710049
0
421263
710049
0
4757119
linktarget
링크 대상 하나당 한 행
열
형식
뜻
lt_id
bigint
대상 번호
lt_namespace
int
대상의 공간 번호
lt_title
varbinary
대상 제목. 없는 문서의 제목도 들어 있어요
lt_id
namespace
lt_title
8031
0
스타크래프트
297729
0
The_New_York_Times
421263
0
미드_시즌_인비테이셔널_2024
4757119
10
리그_오브_레전드
redirect
넘겨주기 문서 하나당 한 행
열
형식
뜻
rd_from
int
넘겨주기 문서의 page_id
rd_namespace
int
가리키는 문서의 공간 번호
rd_title
varbinary
가리키는 문서의 제목
rd_interwiki
varbinary
값이 있으면 다른 위키로 가는 넘겨주기
rd_fragment
varbinary
문서 안의 위치. 쓰지 않아요
rd_from
namespace
rd_title
60
0
민주당_(2000년_대한민국)
72
0
알베르트_아인슈타인
102
0
음악가
1368866
0
뉴욕_타임스
원본 파일의 생김새
SQL 파일이지만 INSERT 줄 아래에 행이 한 줄씩 있어서, 줄 단위로 읽고 정규식으로 나눌 수 있어요. 제목에 쉼표와 괄호가 들어가니까 쉼표로만 자르면 안 돼요.
INSERT INTO `page` VALUES
(3,4,'대문',0,0,0.02613266955,'20261101202310','20261101202453',42458456,14960,'wikitext',NULL),
(5,0,'지미_카터',0,0,0.932405166265001,'20261031012503','20261030071633',42412419,64090,'wikitext',NULL),
링크 하나 따라가기
리그_오브_레전드 문서(page_id 710049)에서 나가는 실제 행이에요. 같은 모양의 행도 대상에 따라 다섯 가지로 갈려요.
pagelinks 행
linktarget에서 찾은 값
page에서 찾은 결과
처리
710049, 0, 8031
0, 스타크래프트
일반 문서 389번
화살표로 저장
710049, 0, 297729
0, The_New_York_Times
넘겨주기 문서 1368866번, 진짜 문서는 뉴욕_타임스 47206번
47206으로 바꿔 저장
710049, 0, 343116
0, 뉴욕_타임스
일반 문서 47206번. 바로 위 행과 같은 화살표
겹치므로 하나만
710049, 0, 421263
0, 미드_시즌_인비테이셔널_2024
page에 없음 (빨간 링크)
버림
710049, 0, 4757119
10, 리그_오브_레전드
공간 10번(틀), 일반 문서가 아님
버림
남는 행 수
pagelinks 85,418,621행 중 63.3%가 화살표로 남아요. 막대 길이는 전체 행에서 차지하는 비율이에요.
분류
처리
행 수
비율
비율 막대
출발지가 일반 문서가 아님 (토론, 사용자, 틀 등)
버림
14,405,612
16.86%
대상이 없는 문서 (빨간 링크)
버림
11,917,369
13.95%
대상이 일반 문서가 아님
버림
3,277,401
3.84%
출발지가 넘겨주기 문서
버림
911,250
1.07%
넘겨주기를 풀었더니 겹침
하나만
792,516
0.93%
자기 자신을 가리킴
버림
28,946
0.03%
끊긴 넘겨주기로 향함
버림
75
0.00%
최종 화살표
저장
54,085,452
63.32%
후보 54,877,968행 중 3,728,111행은 넘겨주기 문서를 가리켜서 진짜 문서로 바꿨어요. linktarget에 번호가 없는 행과 page에 출발 문서가 없는 행은 하나도 없었어요.
page: 점으로 남는 문서
전체 행
3,632,898
공간 0번 (일반 문서)
1,680,906
그중 넘겨주기
910,792
점으로 남는 문서
770,114
redirect: 넘겨주기 910,792개를 풀면
한 번에 도착
910,735
두 번 거쳐 도착
2
다른 공간으로 향함
53
대상 없음
1
서로 가리키며 순환
1
전처리 순서
앞 단계에서 만든 표를 다음 단계에서 쓰기 때문에 이 순서대로 해야 해요.
page를 읽어 공간 0번만 남기고, 제목 → page_id 표와 넘겨주기 목록을 만들어요.
redirect를 읽어 넘겨주기 문서 → 진짜 문서 표를 만들어요. 두 번 거치는 경우까지 따라가고, 순환하거나 대상이 없으면 끊긴 넘겨주기로 표시해요.
넘겨주기가 아닌 문서 770,114개에 0부터 770,113까지 새 번호를 붙여요. page_id는 최댓값이 4,296,017이라 그대로 배열 위치로 쓰면 빈칸이 많아져요.
linktarget을 읽어 lt_id → 최종 문서 표를 만들어요. 공간 0번 대상 4,501,007개 중 문서로 이어지는 것은 1,119,370개(바로 757,483개, 넘겨주기 361,887개)이고, 3,381,616개는 없는 문서, 21개는 끊긴 넘겨주기예요.
pagelinks를 읽어 출발지와 대상을 새 번호로 바꿔요. 자기 자신을 가리키는 행은 버려요.
(출발, 대상) 쌍을 정렬해서 겹치는 행을 지우면 그래프 파일이 돼요. 제목 목록도 번호 순서대로 따로 저장해요.
주의할 점
확인한 사실
어떻게 대응할지
pagelinks는 pl_from 순서가 아니에요. 순서가 뒤집힌 곳이 3,130,616곳이고, 대체로 pl_target_id 순서예요(뒤집힌 곳 25곳).
문서별로 묶여 있다고 가정하면 안 돼요. 전체를 정렬한 뒤에 인접 리스트를 만들어요.
나가는 링크가 없는 문서가 2,967개, 들어오는 링크가 없는 문서가 60,601개예요.
시연에서 경로 없음이 나올 수 있으니 그 경우의 출력도 만들어 둬요.
인접 행렬은 칸마다 1비트만 써도 770,114² 칸, 약 74.1GB가 필요해요. 그중 채워지는 칸은 0.0091%뿐이에요.