Steamデータ取得 データ取得

Steam appreviewsの使い方|レビュー本文・評価サマリーをPythonで取得

2025年10月17日

この記事では、Steam Store APIの appreviews を使い、Steamレビューの評価サマリーやレビュー本文をPythonで取得する方法をまとめます。

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

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

以下はSteamへの通信が必要ですがAPIキーは不要です。CSVが既にある方は 取得なしの可視化、取得せず練習する方は 架空サンプルへ進んでください。

分析へ渡すなら条件記録付き実用版を選ぶ

実用版コード1個を collect_steam_reviews.py に保存します。AppIDと条件をそのファイルで確認し、下を実行します。基本例の定義コードを足す必要はありません。

python -m pip install requests
python collect_steam_reviews.py

動きを段階ごとに学ぶなら基本例を選ぶ

「評価サマリーを取得する」→「レビュー本文を取得する」→「カーソルでページングする」→「CSVに保存する」の4コードだけを順に steam_reviews.py に保存します。実用版とは代替で、両方を実行しません。基本例には説明用のサマリー取得・本文取得も含まれます。

python -m pip install requests
python steam_reviews.py

両経路とも保存CSVは作業フォルダー内の outputs/steam_reviews.csv。基本例は同名CSVを上書きするので過去分を退避してください。実用版は既存ファイルがあれば停止し、outputs/steam_reviews.metadata.json も作ります。可視化へはCSVとこのmetadataをセットで渡します。基本例にはmetadataがなく、後で過去の取得条件を推定して補いません。

最大5ページであり全件取得ではありません。実用版のmetadataにある termination_reasonsaved_rows、送信条件を先に確認します。page_limitは上限、empty_responseは空応答です。初回の first_query_summary は保存CSVの件数・好評率とは別です。0件なら列だけのCSVになります。通信失敗を「レビューなし」と扱いません。

掲載コードと詳しい条件

SteamworksのUser Reviewsドキュメントでは、ストアページに表示されるレビュー情報を取得するAPIとして説明されています。レビュー本文はユーザー投稿に近い情報なので、公開記事では全文転載ではなく、集計・要約・傾向分析として扱います。

User Reviews - Get List

Python 3.10以上で、ターミナルから python -m pip install requests を実行してください。以下4つのPythonコードを上から順に同じ steam_reviews.py へ保存し、作業フォルダーで python steam_reviews.py を実行します。2・3番目は最初の requestsurl、CSV保存は3番目の reviews に依存します。Notebookでは同じカーネルで上から順に実行します。

評価サマリーを取得する

import requests

appid = 2246340
url = f"https://store.steampowered.com/appreviews/{appid}"
params = {
    "json": 1,
    "language": "japanese",
    "purchase_type": "all",
    "filter": "recent",
    "review_type": "all",
    "cursor": "*",
    "num_per_page": 1,
}

response = requests.get(url, params=params, timeout=30)
response.raise_for_status()

data = response.json()
if data.get("success") != 1:
    raise RuntimeError("Steam APIが成功を返しませんでした。")
print(data["query_summary"])

公式仕様の filterrecentupdatedall です。summary は公式の選択肢ではありません。query_summary は初回レスポンスに含まれる情報で、この例も「サマリー専用API」ではなく、レビューを最大1件受け取ってサマリーを表示します。num_reviews は今回返った件数、total_reviews は指定条件に一致する総件数で、後で保存するCSVの行数とは区別してください。

レビュー本文を取得する

params = {
    "json": 1,
    "language": "japanese",
    "purchase_type": "all",
    "filter": "recent",
    "num_per_page": 20,
    "review_type": "all",
    "cursor": "*",
}

response = requests.get(url, params=params, timeout=30)
response.raise_for_status()

data = response.json()
if data.get("success") != 1:
    raise RuntimeError("Steam APIが成功を返しませんでした。")

for review in data["reviews"][:3]:
    print(review["voted_up"], review["review"][:100])

カーソルでページングする

cursor は初回に *、次回に返却値を渡します。requests.get(..., params=params) がURLエンコードを行うため、手動の quote() は不要です。この例は最大5ページ・1ページ最大100件であり、全件取得ではありません。空の一覧、次カーソルの欠落・同値でも終了します。HTTPエラーやAPI失敗は0件扱いにせず例外で停止します。

import time

reviews = []
cursor = "*"

