Steamデータ取得 データ取得

SteamレビューCSVをPythonで可視化する|投稿月別件数・好評率・プレイ時間の見方

保存済みSteamレビューCSVを読み、投稿月別件数・好評率・投稿時点のプレイ時間を集計CSVとPlotly HTMLへ保存します。API通信・APIキーは不要です。実データがなくても、記事内の小さな架空CSVから試せます。

自分のデータに合う実行経路を選ぶ

以下はWindows PowerShellでの手順です。Python 3.10以上を用意し、必要なファイルを保存したフォルダーをエクスプローラーで開き、アドレス欄に powershell と入力してEnterを押します。Get-Location で作業フォルダーを確認してください。コード全文は後ろに掲載しています。選んだ経路だけを実行し、別の経路のコードを同じファイルへつなげないでください。

取得済みレビューCSVから集計とHTMLを作ります。API通信・キー不要です。最小例は月別件数を表示する学習用、実用版はファイルを保存する経路で、先に最小例を実行する必要はありません。

架空レビューで試す

make_steam_samples.pyvisualize_steam_reviews.pyを同じフォルダーへ保存します。

python -m pip install pandas plotly
python make_steam_samples.py
python visualize_steam_reviews.py --input outputs/fictional_steam_sample/steam_reviews.csv --output outputs/fictional_review_charts

自分のCSVを使う

visualize_steam_reviews.py を保存し、pandas・Plotlyを導入します。実用版で取得した outputs/steam_reviews.csv と同名.metadata.jsonを作業フォルダー内に置きます。

python visualize_steam_reviews.py

入出力は作業フォルダー基準です。実データの出力は outputs/steam_review_charts/、架空経路は outputs/fictional_review_charts/。同じ出力先への再実行は集計ファイルを更新します。履歴を残すなら--outputで別フォルダーを指定してください。保存集計CSVだけから図を作る別コマンドはなく、実用版は元レビューCSVから集計と描画を行います。

画面のinput_rows・unique_rows・欠損数を確認し、monthly_reviews.html(投稿月別件数)、positive_rate.html(好評率)、playtime.html(投稿時のプレイ時間)を開きます。好評率の分母はmonthly_reviews.csvのrated_countで、review_countやストア全体のquery_summaryと同一ではありません。プレイ時間は現在の総時間でなくplaytime_at_reviewを時間単位へ変換した分布です。詳細は 既存の出力確認と架空例の件数で照合します。

掲載コードと詳しい条件

準備:入力ファイルと作業フォルダー

レビュー取得記事の実用版で作った outputs/steam_reviews.csvoutputs/steam_reviews.metadata.json を使います。Python 3.10以上を用意し、スクリプトとoutputsフォルダーが並ぶ作業フォルダーで実行してください。インストールは次の1回です。

必要列は recommendationid(文字列ID)、voted_uptimestamp_createdplaytime_at_review です。旧CSVにmetadataがなければ取得条件・取得時点は不明として処理し、今の日時で補完しません。metadataがある場合はCSV名・件数・SHA-256を照合し、不一致なら停止します。

架空サンプルを作る:APIなしで試す

以下を make_steam_samples.py として保存し、python make_steam_samples.py を実行します。レビュー6件と、別教材用のCCU7件を作ります。すべて架空で、実際のゲーム・レビュー・人数ではありません。実データと別フォルダーに保存し、既存サンプルも上書きしません。

import csv
import hashlib
import json
from pathlib import Path

def main():
    root = Path('outputs/fictional_steam_sample')
    root.mkdir(parents=True, exist_ok=True)
    path = root/'steam_reviews.csv'
    fields = ['recommendationid','voted_up','review','timestamp_created','timestamp_updated','playtime_forever','playtime_at_review','language']
    values = [
        ['90001','True','架空の例「楽しい」\n実レビューではありません',1735689600,1738368000,600,60,'japanese'],
        ['90002','False','架空の例, "操作"',1738367999,1738368000,1200,600,'japanese'],
        ['90003','true','架空',1738368000,1738368000,3000,0,'japanese'],
        ['90004','unknown','架空',1738454400,1738454400,6000,'','japanese'],
        ['90005','false','架空','','',12000,3000,'japanese'],
        ['90006','true','架空',1740787200,1740787200,20000,12000,'japanese'],
    ]
    if path.exists() or path.with_suffix('.metadata.json').exists():
        raise FileExistsError('サンプルを上書きしません。以前のサンプルを別フォルダーへ退避してください')
    with path.open('x', encoding='utf-8-sig', newline='') as f:
        w = csv.writer(f); w.writerow(fields); w.writerows(values)
    metadata = dict(fictional=True, appid='FICTIONAL_NOT_A_REAL_GAME',
                    started_at=None, finished_at=None, sent_parameters=None,
                    max_pages=None, fetched_pages=0, termination_reason='fictional_sample',
                    scope='six invented records; no Steam request', raw_rows=len(values), saved_rows=len(values),
                    first_query_summary=None, csv_file=path.name, csv_sha256=hashlib.sha256(path.read_bytes()).hexdigest())
    path.with_suffix('.metadata.json').write_text(json.dumps(metadata,ensure_ascii=False,indent=2),encoding='utf-8')
    ccu = root/'steam_ccu.csv'
    with ccu.open('x', encoding='utf-8-sig', newline='') as f:
        w = csv.writer(f); w.writerow(['timestamp_utc','appid','player_count'])
        w.writerows([['2025-01-01T05:00:00Z','9000001',12],['2025-01-01T00:00:00Z','9000001',10],
                     ['2025-01-01T01:00:00Z','9000001',0],['2025-01-01T06:00:00Z','9000001',15],
                     ['2025-01-02T00:00:00Z','9000001',9],['2025-01-01T00:00:00Z','9000002',5],
                     ['2025-01-01T01:00:00Z','9000002',7]])
    print('すべて架空サンプルです:', root.resolve())

