Steamデータ取得 データ取得

Steam同時接続数を定期取得して可視化する方法|CCU推移をPythonで保存する

2025年10月17日

Steamの現在同時接続数(CCU)を1回取得して保存する方法と、保存CSV・架空CSVから観測の記録を可視化する方法をまとめます。

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

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

CCUは取得瞬間の同時接続人数です。下の3経路から必要なものだけ選びます。練習のためにタスクスケジューラや定期収集を始める必要はありません。

現在値をAPIで1回取得・保存する

「CCU取得の考え方」の関数コード→「CSVに追記する」のコードだけを順に collect_ccu.py へ保存します。末尾のAppID(配布例730)を確認します。実行1回につき1回取得し、APIキーは不要です。

python -m pip install requests
python collect_ccu.py

collect_ccu.pyは自分のファイルの場所を基準に outputs/steam_ccu.csv へ追記します。この取得コードは正常時に保存先を表示しません。スクリプト横のCSVを開き、末尾のtimestamp_utc・appid・player_countを確認します。ほかの分析例の相対パスは作業フォルダー基準なので、スクリプトのあるフォルダーから実行して揃えます。

既存CCU CSVを集計する(API通信なし)

visualize_steam_ccu.pyだけを保存します。入力は outputs/steam_ccu.csv。取得間隔に応じて引数を指定します。

python -m pip install pandas plotly
python visualize_steam_ccu.py --interval-minutes 60 --tolerance-minutes 15

架空CSVで記録漏れと実測0を試す(API通信なし)

make_steam_samples.pyと本記事のvisualize_steam_ccu.pyを同じフォルダーへ保存し、pandas・Plotlyを導入します。

python make_steam_samples.py
python visualize_steam_ccu.py --input outputs/fictional_steam_sample/steam_ccu.csv --output outputs/fictional_ccu_charts

出力は既存CSV経路が outputs/steam_ccu_charts/、架空経路が outputs/fictional_ccu_charts/ccu.html で線が途切れる時間帯、excluded_rows.csv のreasonで除外行を確認します。CSV自体にない未観測時刻の一覧を作るわけではありません。画面とvalidation.jsonの件数を照合します。同じ出力先への再実行は集計を更新します。

架空AppID 9000001は1時の実測0を残し、1時→5時は線を分けます。1月1日は4観測・観測平均9.25・観測最大15です。daily_ccu.csvのobserved_meanは時間平均ではなく、observed_maxも取り逃しのない日中ピークではありません。保存済み集計だけを読む別の描画経路はありません。

掲載コードと詳しい条件

CCU取得の考え方

Steam Web APIの ISteamUserStats/GetNumberOfCurrentPlayers を使うと、指定したAppIDの現在同時接続数を取得できます。この値は「取得した瞬間の値」です。過去推移を見たい場合は、自分で定期取得して保存する必要があります。

import requests

def get_current_players(appid: int) -> int:
    url = "https://api.steampowered.com/ISteamUserStats/GetNumberOfCurrentPlayers/v1/"
    params = {"appid": appid}
    response = requests.get(url, params=params, timeout=30)
    response.raise_for_status()
    data = response.json()["response"]
    return int(data["player_count"])

CSVに追記する

import csv
from datetime import datetime, timezone
from pathlib import Path

def append_ccu(appid: int, csv_path: Path) -> None:
    player_count = get_current_players(appid)
    now = datetime.now(timezone.utc).isoformat()

    csv_path.parent.mkdir(parents=True, exist_ok=True)
    fields = ["timestamp_utc", "appid", "player_count"]
    has_header = csv_path.exists() and csv_path.stat().st_size > 0
    if has_header:
        with csv_path.open(encoding="utf-8-sig", newline="") as existing:
            if next(csv.reader(existing), None) != fields:
                raise ValueError("CSVの列名が異なります。既存ファイルを確認してください")

    with csv_path.open("a", encoding="utf-8-sig", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=["timestamp_utc", "appid", "player_count"])
        if not has_header:
            writer.writeheader()
        writer.writerow({
            "timestamp_utc": now,
            "appid": appid,
            "player_count": player_count,
        })

if __name__ == "__main__":
    append_ccu(730, Path(__file__).resolve().parent / "outputs/steam_ccu.csv")