for _ in range(5):
    params = {
        "json": 1,
        "language": "japanese",
        "purchase_type": "all",
        "filter": "recent",
        "num_per_page": 100,
        "review_type": "all",
        "cursor": cursor,
    }

    response = requests.get(url, params=params, timeout=30)
    response.raise_for_status()
    data = response.json()

    if data.get("success") != 1:
        raise RuntimeError("Steam APIが成功を返しませんでした。")
    batch = data["reviews"]
    reviews.extend(batch)

    if not batch:
        break

    next_cursor = data.get("cursor")
    if not next_cursor or next_cursor == cursor:
        break
    cursor = next_cursor
    time.sleep(1)

print(len(reviews))

CSVに保存する

import csv
from pathlib import Path

def normalize_review(review: dict) -> dict:
    author = review.get("author", {})
    return {
        "recommendationid": review.get("recommendationid"),
        "voted_up": review.get("voted_up"),
        "review": review.get("review"),
        "timestamp_created": review.get("timestamp_created"),
        "timestamp_updated": review.get("timestamp_updated"),
        "playtime_forever": author.get("playtime_forever"),
        "playtime_at_review": author.get("playtime_at_review"),
        "language": review.get("language"),
    }

fieldnames = [
    "recommendationid", "voted_up", "review",
    "timestamp_created", "timestamp_updated",
    "playtime_forever", "playtime_at_review", "language",
]
rows = [normalize_review(r) for r in reviews]

csv_path = Path("outputs/steam_reviews.csv")
csv_path.parent.mkdir(parents=True, exist_ok=True)

with csv_path.open("w", encoding="utf-8-sig", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=fieldnames)
    writer.writeheader()
    writer.writerows(rows)

print("保存先:", csv_path.resolve())
print("保存件数:", len(rows))
if not rows:
    print("0件のため、列名だけのCSVを保存しました。対象App ID・言語・取得条件を確認してください。")
with csv_path.open(encoding="utf-8-sig", newline="") as f:
    reader = csv.DictReader(f)
    assert reader.fieldnames == fieldnames
    saved_count = sum(1 for _ in reader)
assert saved_count == len(rows)
print("再読込で確認した件数:", saved_count)

CSVは実行時の作業フォルダーを基準に outputs/steam_reviews.csv へ保存されます。同名ファイルは上書きするため、過去分は先に退避してください。表示された絶対パスと再読込件数を確認します。0件でも列名は残り、再読込件数0で正常終了します。レビューが存在しないと断定せず、対象と条件を確認してください。

公開記事に使うときの注意

  • レビュー本文を長文転載しない
  • 特定ユーザーを追跡・特定しない
  • 取得条件、言語、取得日、件数を明記する
  • レビューは投稿者の主観であり、ゲーム全体の評価と同一視しない
  • 機械翻訳や要約を使う場合は、誤読の可能性を残す

取得条件も残す実用版:CSVとmetadataをセットで保存

ここからは上の最小手順に代わる実用版です。両方を続けて実行する必要はありません。作業フォルダーに以下を collect_steam_reviews.py として保存し、同じフォルダーで実行します。前のPython実行の変数には依存しません。

import csv
import hashlib
import io
import json
import time
from datetime import datetime, timezone
from pathlib import Path
import requests

FIELDS = ['recommendationid', 'voted_up', 'review', 'timestamp_created',
          'timestamp_updated', 'playtime_forever', 'playtime_at_review', 'language']