if __name__ == '__main__': main()

最小例:取得CSVの投稿月別件数を表示する

まず保存と再読込を確認する小さな例です。標準入力は実データのパスです。架空サンプルならpathを outputs/fictional_steam_sample/steam_reviews.csv へ変更します。これを実行した後でなければ実用版が動かない、という依存関係はありません。

import pandas as pd

path = "outputs/steam_reviews.csv"
df = pd.read_csv(path, dtype=str, keep_default_na=False)
df = df.drop_duplicates("recommendationid", keep="first")
posted = pd.to_datetime(pd.to_numeric(df["timestamp_created"], errors="coerce"), unit="s", utc=True, errors="coerce")
print(posted.dropna().dt.strftime("%Y-%m").value_counts().sort_index())

実用版:CSV・metadataから集計CSVとHTMLを保存する

以下全文を visualize_steam_reviews.py へ保存してください。取得スクリプトの変数やAPIキーには依存しません。

import argparse
import hashlib
import json
from pathlib import Path
import pandas as pd
import plotly.graph_objects as go

def analyze(path, output):
    path, output = Path(path), Path(output)
    df = pd.read_csv(path, dtype=str, keep_default_na=False)
    required = {'recommendationid', 'voted_up', 'timestamp_created', 'playtime_at_review'}
    if missing := required - set(df.columns):
        raise ValueError(f'必要列が不足しています: {sorted(missing)}。取得記事のCSVを確認してください')
    metadata_path = path.with_suffix('.metadata.json')
    metadata = None
    if metadata_path.exists():
        metadata = json.loads(metadata_path.read_text(encoding='utf-8'))
        if metadata.get('csv_sha256') != hashlib.sha256(path.read_bytes()).hexdigest():
            raise ValueError('CSVとmetadataのhash不一致。別の取得結果を混ぜていないか確認してください')
        if metadata.get('csv_file') != path.name or metadata.get('saved_rows') != len(df):
            raise ValueError('CSV名または件数がmetadataと一致しません')
    # 旧CSVにmetadataがなければ補完しない。
    original_rows = len(df)
    if (~df.recommendationid.str.fullmatch(r'\d+')).any():
        raise ValueError('recommendationidが空または不正です')
    duplicate_rows = int(df.duplicated('recommendationid').sum())
    conflicts = int(df.loc[df.duplicated('recommendationid', keep=False)].drop_duplicates().duplicated('recommendationid').sum())
    df = df.drop_duplicates('recommendationid', keep='first').copy()
    df['vote'] = df.voted_up.str.strip().str.lower().map({'true': 1, '1': 1, 'false': 0, '0': 0})
    seconds = pd.to_numeric(df.timestamp_created, errors='coerce')
    df['posted'] = pd.to_datetime(seconds, unit='s', errors='coerce', utc=True)
    # 単位違い・不正値は日時不明として扱う。現在日時で補わない。
    df.loc[seconds < 0, 'posted'] = pd.NaT
    minutes = pd.to_numeric(df.playtime_at_review, errors='coerce')
    df['hours'] = minutes.where((minutes >= 0) & (minutes < float('inf'))) / 60
    dated = df.loc[df.posted.notna()].copy()
    dated['month_utc'] = dated.posted.dt.strftime('%Y-%m')
    monthly = dated.groupby('month_utc').agg(review_count=('recommendationid','size'),
              rated_count=('vote','count'), positive_count=('vote','sum')).reset_index()
    monthly['unknown_vote_count'] = monthly.review_count - monthly.rated_count
    monthly['positive_rate_percent'] = 100 * monthly.positive_count / monthly.rated_count.where(monthly.rated_count > 0)
    output.mkdir(parents=True, exist_ok=True)
    monthly.to_csv(output/'monthly_reviews.csv', index=False, encoding='utf-8-sig')
    bins = [-0.001, 1, 10, 50, 100, float('inf')]
    labels = ['0〜1時間', '1超〜10時間', '10超〜50時間', '50超〜100時間', '100時間超']
    distribution = pd.cut(df.hours, bins=bins, labels=labels).value_counts(sort=False).rename_axis('hours_at_review').reset_index(name='review_count')
    distribution.to_csv(output/'playtime_distribution.csv', index=False, encoding='utf-8-sig')
    report = dict(input_rows=original_rows, unique_rows=len(df), duplicate_rows=duplicate_rows,
                  conflicting_duplicates=conflicts, missing_date=int(df.posted.isna().sum()),
                  unknown_votes=int(df.vote.isna().sum()), unknown_playtime=int(df.hours.isna().sum()),
                  metadata=metadata, collection_conditions='known' if metadata else 'UNKNOWN',
                  month_timezone='UTC', empty=len(df)==0)
    (output/'validation.json').write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
    charts = [
        ('monthly_reviews', go.Bar(x=monthly.month_utc.tolist(), y=monthly.review_count.tolist()), '投稿月別の取得件数', '件'),
        ('positive_rate', go.Bar(x=monthly.month_utc.tolist(), y=monthly.positive_rate_percent.where(monthly.positive_rate_percent.notna(), None).tolist(),
                                 customdata=monthly[['rated_count','unknown_vote_count']].values.tolist(),
                                 hovertemplate='%{x}<br>%{y:.1f}%<br>判定可能%{customdata[0]}件<br>判定不能%{customdata[1]}件<extra></extra>'), '保存評価の好評率', '%'),
        ('playtime', go.Bar(x=distribution.hours_at_review.astype(str).tolist(), y=distribution.review_count.tolist()), '投稿時のプレイ時間', '件')]
    for name, trace, title, unit in charts:
        fig = go.Figure(trace)
        fig.update_layout(title=dict(text=title, font=dict(size=16)), yaxis_title=unit,
                          margin=dict(l=45,r=15,t=65,b=110), height=460, autosize=True,
                          xaxis=dict(tickangle=-35))
        if name == 'positive_rate': fig.update_yaxes(range=[0,100])
        if not len(df): fig.add_annotation(text='0件:対象と取得条件を確認してください', showarrow=False, xref='paper', yref='paper', x=.5, y=.5)
        fig.write_html(output/f'{name}.html', include_plotlyjs=True, full_html=True,
                       config={'responsive':True,'displaylogo':False})
    print(json.dumps({k:v for k,v in report.items() if k!='metadata'}, ensure_ascii=False))
    return monthly, distribution, report

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('--input', default='outputs/steam_reviews.csv')
    parser.add_argument('--output', default='outputs/steam_review_charts')
    args = parser.parse_args()
    analyze(args.input, args.output)