定期実行する

最初の関数定義とCSV追記コードを順に collect_ccu.py へ保存します。python -m pip install requests pandas plotly の後、保存フォルダーで python collect_ccu.py を実行します。CSVはスクリプトと同じフォルダー内の outputs/steam_ccu.csv です。タスクスケジューラにはPython実行ファイルの絶対パス、引数にこのスクリプトの絶対パス、開始フォルダーに保存フォルダーを指定してください。同じCSVへの同時実行は避けます。可視化コードは取得とは別に、同じ保存フォルダーから実行します。

Windowsならタスクスケジューラ、Linuxならcronで定期実行できます。まずは1時間に1回など、負荷の低い間隔で十分です。細かすぎる間隔で取得しても、分析に使い切れないことが多いです。

保存CSVから、記録漏れを区切って集計・可視化する

上の取得・追記処理とは別のPython実行です。collect_ccu.py と同じ作業フォルダーに次のコードを visualize_steam_ccu.py として保存してください。過去の記録を新たに取得する処理でも、定期実行タスクを作る処理でもありません。

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

def analyze(path, output, interval_minutes=60, tolerance_minutes=15):
    if interval_minutes <= 0 or tolerance_minutes < 0:
        raise ValueError('取得間隔は正、許容幅は0以上にしてください')
    df = pd.read_csv(path, dtype=str, keep_default_na=False)
    required = {'timestamp_utc','appid','player_count'}
    if missing := required - set(df.columns):
        raise ValueError(f'必要列不足: {sorted(missing)}。取得CSVの保存先を確認してください')
    df['source_row'] = range(2, len(df)+2)
    df['time'] = pd.to_datetime(df.timestamp_utc, utc=True, errors='coerce', format='mixed')
    df['count'] = pd.to_numeric(df.player_count, errors='coerce')
    reasons = []
    for r in df.itertuples():
        bad = []
        if not str(r.appid).isdigit(): bad.append('invalid_appid')
        # UTC列にタイムゾーンのない文字列は黙ってUTCと推定しない。
        if pd.isna(r.time) or not (str(r.timestamp_utc).endswith('Z') or str(r.timestamp_utc).endswith('+00:00')):
            bad.append('invalid_or_non_utc_time')
        if pd.isna(r.count) or r.count == float('inf'): bad.append('non_numeric_or_infinite')
        elif r.count < 0: bad.append('negative_count')
        elif r.count % 1: bad.append('non_integer_count')
        reasons.append('|'.join(bad))
    df['reason'] = reasons
    invalid = df.loc[df.reason != ''].copy()
    valid = df.loc[df.reason == ''].copy()
    key = ['appid','time']
    conflict = valid.groupby(key)['count'].transform('nunique') > 1
    conflicted = valid.loc[conflict].copy(); conflicted['reason'] = 'conflicting_same_timestamp'
    valid = valid.loc[~conflict].copy()
    duplicate = valid.duplicated(key, keep='first')
    duplicates = valid.loc[duplicate].copy(); duplicates['reason'] = 'identical_duplicate'
    valid = valid.loc[~duplicate].sort_values(key).copy()
    valid['day_utc'] = valid.time.dt.strftime('%Y-%m-%d')
    daily = valid.groupby(['appid','day_utc']).agg(observation_count=('count','size'),
              observed_mean=('count','mean'), observed_max=('count','max')).reset_index()
    # 日次完全性の判定ではなく、単純な少数観測表示。
    daily['few_observations'] = daily.observation_count < 3
    output = Path(output); output.mkdir(parents=True, exist_ok=True)
    daily.to_csv(output/'daily_ccu.csv', index=False, encoding='utf-8-sig')
    pd.concat([invalid,conflicted,duplicates]).to_csv(output/'excluded_rows.csv', index=False, encoding='utf-8-sig')
    fig = go.Figure(); segments = 0
    threshold = pd.Timedelta(minutes=interval_minutes+tolerance_minutes)
    for appid, group in valid.groupby('appid', sort=True):
        cut = group.time.diff().gt(threshold).cumsum()
        for number, part in group.groupby(cut):
            segments += 1
            fig.add_trace(go.Scatter(x=part.time.dt.strftime('%Y-%m-%d %H:%M:%S').tolist(),
                          y=part['count'].tolist(), mode='lines+markers', connectgaps=False,
                          name=f'AppID {appid}', legendgroup=str(appid), showlegend=number==0))
    fig.update_layout(title=dict(text='CCU観測値(UTC)', font=dict(size=16)),
                      xaxis_title='観測日時 UTC', yaxis_title='同時接続人数',
                      legend=dict(orientation='h', y=1.18), height=480, autosize=True,
                      margin=dict(l=50,r=15,t=95,b=100), xaxis=dict(tickangle=-30))
    if valid.empty:
        fig.add_annotation(text='有効な観測なし:除外理由・入力を確認', showarrow=False, xref='paper', yref='paper', x=.5,y=.5)
    fig.write_html(output/'ccu.html', include_plotlyjs=True, full_html=True,
                   config={'responsive':True,'displaylogo':False})
    report = dict(input_rows=len(df), valid_observations=len(valid), invalid_rows=len(invalid),
                  conflicting_rows=len(conflicted), identical_duplicates=len(duplicates),
                  segments=segments, interval_minutes=interval_minutes, tolerance_minutes=tolerance_minutes,
                  display_timezone='UTC', few_observations_rule='observation_count < 3')
    (output/'validation.json').write_text(json.dumps(report, indent=2), encoding='utf-8')
    print(json.dumps(report)); return daily, report

