├── .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 | --------------------------------------------------------------------------------