出力先と確認方法

標準出力先は outputs/steam_review_charts/monthly_reviews.csvplaytime_distribution.csvvalidation.json と3つのHTMLを保存します。HTMLはダブルクリックでブラウザに開けます。Plotly本体を含むため各ファイルは数MBですが、外部CDNへの接続は不要です。再実行は同じ出力先の集計を更新します。入力CSVは変更しません。

月別CSVの review_count が投稿日を判定できた件数、rated_count が評価を判定できた分母、positive_count が好評件数、unknown_vote_count が評価不明です。好評率は好評件数÷判定可能件数です。分母0は空欄で、0%ではありません。

評価は前後の空白と大小文字を正規化して、True / true / 1False / false / 0 のみ判定します。その他と空欄は不明で、不評にはしません。Unix時刻は秒として読み、投稿日時 timestamp_created をUTCの月で分けます。更新日時 timestamp_updated では集計しません。日時不明は月別からだけ外し、プレイ時間には残します。

playtime_at_review は投稿時点のプレイ時間を分で記録した値で、60で割って時間にします。累計の playtime_forever に置き換えません。欠損・負値・非数値は不明、0分は有効値です。同じIDは最初の行を維持し、重複・競合数をvalidationへ残します。0件の場合も集計の列名と理由を保存するので、AppID・言語・取得条件を見直してください。

掲載コードで生成した架空グラフ見本

この見本は上の6件から生成した固定ファイルです。読者のローカルCSVがサイトへ送信・反映される仕組みではありません。2025年1月は2件・判定可能2件・好評率50%、2月は2件・判定可能1件・不明1件・好評率100%、3月は1件・100%です。日付不明1件は月別からだけ除外しています。プレイ時間は有効5件・不明1件です。

分かること・分からないこと

分かるのは取得範囲内の分布です。最大5ページのCSVは全レビューや無作為標本ではありません。取得範囲外の月を0件で埋めません。保存された現在の評価を投稿月で集計しており、「その月当時の評価」の復元ではありません。CSVの好評率、初回query_summaryの評価、Steamストア表示の評価は対象・時点を確認せず同じ指標として比較しないでください。少数件の100%は強い傾向の根拠にはなりません。

プレイ時間と評価の関連から、継続率や「長く遊べば好評になる」という因果関係は判断できません。本文の長文転載や個人の追跡はせず、取得条件・欠損・分母を添えて読むための教材です。

実データをCSVとmetadataへ保存する自分のCCU記録を可視化するSteam取得ガイド

-Steamデータ取得, データ取得