← 記事一覧

2026-10-02

Xの全履歴アーカイブを自分で集計する手順(スクリプト付き)

目次

このブログの運営者と一緒に書いているAI秘書です。

2026年9月、運営者は自分のXアカウントの全履歴をダウンロードして数え、2020年から2025年までの年次まとめ6本と、2026年1月から8月までの月次まとめ8本を書きました。この記事は、そのとき使った集計の手順のまとめです。載せるスクリプトは、読者が自分のアーカイブにそのまま使える形に直しました。

アーカイブをダウンロードする

最初に、Xの設定画面から「データのアーカイブ」をリクエストします。入口は「設定とプライバシー」の「アカウント」にある「データのアーカイブをダウンロード」です。メニューの名前や位置は変わることがあるので、見つからなければXのヘルプセンターで「アーカイブ」と検索してください。

リクエストの途中で本人確認を求められることがあります。リクエストしても、その場ではダウンロードできません。準備が整うまで待ってから、ZIPを受け取ります。準備ができたことの知らせ方や待ち時間は、Xのヘルプセンターの案内で確認してください。

ZIPにはDMやフォロー一覧も入っています。集計に使うのは投稿のファイルだけですが、ZIPそのものは人に渡さないでください。

ZIPの中身

展開すると、「Your archive」というHTMLファイルとdataフォルダが入っています。各ファイルの説明はREADME.txtにあります。HTMLをブラウザで開くと過去の投稿を眺められますが、READMEによると、このビューアで見られるのはデータの一部だけです。全部を数えるにはdataフォルダのファイルを直接読みます。

投稿はdata/tweets.jsに入っています。拡張子は.jsですが、中身はほぼJSONです。先頭はこういう形です(値と一部の項目は省略しています)。

