保存済みSteamレビューCSVを読み、投稿月別件数・好評率・投稿時点のプレイ時間を集計CSVとPlotly HTMLへ保存します。API通信・APIキーは不要です。実データがなくても、記事内の小さな架空CSVから試せます。
自分のデータに合う実行経路を選ぶ
以下はWindows PowerShellでの手順です。Python 3.10以上を用意し、必要なファイルを保存したフォルダーをエクスプローラーで開き、アドレス欄に powershell と入力してEnterを押します。Get-Location で作業フォルダーを確認してください。コード全文は後ろに掲載しています。選んだ経路だけを実行し、別の経路のコードを同じファイルへつなげないでください。
取得済みレビューCSVから集計とHTMLを作ります。API通信・キー不要です。最小例は月別件数を表示する学習用、実用版はファイルを保存する経路で、先に最小例を実行する必要はありません。
架空レビューで試す
make_steam_samples.pyと visualize_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.csv と outputs/steam_reviews.metadata.json を使います。Python 3.10以上を用意し、スクリプトとoutputsフォルダーが並ぶ作業フォルダーで実行してください。インストールは次の1回です。
必要列は recommendationid(文字列ID)、voted_up、timestamp_created、playtime_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.csv、playtime_distribution.csv、validation.json と3つのHTMLを保存します。HTMLはダブルクリックでブラウザに開けます。Plotly本体を含むため各ファイルは数MBですが、外部CDNへの接続は不要です。再実行は同じ出力先の集計を更新します。入力CSVは変更しません。
月別CSVの review_count が投稿日を判定できた件数、rated_count が評価を判定できた分母、positive_count が好評件数、unknown_vote_count が評価不明です。好評率は好評件数÷判定可能件数です。分母0は空欄で、0%ではありません。
評価は前後の空白と大小文字を正規化して、True / true / 1 と False / 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%は強い傾向の根拠にはなりません。
プレイ時間と評価の関連から、継続率や「長く遊べば好評になる」という因果関係は判断できません。本文の長文転載や個人の追跡はせず、取得条件・欠損・分母を添えて読むための教材です。