├── .gitignore
├── extract_my_tweets_slim.py
├── extract_my_tweets.py
├── README.md
└── split_tweets_by_size.py
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__/
2 | .vscode/
3 | *.pyc
4 | .env
5 | input/
6 | output/
7 |
--------------------------------------------------------------------------------
/extract_my_tweets_slim.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # extract_my_tweets_slim.py
3 | """
4 | tweets.js / tweets-part*.js → 自分のツイートを
5 | {created_at, full_text} だけ残した JSON Lines (gzip) に変換
6 | """
7 |
8 | import json, gzip, pathlib, re
9 |
10 | INPUT_DIR = pathlib.Path('./input')
11 | OUTPUT_DIR = pathlib.Path('./output')
12 | OUTPUT_DIR.mkdir(exist_ok=True)
13 |
14 | # ★ ここに @スクリーンネームを指定する
15 | MY_SCREEN_NAME = "myname" # 例: "a2see"
16 |
17 | JS_HEADER_RE = re.compile(r'^window\.YTD\.tweets\.part\d+\s*=\s*', re.S)
18 | KEEP_KEYS = ('created_at', 'full_text') # ← 欲しいフィールドはここで制御
19 |
20 | def load_part(path: pathlib.Path):
21 | raw = path.read_text(encoding='utf-8')
22 | data = json.loads(JS_HEADER_RE.sub('', raw).rstrip(';\n'))
23 | return [x['tweet'] for x in data]
24 |
25 | def is_my_tweet(tw: dict) -> bool:
26 | txt = tw.get('full_text', '')
27 | if txt.startswith('RT @') or tw.get('retweeted') or tw.get('is_quote_status'):
28 | return False
29 | if MY_SCREEN_NAME:
30 | # created_at が入っていればほぼ自ツイなので細かい判定は省略
31 | return True
32 | return True # ヒューリスティック
33 |
34 | def main():
35 | tweets = []
36 | for f in sorted(INPUT_DIR.glob('tweets*.js')):
37 | tweets.extend(load_part(f))
38 | print(f'ロード: {len(tweets):,} 件')
39 |
40 | out_path = OUTPUT_DIR / 'self_tweets.slim.jsonl.gz'
41 | n = 0
42 | with gzip.open(out_path, 'wt', encoding='utf-8') as gz:
43 | for tw in tweets:
44 | if is_my_tweet(tw):
45 | record = {k: tw.get(k, '') for k in KEEP_KEYS}
46 | gz.write(json.dumps(record, ensure_ascii=False) + '\n')
47 | n += 1
48 |
49 | print(f'✔ 抽出完了: {n:,} 件 → {out_path}')
50 |
51 | if __name__ == '__main__':
52 | main()
53 |
--------------------------------------------------------------------------------
/extract_my_tweets.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # extract_my_tweets.py
3 | """
4 | Twitter/X アーカイブ (tweets.js / tweets-partN.js) から
5 | 自分の投稿だけを JSON Lines (gzip) で抽出
6 | """
7 |
8 | import json
9 | import pathlib
10 | import re
11 | import gzip
12 |
13 | INPUT_DIR = pathlib.Path('./input')
14 | OUTPUT_DIR = pathlib.Path('./output')
15 | OUTPUT_DIR.mkdir(exist_ok=True)
16 |
17 | # ★ ここに @スクリーンネームを指定する
18 | MY_SCREEN_NAME = "myname" # 例: "a2see"
19 |
20 | #------------------------------------------------------------
21 | JS_HEADER_RE = re.compile(r'^window\.YTD\.tweets\.part\d+\s*=\s*', re.S)
22 |
23 | def load_tweets(path: pathlib.Path):
24 | raw = path.read_text(encoding='utf-8')
25 | data = json.loads(JS_HEADER_RE.sub('', raw).rstrip(';\n'))
26 | return [item['tweet'] for item in data]
27 |
28 | def is_my_tweet(tw: dict) -> bool:
29 | """
30 | ① スクリーンネーム指定あり → それで判定
31 | ② 指定なし → RT/引用でなく retweeted==False なら自分とみなす
32 | """
33 | text = tw.get('full_text', '')
34 | if text.startswith('RT @'):
35 | return False # 明示 RT
36 | if tw.get('is_quote_status'):
37 | return False # 引用ツイ
38 | if tw.get('retweeted'): # エクスポート上 True のこともある
39 | return False
40 |
41 | if MY_SCREEN_NAME:
42 | # 'user' オブジェクトは今のエクスポートに入っていないことが多いので
43 | # screen_name は full_text の先頭 "@name " から逆算するしかないが、
44 | # 自ツイなら先頭が @ で始まらないことがほとんど
45 | return True
46 | else:
47 | # ヒューリスティック(RT/引用でない → 自分の本文と仮定)
48 | return True
49 |
50 | def main():
51 | all_tw = []
52 | for js in sorted(INPUT_DIR.glob('tweets*.js')):
53 | all_tw.extend(load_tweets(js))
54 | print(f'ロード完了: {len(all_tw):,} 件')
55 |
56 | out_path = OUTPUT_DIR / 'self_tweets.jsonl.gz'
57 | kept = 0
58 | with gzip.open(out_path, 'wt', encoding='utf-8') as gz:
59 | for tw in all_tw:
60 | if is_my_tweet(tw):
61 | gz.write(json.dumps(tw, ensure_ascii=False) + '\n')
62 | kept += 1
63 |
64 | print(f'✔ 抽出完了: {kept:,} 件 → {out_path}')
65 |
66 | if __name__ == '__main__':
67 | main()
68 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | README.md
2 | =========
3 |
4 | > **extract_my_tweets** ― Twitter/X アーカイブを
5 | > 自分の投稿だけに絞り、軽量化・年別分割する 3 ステップツール
6 |
7 | ---
8 |
9 | ## 0. 準備
10 |
11 | | 必要なもの | 説明 |
12 | |------------|------|
13 | | **Python 3.9 以上** | Windows / macOS / Linux いずれも動作 |
14 | | **Twitter/X アーカイブ** | `tweets.js`, `tweets-part1.js`, … が入った一式 |
15 |
16 | 推奨ディレクトリ構成
17 | ```
18 | extract_my_tweets/ ← リポジトリ(この README と *.py 3 本)
19 | ├─ input/ ← Twitterアーカイブ JS をここへ
20 | ├─ output/ ← 自動生成(空で OK)
21 | ├─ extract_my_tweets.py
22 | ├─ extract_my_tweets_slim.py
23 | └─ split_tweets_by_size.py
24 | ```
25 |
26 | ターミナルでルートに移動
27 | ```bash
28 | cd path/to/extract_my_tweets
29 | ```
30 |
31 | ---
32 |
33 | ## 1. 自分のツイートだけ抽出
34 |
35 | ```
36 | python extract_my_tweets.py
37 | ```
38 |
39 | 生成物
40 | ```
41 | output/self_tweets.jsonl.gz # メタ情報込み・圧縮
42 | ```
43 |
44 | > **注意**
45 | > `extract_my_tweets.py` 内の `MY_SCREEN_NAME` に自分の @名を設定してください。
46 |
47 | ---
48 |
49 | ## 2. 本文+日付だけにスリム化
50 |
51 | ```
52 | python extract_my_tweets_slim.py
53 | ```
54 |
55 | 生成物
56 | ```
57 | output/self_tweets.slim.jsonl.gz
58 | ```
59 |
60 | > **注意**
61 | > `extract_my_tweets_slim.py` 内の `MY_SCREEN_NAME` に自分の @名を設定してください。
62 |
63 | ---
64 |
65 | ## 3. 5 MB 未満で分割
66 |
67 | | コマンド | 動作 | 主な生成物 |
68 | |----------|------|-----------|
69 | | `python split_tweets_by_size.py` | **デフォルト**: 年単位でいったん分割した後、年代をまたいで 5 MiB ごとに再集約 | `output/aggregate/self_tweets_<開始年>-<終了年>_pXX.jsonl`
`output/split/self_tweets_<年>_pXX.jsonl` |
70 | | `python split_tweets_by_size.py --yearly-only` | 年ごとに 5 MiB 未満で分割して終了 | `output/split/self_tweets_<年>_pXX.jsonl` |
71 |
72 | * ファイル名:
73 | * 年単位分割 → `self_tweets__pXX.jsonl`
74 | * 集約版 → `self_tweets_-_pXX.jsonl`
75 | * 1 ファイルのサイズは **5 MiB 未満**
76 | (変更したい場合は `MAX_BYTES` を編集)
77 |
78 | ---
79 |
80 | ## ワークフローまとめ
81 |
82 | ```
83 | input/*.js
84 | │ extract_my_tweets.py
85 | ▼
86 | self_tweets.jsonl.gz
87 | │ extract_my_tweets_slim.py
88 | ▼
89 | self_tweets.slim.jsonl.gz
90 | │ split_tweets_by_size.py (デフォルト=集約モード)
91 | ▼
92 | output/
93 | ├─ split/ # 年ごとの 5MiB ファイル
94 | └─ aggregate/ # 年代横断の 5MiB ファイル
95 | ```
96 |
97 | ---
98 |
99 | ## トラブルシューティング
100 |
101 | | 症状 | 対処 |
102 | |------|------|
103 | | 出力が空・少ない | `input/` フォルダに正しいファイル名 (`tweets.js` など) が置かれているか確認 |
104 | | サイズ上限を変えたい | `split_tweets_by_size.py` の `MAX_BYTES` を変更して再実行 |
105 |
106 | ---
107 |
108 | Happy archiving! 🎉
--------------------------------------------------------------------------------
/split_tweets_by_size.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | """
3 | split_tweets_by_size.py
4 | ────────────────────────────────────────────────────────────
5 | デフォルト: 年を跨いで 5 MiB 単位で連結 (output/aggregate)
6 | --yearly-only: 年ごとに 5 MiB 分割して終了 (output/split)
7 | """
8 |
9 | import argparse
10 | import gzip
11 | import json
12 | import os
13 | import pathlib
14 | import re
15 | import shutil
16 | from typing import TextIO
17 |
18 | INPUT_GZ = pathlib.Path("output/self_tweets.slim.jsonl.gz")
19 | TMP_DIR = pathlib.Path("tmp_years")
20 | SPLIT_DIR = pathlib.Path("output/split")
21 | AGG_DIR = pathlib.Path("output/aggregate")
22 | MAX_BYTES = 5 * 1024 * 1024 # 5 MiB
23 |
24 | YEAR_FILE_RE = re.compile(r"self_tweets_(\d{4})_p(\d{2})\.jsonl$")
25 |
26 |
27 | # ── 共通 ──────────────────────────────────────────
28 | def parse_year(created_at: str) -> int:
29 | return int(created_at.rsplit(" ", 1)[-1])
30 |
31 |
32 | def ensure_dirs(*dirs):
33 | for d in dirs:
34 | d.mkdir(parents=True, exist_ok=True)
35 |
36 |
37 | # ── STEP‑1: 年別バケツ ─────────────────────────────
38 | def bucket_by_year():
39 | writers: dict[int, TextIO] = {}
40 | with gzip.open(INPUT_GZ, "rt", encoding="utf-8") as src:
41 | for ln in src:
42 | y = parse_year(json.loads(ln)["created_at"])
43 | if y not in writers:
44 | writers[y] = open(TMP_DIR / f"{y}.jsonl", "w", encoding="utf-8", newline="\n")
45 | writers[y].write(ln)
46 | for fh in writers.values():
47 | fh.close()
48 | print(f"[1/3] {len(writers)} 年に振り分け完了 → {TMP_DIR}")
49 |
50 |
51 | # ── STEP‑2: 年ごと 5 MiB 分割 ───────────────────────
52 | def split_year_file(path: pathlib.Path, year: int):
53 | part_no, size = 1, 0
54 | tmp = None
55 | out_fh = None
56 |
57 | def open_tmp():
58 | nonlocal tmp, out_fh, size
59 | tmp = SPLIT_DIR / f"tmp_{year}_p{part_no:02d}.jsonl"
60 | out_fh = open(tmp, "w", encoding="utf-8", newline="\n")
61 | size = 0
62 |
63 | def close_and_rename():
64 | nonlocal tmp, out_fh
65 | if out_fh is None:
66 | return
67 | out_fh.close()
68 | final = SPLIT_DIR / f"self_tweets_{year}_p{part_no:02d}.jsonl"
69 | os.replace(tmp, final)
70 | tmp, out_fh = None, None
71 |
72 | open_tmp()
73 | with open(path, "r", encoding="utf-8") as src:
74 | for ln in src:
75 | b = len(ln.encode())
76 | if size + b > MAX_BYTES:
77 | close_and_rename()
78 | part_no += 1
79 | open_tmp()
80 | out_fh.write(ln)
81 | size += b
82 | close_and_rename()
83 | print(f" {year}: p{part_no:02d} まで生成")
84 |
85 | def split_all_years():
86 | for p in sorted(TMP_DIR.glob("*.jsonl")):
87 | split_year_file(p, int(p.stem))
88 | shutil.rmtree(TMP_DIR)
89 | print(f"[2/3] 年ごと 5 MiB 分割完了 → {SPLIT_DIR}")
90 |
91 |
92 | # ── STEP‑3: 集約 (デフォルト) ──────────────────────
93 | def aggregate_files():
94 | files = sorted(
95 | SPLIT_DIR.glob("self_tweets_*.jsonl"),
96 | key=lambda p: (int(YEAR_FILE_RE.match(p.name)[1]),
97 | int(YEAR_FILE_RE.match(p.name)[2])),
98 | )
99 | if not files:
100 | print("[3/3] 集約対象がありません。")
101 | return
102 |
103 | ensure_dirs(AGG_DIR)
104 | part_no, size = 1, 0
105 | start_year = end_year = None
106 | tmp = None
107 | out_fh = None
108 |
109 | def open_tmp():
110 | nonlocal tmp, out_fh, size
111 | tmp = AGG_DIR / f"tmp_p{part_no:02d}.jsonl"
112 | out_fh = open(tmp, "w", encoding="utf-8", newline="\n")
113 | size = 0
114 |
115 | def close_and_rename():
116 | nonlocal tmp, out_fh
117 | if out_fh is None:
118 | return
119 | out_fh.close()
120 | final = AGG_DIR / f"self_tweets_{start_year}-{end_year}_p{part_no:02d}.jsonl"
121 | os.replace(tmp, final)
122 | tmp, out_fh = None, None
123 |
124 | open_tmp()
125 | for fp in files:
126 | with open(fp, "r", encoding="utf-8") as src:
127 | for ln in src:
128 | y = parse_year(json.loads(ln)["created_at"])
129 | start_year = y if start_year is None else start_year
130 | end_year = y
131 | b = len(ln.encode())
132 | if size + b > MAX_BYTES:
133 | close_and_rename()
134 | part_no += 1
135 | open_tmp()
136 | start_year = end_year = y
137 | out_fh.write(ln)
138 | size += b
139 | close_and_rename()
140 | print(f"[3/3] 年代横断 5 MiB 分割完了 → {AGG_DIR}")
141 |
142 |
143 | # ── メイン ──────────────────────────────────────
144 | def main():
145 | parser = argparse.ArgumentParser(
146 | description="自ツイ JSONL を 5 MiB 単位で分割します "
147 | "(デフォルト: 年代横断で再集約)"
148 | )
149 | parser.add_argument(
150 | "--yearly-only",
151 | action="store_true",
152 | help="年ごとの 5 MiB 分割までで終了 (output/split のみ)"
153 | )
154 | args = parser.parse_args()
155 |
156 | if not INPUT_GZ.exists():
157 | raise SystemExit("self_tweets.slim.jsonl.gz が見つかりません。")
158 |
159 | ensure_dirs(TMP_DIR, SPLIT_DIR)
160 | bucket_by_year()
161 | split_all_years()
162 |
163 | if not args.yearly_only:
164 | aggregate_files()
165 |
166 |
167 | if __name__ == "__main__":
168 | main()
169 |
--------------------------------------------------------------------------------