if __name__ == '__main__':
    p = argparse.ArgumentParser()
    p.add_argument('--input', default='outputs/steam_ccu.csv')
    p.add_argument('--output', default='outputs/steam_ccu_charts')
    p.add_argument('--interval-minutes', type=float, default=60)
    p.add_argument('--tolerance-minutes', type=float, default=15)
    a = p.parse_args(); analyze(a.input, a.output, a.interval_minutes, a.tolerance_minutes)

入力は outputs/steam_ccu.csv、必要列は timestamp_utcappidplayer_count。UTC保存・UTC表示で統一します。AppIDと時刻で整列し、同時刻・同人数の重複は1件にします。同時刻で人数が競合した行は、どちらが正しいか推定せず両方を除外します。不正日時、UTC表記なし、非数値、負数、小数人数は理由と元CSV行番号を excluded_rows.csv へ保存します。実測0は残します。

60分間隔・15分の許容幅なら、75分を超える時刻差で線を別の区間に分けます。取得頻度に合わせて引数を指定してください。欠損行がCSVになくても時刻差で区切り、未観測を0や直前値で補いません。競合・欠損の除外後にも時刻差を判定します。

出力は outputs/steam_ccu_charts/ccu.htmldaily_ccu.csvexcluded_rows.csvvalidation.json です。HTMLはPlotlyを含む単体ファイルで、ブラウザで開けます(数MBになります)。再実行すると同じ出力先の集計を更新します。入力CSVは変更しません。

observation_count は日別観測件数、observed_mean は観測値の単純平均、observed_max は観測最大値です。厳密な時間平均・公式の日平均・取り逃しのない真の最大値ではありません。few_observations は3件未満という目安で、完全に取得できた日の判定ではありません。記録のない日の人数は不明です。CCUからDAU(日間利用者数)・購入者数・売上は計算できません。

架空CSVで記録漏れと実測0を試す

実測データがない場合は、架空サンプル生成コードを保存して実行してください。レビューCSVとCCU CSVを outputs/fictional_steam_sample/ に作ります。以下は別の出力先を使うので実観測CSVには触れません。

以下は掲載コードで生成した固定の架空見本です。実際のゲームの人数ではありません。AppID 9000001の1時から5時は線が途切れ、1時の0人は点として残ります。1月1日は4観測・平均9.25・観測最大15、翌日は1観測です。読者のCSVがこのサイトの見本へ送信される仕組みではありません。

分析時の注意

  • 取得間隔が粗いとピークを逃す
  • タイムゾーンはUTCで保存し、表示時に変換する
  • メンテナンスや大型アップデートで急変することがある
  • 1時点のCCUだけで人気や売上を断定しない

まとめ

Steamの同時接続数は現在値として取得できます。推移を見たい場合は、取得時刻と一緒にCSVへ蓄積し、後から可視化します。レビュー分析やアップデート前後の反応を見る補助指標として使うと扱いやすいです。

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