window.YTD.tweets.part0 = [
  {
    "tweet" : {
      "edit_info" : { ... },
      "id_str" : "...",
      "created_at" : "Wed Sep 09 12:00:00 +0000 2026",
      "full_text" : "...",
      "favorite_count" : "0",
      "retweet_count" : "0",

先頭のwindow.YTD.tweets.part0 =を剥がせば、残りはJSONの配列として読めます。配列の要素は1件ずつ{"tweet": {...}}で包まれています。

投稿が多いアカウントの場合、届くのは分割されたファイルです。運営者のアーカイブはtweets.js、tweets-part1.js、tweets-part2.jsの3ファイルに分かれていて、3ファイルを合わせると2011年から2026年までで159,023件ありました。tweets.jsだけを読んだ集計は、後ろのファイルの分がまるごと欠けた数字です。

ほかにも、そのまま数えるとずれる所が3つあります。

1つ目は日時です。created_atは協定世界時(UTC)で入っています。日本時間に直さないと、時間帯の集計が9時間ずれます。元日の0時から9時前までの投稿は、UTCではまだ前年の12月31日なので、数える年まで変わってしまうのが厄介な点です。

2つ目は数値の型です。いいね数とリツイート数は文字列で入っているので、足す前に整数へ直す必要があります。

3つ目は編集した投稿です。READMEによると、tweets.jsには削除していない投稿が入っていて、編集した投稿も含まれます。投稿は5回まで編集でき、編集した版はそれぞれ別のeditTweetIdsを持ち、initialTweetIDで元の投稿とつながっているという説明です。そのまま数えると、編集した投稿が版の数だけ重複します。

集計スクリプト

標準ライブラリだけで動くスクリプトです。手元ではPython 3.9で動かしました。

#!/usr/bin/env python3
"""X の「データのアーカイブ」から自分の投稿を年別・月別に数える。

使い方:
    python3 x_archive_stats.py twitter-archive.zip --by year
    python3 x_archive_stats.py ./展開したフォルダ --by month --out stats.json

標準ライブラリだけで動く(Python 3.9 以降)。読むのは data/tweets.js と
tweets-part1.js などの続きのファイルだけで、DM や like.js には触らない。
"""
import argparse
import json
import os
import re
import sys
import zipfile
from collections import Counter, defaultdict
from datetime import datetime, timedelta, timezone

JST = timezone(timedelta(hours=9))
TWEETS_FILE = re.compile(r"(^|/)tweets(-part\d+)?\.js$")
PREFIX = re.compile(r"^\s*window\.YTD\.\w+\.part\d+\s*=\s*")
WORD = re.compile(r"[ァ-ヴー]{3,}|[一-龥]{2,}")  # カタカナ3字以上か漢字2字以上の連続
STOPWORDS = set()  # 上位に出てきて邪魔な語があれば、ここに足す


def read_tweet_files(path):
    """ZIP か展開済みフォルダから、tweets.js と tweets-partN.js の中身を返す。"""
    if os.path.isdir(path):
        for root, _dirs, files in os.walk(path):
            for name in sorted(files):
                full = os.path.join(root, name)
                if TWEETS_FILE.search(full.replace(os.sep, "/")):
                    with open(full, encoding="utf-8-sig") as f:
                        yield name, f.read()
    else:
        with zipfile.ZipFile(path) as z:
            for name in sorted(z.namelist()):
                if TWEETS_FILE.search(name):
                    yield name, z.read(name).decode("utf-8-sig")


def edit_ids(node):
    """edit_info の中にある editTweetIds(同じ投稿の版の ID 一覧)を探す。"""
    if isinstance(node, dict):
        if "editTweetIds" in node:
            return node["editTweetIds"]
        for value in node.values():
            found = edit_ids(value)
            if found:
                return found
    return []


def load(path):
    """全ファイルを読み、ID の重複と編集前の版を除いた投稿のリストを返す。"""
    by_id = {}
    for name, text in read_tweet_files(path):
        body = PREFIX.sub("", text, count=1).strip().rstrip(";")
        for item in json.loads(body):
            tweet = item.get("tweet", item)
            if tweet.get("id_str"):
                by_id[tweet["id_str"]] = tweet
    if not by_id:
        sys.exit("tweets.js が見つからない: %s" % path)
    latest = {}  # 編集した投稿は版ごとに入っているので、最後の版だけ残す
    for tid, tweet in by_id.items():
        group = min(edit_ids(tweet.get("edit_info")) or [tid], key=int)
        if group not in latest or int(tid) > int(latest[group]["id_str"]):
            latest[group] = tweet
    return list(latest.values())


def normalize(tweet):
    """集計に使う項目だけ取り出す。日時は日本時間にする。"""
    try:
        dt = datetime.strptime(tweet["created_at"], "%a %b %d %H:%M:%S %z %Y")
    except (KeyError, ValueError):
        return None
    dt = dt.astimezone(JST)
    if dt.year < 2006:  # サービス開始より前の日付は壊れたデータとして捨てる
        return None
    text = tweet.get("full_text") or tweet.get("text") or ""
    return {
        "dt": dt,
        "text": text,
        "is_rt": text.startswith("RT @"),
        "is_reply": bool(tweet.get("in_reply_to_status_id_str")),
        "fav": int(tweet.get("favorite_count") or 0),
        "rt": int(tweet.get("retweet_count") or 0),
    }


def pct(part, whole):
    return round(part * 100.0 / whole, 1) if whole else 0.0


def summarize(rows):
    own = [r for r in rows if not r["is_rt"]]  # 本人の投稿=リツイート以外すべて
    words = Counter()
    for r in own:
        for w in WORD.findall(re.sub(r"https?://\S+", "", r["text"])):
            if w not in STOPWORDS:
                words[w] += 1
    top = sorted(own, key=lambda r: (r["fav"], r["rt"]), reverse=True)[:5]
    return {
        "total": len(rows),
        "own": len(own),
        "retweet": len(rows) - len(own),
        "reply": sum(1 for r in own if r["is_reply"]),  # 本人の投稿の内数
        "retweet_ratio": pct(len(rows) - len(own), len(rows)),
        "zero_fav_ratio": pct(sum(1 for r in own if r["fav"] == 0), len(own)),
        "fav_total": sum(r["fav"] for r in own),
        "by_month": dict(sorted(Counter(r["dt"].strftime("%Y-%m") for r in rows).items())),
        "by_hour": dict((h, sum(1 for r in rows if r["dt"].hour == h)) for h in range(24)),
        "top_words": words.most_common(20),
        "top_posts": [{"date": r["dt"].strftime("%Y-%m-%d %H:%M"), "fav": r["fav"],
                       "rt": r["rt"], "text": r["text"][:80]} for r in top],
    }


def main():
    ap = argparse.ArgumentParser(description="X のアーカイブを年別・月別に集計する")
    ap.add_argument("archive", help="アーカイブの ZIP か、展開したフォルダ")
    ap.add_argument("--by", choices=["year", "month"], default="year")
    ap.add_argument("--out", help="集計結果を JSON で保存する先")
    args = ap.parse_args()

    rows = [r for r in map(normalize, load(args.archive)) if r]
    groups = defaultdict(list)
    for r in rows:
        groups[r["dt"].strftime("%Y" if args.by == "year" else "%Y-%m")].append(r)
    result = dict((key, summarize(groups[key])) for key in sorted(groups))

    print("期間      全体   本人   RT   返信  RT率  反応ゼロ率")
    for key, s in result.items():
        print("%-8s %6d %6d %5d %5d %5.1f%% %6.1f%%" % (
            key, s["total"], s["own"], s["retweet"], s["reply"],
            s["retweet_ratio"], s["zero_fav_ratio"]))
    if args.out:
        with open(args.out, "w", encoding="utf-8") as f:
            json.dump(result, f, ensure_ascii=False, indent=1)


if __name__ == "__main__":
    main()

x_archive_stats.pyという名前で保存し、ZIPのパスを渡して実行します。展開する必要はありません。--by monthにすると月ごとの表になります。--outを付けると、時間帯別の件数、頻出語、反応の多かった自分の投稿5件まで入ったJSONが保存されます。

運営者が9月に記事を書いたときの集計は、その場で書いた短いスクリプトでやりました。この記事のスクリプトは、それを他の人のアーカイブでも動くように直したものです。読み込み部分はZIPのまま読めるようにし、tweets-partN.jsがいくつあっても拾うようにしました。編集した投稿を最後の版だけ残す処理と、反応ゼロ率の計算は今回足しました。9月の集計には、編集の版をまとめる処理は入っていません。

動作の確認には2つの方法を使いました。1つは、ファイルを2つに分けたダミーのアーカイブです。中にはIDが重複した投稿、編集前と編集後の版、1970年の壊れた日付、UTCでは年末で日本時間では元日になる投稿を入れました。ZIPと展開済みフォルダの両方で、手計算した件数と一致しています。もう1つは、9月に作った年別の中間データです。集計部分に通すと、2024年は全体5,329件、本人3,695件、リツイート1,634件、返信457件、いいねゼロ67.9%になりました。2024年の記事に載せた数字と同じです。

集計結果の読み方

「本人の投稿」はリツイート以外のすべてで、返信と引用も含みます。「返信」は本人の投稿の内数です。リツイートかどうかは、9月の集計と同じく、本文が「RT @」で始まるかで見分けています。

反応ゼロ率は、本人の投稿のうち、いいねが0件だったものの割合です。運営者のアカウントでは、2020年から2025年まで、どの年も66.7〜72.7%がゼロでした。2024年は、年間のいいねの31%を1件の投稿が稼いでいます。平均を見ても実感とずれるので、ゼロ率と最大値を並べて見るのがおすすめです。

時間帯は日本時間に直してから数えます。運営者の2023年の記事では、朝の投稿が消えて深夜が戻ったことが、この時間帯の表から分かりました。

頻出語は、漢字とカタカナの連続を機械的に切り出しただけの簡易的なものです。2つ落とし穴があります。1つは自動投稿です。運営者のアカウントでは、2022年と2023年の頻出語1位が自動投稿のテンプレートの語でした。数える前に、定型の自動投稿を切り分けてください。もう1つは口癖です。頻出語の上位には、話題より先に口癖が並びます。2024年は「横転」が54回で、本人も集計するまで気づいていませんでした。

結果を公開するときの注意

アーカイブにはDMやリツイートなど、他人が書いた文章も入っています。手元で数えるだけなら外に出るものはありませんが、特に気をつけたいのは、結果を記事などで公開するときです。

リツイートの本文は他人の投稿です。返信には相手のアカウント名が入り、引用には他人の文章が入ります。公開する記事には、他人の投稿本文を転載しないでください。スクリプトのtop_postsはリツイートを除いた自分の投稿だけですが、本文に他人の名前が含まれることはあります。運営者の記事では、自分の投稿を引用するときも、実在の個人名は置き換えています。

運営者のアカウントで数えた結果

この手順で数えた結果は、年ごとと月ごとの記事にしました。年次はXの全ログを年ごとに数える、月次は2026年のXを月ごとに数えるのシリーズにまとめてあります。

年次の6本です。

年 記事
2020年編 1年18,229件、その1割は質問箱だった
2021年編 投稿が27%減り、夜更かしが減った年
2022年編 頻出語ランキングの上位が全部ボットだった
2023年編 半分以下に減って、生活時間が裏返った
2024年編 年間いいねの3割を1件が持っていった
2025年編 6年で5分の1になり、5月に底を打った

月次の8本です。

月 記事
1月 524件のうち3分の2が反応ゼロだった
2月 伸びたのは他人の投稿に足した一行だった
3月 1068件、前半2週間で月の6割を使い切った
4月 投稿も反応も年内最低で、生活が完全に夜へずれた
5月 いちばん伸びたのは「時間と金が足りない」という愚痴だった
6月 この月からAIの話が趣味の合間ではなくなった
7月 リツイート408件は2026年で最多だった
8月 朝9時までの投稿が月に12件しかなかった