def collect(appid, max_pages=5, get=requests.get, pause=time.sleep):
    if max_pages < 1:
        raise ValueError('max_pagesは1以上にしてください')
    started = datetime.now(timezone.utc).isoformat()
    sent = dict(json=1, language='japanese', purchase_type='all', filter='recent',
                num_per_page=100, review_type='all')
    cursor, unique, raw_count, conflicts, duplicates = '*', {}, 0, 0, 0
    summary, reason = None, 'page_limit'
    for page in range(1, max_pages + 1):
        response = get(f'https://store.steampowered.com/appreviews/{appid}',
                       params={**sent, 'cursor': cursor}, timeout=30)
        response.raise_for_status()
        data = response.json()
        if data.get('success') != 1 or not isinstance(data.get('reviews'), list):
            raise RuntimeError('API失敗または応答形式不正。CSVは保存しません')
        if page == 1:
            summary = data.get('query_summary')
            if not isinstance(summary, dict):
                raise RuntimeError('初回query_summaryがありません')
        for source in data['reviews']:
            rid = str(source.get('recommendationid', '')).strip()
            if not rid.isdigit():
                raise ValueError('レビューID不正。保存せず停止します')
            row = {k: source.get(k) for k in FIELDS}
            row['recommendationid'] = rid
            for k in ['playtime_forever', 'playtime_at_review']:
                row[k] = source.get('author', {}).get(k)
            raw_count += 1
            if rid in unique:
                duplicates += 1
                conflicts += row != unique[rid]
            else:
                unique[rid] = row  # 同一IDは最初に受け取った内容を維持
        if not data['reviews']:
            reason = 'empty_response'; break
        following = data.get('cursor')
        if not following:
            reason = 'missing_cursor'; break
        if following == cursor:
            reason = 'same_cursor'; break
        cursor = following
        if page < max_pages:
            pause(1)
    metadata = dict(appid=str(appid), started_at=started,
                    finished_at=datetime.now(timezone.utc).isoformat(),
                    sent_parameters=sent, first_cursor='*', max_pages=max_pages,
                    fetched_pages=page, termination_reason=reason,
                    raw_rows=raw_count, saved_rows=len(unique), duplicate_rows=duplicates,
                    conflicting_duplicate_rows=conflicts, duplicate_policy='first_seen',
                    first_query_summary=summary,
                    omitted_parameters={'filter_offtopic_activity': 'default: off-topic reviews excluded',
                                        'day_range': 'not sent; not applicable to recent'},
                    scope='bounded API result, not all reviews or a random sample')
    return list(unique.values()), metadata

def save(rows, metadata, path=Path('outputs/steam_reviews.csv')):
    path = Path(path)
    sidecar = path.with_suffix('.metadata.json')
    # 過去の成果物を守るため、既存ファイルは上書きしない。
    if path.exists() or sidecar.exists():
        raise FileExistsError('以前のCSVとmetadataをセットで退避してください')
    buf = io.StringIO(newline='')
    writer = csv.DictWriter(buf, fieldnames=FIELDS)
    writer.writeheader(); writer.writerows(rows)
    raw = buf.getvalue().encode('utf-8-sig')
    metadata = {**metadata, 'csv_file': path.name, 'csv_sha256': hashlib.sha256(raw).hexdigest()}
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open('xb') as f:
        f.write(raw)
    sidecar.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding='utf-8')
    with path.open(encoding='utf-8-sig', newline='') as f:
        assert len(list(csv.DictReader(f))) == len(rows)
    print(path.resolve(), len(rows), '件', metadata['termination_reason'])

if __name__ == '__main__':
    rows, metadata = collect(2246340, max_pages=5)
    save(rows, metadata)

保存先は outputs/steam_reviews.csvoutputs/steam_reviews.metadata.json。既存ファイルがあれば停止するので、過去分は2ファイルをセットで退避してください。正常な0件は列名だけのCSVを保存します。HTTPエラー・API失敗・不正な応答は保存前に停止し、以前の正常なCSVを空データで上書きしません。保存途中のディスク障害等で片方しかない場合は、2ファイルの対応を確認してから再実行します。

同じ recommendationid は文字列IDで重複を除き、最初に受け取った内容を残します。後のページで内容が異なる場合も更新せず、metadataの conflicting_duplicate_rows に記録します。これは取得中の変化を記録する方針であり、常に最新の内容を選ぶものではありません。

送信する条件は sent_parameters、省略項目は omitted_parameters に分けます。今回も filter_offtopic_activity は送信しません。Steam公式仕様では、省略時はオフトピックなレビューが除外され、0を渡すと含まれます。既存の取得対象を変えないため、ここでは0を追加しません。day_range はrecentでは使いません。

終了理由は page_limit(上限)、empty_response(空応答)、same_cursor(同じカーソル)、missing_cursor(欠落)を区別します。最大5ページはSteam全レビューでも無作為標本でもありません。空応答も、この条件・取得時点における終了であり、将来を含む完全性の保証ではありません。初回の first_query_summary とCSVの保存件数は別に確認します。

保存したら、API通信なしで 投稿月別件数・好評率・投稿時プレイ時間を集計・可視化できます。レビュー本文の転載や個人の特定を行わず、取得範囲に限って読み取ります。

まとめ

appreviews を使うと、Steamレビューの評価サマリーやレビュー本文を取得できます。分析記事では、本文をそのまま載せるより、評価傾向、投稿時期、プレイ時間、話題の変化として集計する使い方が向いています。

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