Google Playデータ取得 データ取得

Google Playレビュー可視化入門|評価推移をPlotlyで確認するPython手順

2025年10月11日

保存済みのGoogle PlayレビューCSVを、日付別の取得件数・星評価・バージョン別の表とPlotly HTMLへ変換します。APIなしで再読込できるよう、集計と描画を別の実行に分けます。

最短手順:ファイルを用意し、実行して出力を開く

レビューCSVから件数・平均評価・星構成を集計し、ブラウザで開けるHTMLを作ります。架空6行ならGoogle Playへの通信なしで一通り試せます。

掲載Python10ファイルのZIPをダウンロードし、「すべて展開」で任意の作業フォルダーへ展開します。ZIP内にサブフォルダーはありません。必要ファイルは gp_common.py、make_gp_sample.py、aggregate_gp_reviews.py、plot_gp_saved.py です。使わないファイルは実行しません。以下の長いコードを手で保存する場合も、同じ名前・同じフォルダーへ置きます。

Python導入済みのWindows PowerShellで実行します。最初の入力欄へ、展開後の gp_common.py が見えるフォルダーの絶対パスを貼り付けてEnterを押してください。引用符は入力不要です。以降もその作業フォルダーから実行します。インストールには通信が必要ですが、その後の保存CSV分析とは別です。

$work = Read-Host "ZIPを展開したフォルダーの絶対パス"
Set-Location -LiteralPath $work
Get-ChildItem *.py
python -m pip install pandas plotly

入力は次のどちらか一方です。自分のCSVなら 取得・保存記事で確認したCSV のパスを指定します(必要列はcontent・score・at)。練習なら次だけ実行し、最後に表示された outputs/gp_runs/fictional-sample-… をコピーします。

python make_gp_sample.py

次の入力欄にはCSVファイルのパスを貼り付けます。架空データの場合は、今コピーしたフォルダー名の末尾へ /google_play_reviews.csv を付けてください。 は実際の表示ではなく省略記号で、そのまま入力しません。自分のCSVなら例として固定した実行名を使わず、実在するファイルを指定します。

$csv = Read-Host "分析するCSVファイルのパス"
Test-Path -LiteralPath $csv

True を確認してから進みます。False ならフォルダーではなくCSVまで指定したか、作業フォルダーが正しいかを確認してください。別のPowerShellを開いた場合は作業フォルダーと $csv を設定し直します。

python aggregate_gp_reviews.py "$csv"

処理後、最後の行に今回の実行フォルダー(review-aggregates/text/sentimentから始まる名前)が表示されます。その行だけをコピーして、次の入力欄へ貼り付けます。これはCSVファイルではなくmetadata.jsonを含むフォルダーです。

$run = Read-Host "直前に表示された実行フォルダーのパス"
python plot_gp_saved.py "$run"

さらに新しいsaved-plotsフォルダーが表示されます。元の集計CSVは先ほどのフォルダー、HTMLはこの新しいフォルダーです。HTMLをダブルクリックするとブラウザで開きます。0件・語彙なし等で図が省略された場合はmetadata.jsonのskippedを確認し、全図が揃うと決めつけないでください。

まず開くファイル:知りたいことから選ぶ

月別の件数と平均評価
集計フォルダーのmonth.csv:review_count・valid_score_count・mean_score。図はHTMLフォルダーのmonth.html(件数)。

星の構成比・日次の変動
monthly_stars.csv/daily_stars.csv:count・denominator・ratio。対応するHTMLを開きます。

欠損などの元行を確認
reviews_audit.csv:row_id・content_raw・score_raw・at_raw。詳細な母集団はmetadata.json。

CSVは表計算ソフトでも開けますが、保存し直すと型やファイルの確認値(hash)が変わる場合があります。元ファイルは上書きせず閲覧してください。図がない場合は、同じ実行フォルダーのmetadata.jsonにある skipped や手法別状態を確認します。

コードの前に:結果から何が分かるか

件数は「投稿日が使えるレビュー」、平均評価はその中で星1〜5があるレビュー、星構成比は同じ日・月・日時基準の有効評価件数が分母です。前回検証した架空6行は日付有効4行・星有効4行・両方有効2行なので、すべてを6で割るわけではありません。日次は短い変化、月次は大まかな流れを見る用途ですが、少数の日の平均は揺れやすくなります。これは取得CSV内の集計で、ストア全体の評価ではありません。月別グラフは件数、day.htmlは日別平均です。月別平均はmonth.csvで確認できます。

掲載コードと詳しい条件(ZIPと同じ内容)

入力の基本は outputs/google_play_reviews.csv取得・保存手順)。content・score・atが必要です。reviewId・バージョンは文字列、row_idは入力CSVの0始まり行番号として保持します。元本文・元日時・元評価と、分析用の値を分けます。日時にオフセットがある行はUTCで集計し、旧CSVの時刻帯不明な行は壁時計の年月を別集団として扱います。lang・countryから時刻帯を推定しません。

入力と実行記録の共通ファイル

この小さなファイルはCSV読込・実行別保存・hash確認のみを担当し、API取得や学習を実行しません。metadataがない旧CSVも読めますが、過去の取得条件・時刻帯・当時のファイル同一性は証明できません。新しく計算したhashは今回読んだファイルのhashです。

CSVの読込と保存を共通化する:gp_common.py

掲載コードを保存:gp_common.py

"""記事掲載用の小さなCSV入出力ヘルパー。ネットワーク通信はしません。"""
from pathlib import Path
from datetime import datetime, timezone
import hashlib
import json
import uuid
import importlib.metadata
import pandas as pd

def digest(path):
    return hashlib.sha256(Path(path).read_bytes()).hexdigest()

def versions(names):
    result = {}
    for name in names:
        try:
            result[name] = importlib.metadata.version(name)
        except importlib.metadata.PackageNotFoundError:
            result[name] = "not installed"
    return result

def load_reviews(path):
    df = pd.read_csv(path, dtype=str, keep_default_na=False, encoding="utf-8-sig")
    required = {"content", "score", "at"}
    if not required <= set(df):
        raise ValueError(f"必要列がありません: {sorted(required - set(df))}。レビュー取得記事のCSVを確認してください。")
    if "row_id" in df:
        df = df.rename(columns={"row_id": "input_row_id"})
    df.insert(0, "row_id", range(len(df)))
    for col in ["reviewId", "reviewCreatedVersion", "appVersion"]:
        if col not in df:
            df[col] = ""
    df["content_raw"] = df["content"]
    df["at_raw"] = df["at"]
    df["score_raw"] = df["score"]
    df["score_valid"] = pd.to_numeric(df["score"], errors="coerce")
    df.loc[~df["score_valid"].isin([1, 2, 3, 4, 5]), "score_valid"] = float("nan")
    # オフセット付き日時だけUTCへ変換。旧CSVのnaive日時は壁時計のまま別集団にする。
    def date_parts(value):
        try:
            t = pd.Timestamp(value)
            if pd.isna(t):
                raise ValueError("missing")
            basis = "UTC" if t.tzinfo is not None else "timezone_unknown_wall_clock"
            if t.tzinfo is not None:
                t = t.tz_convert("UTC")
            return (t.strftime("%Y-%m-%d"), t.strftime("%Y-%m"), basis)
        except (ValueError, TypeError, OverflowError):
            return ("", "", "invalid_or_missing")
    parts = [date_parts(v) for v in df["at"]]
    for i, col in enumerate(["day", "month", "date_basis"]):
        df[col] = [p[i] for p in parts]
    # 1.2.7では両項目の抽出元は同じ。しかし旧CSVの由来を保証しないため自動補完しない。
    df["version"] = df["reviewCreatedVersion"].str.strip().replace("", "unknown")
    df["version_source"] = df["version"].map(lambda v: "unknown" if v == "unknown" else "reviewCreatedVersion")
    return df

class Run:
    def __init__(self, kind, input_path=None, settings=None, root="outputs/gp_runs"):
        stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
        self.path = Path(root) / f"{kind}-{stamp}-{uuid.uuid4().hex[:8]}"
        self.path.mkdir(parents=True, exist_ok=False)
        self.meta = {"run_id": self.path.name, "kind": kind,
                     "started_at_utc": datetime.now(timezone.utc).isoformat(),
                     "input_file": str(input_path) if input_path else None,
                     "input_sha256": digest(input_path) if input_path else None,
                     "settings": settings or {}, "files": {}, "skipped": {},
                     "versions": versions(["pandas", "plotly", "Janome", "scikit-learn", "bertopic", "google-play-scraper"])}
        if input_path:
            source = Path(input_path)
            self.meta["source_metadata"] = {"status": "not_available"}
            for receipt in [source.with_suffix(".metadata.json"), source.parent / "metadata.json"]:
                if receipt.is_file():
                    try:
                        provenance = json.loads(receipt.read_text(encoding="utf-8"))
                        matches = provenance.get("files", {}).get(source.name) == digest(source)
                        self.meta["source_metadata"] = {"path": str(receipt), "sha256": digest(receipt),
                            "status": "matching_csv_hash" if matches else "not_matching_or_no_csv_hash",
                            "settings": provenance.get("settings") if matches else None,
                            "started_at_utc": provenance.get("started_at_utc") if matches else None,
                            "finished_at_utc": provenance.get("finished_at_utc") if matches else None}
                        if matches:
                            break
                    except (ValueError, OSError):
                        self.meta["source_metadata"] = {"status": "unreadable"}

    def csv(self, name, frame):
        frame.to_csv(self.path / name, index=False, encoding="utf-8-sig")
        self.record(name)

    def record(self, name):
        self.meta["files"][name] = digest(self.path / name)

    def figure(self, name, fig):
        fig.update_layout(autosize=True, margin=dict(l=45, r=20, t=85, b=130))
        fig.write_html(self.path / name, include_plotlyjs=True, full_html=True,
                       config={"responsive": True})
        self.record(name)

    def finish(self, **extra):
        self.meta.update(extra)
        self.meta["finished_at_utc"] = datetime.now(timezone.utc).isoformat()
        (self.path / "metadata.json").write_text(json.dumps(self.meta, ensure_ascii=False, indent=2), encoding="utf-8")
        print(self.path)
        return self.path

def checked_run(folder):
    folder = Path(folder)
    meta = json.loads((folder / "metadata.json").read_text(encoding="utf-8"))
    if meta["run_id"] != folder.name:
        raise ValueError("実行フォルダー名とmetadataが一致しません。")
    for name, expected in meta["files"].items():
        if Path(name).name != name or digest(folder / name) != expected:
            raise ValueError(f"出力の取り違え・変更: {name}")
    return folder, meta

APIなしで試す架空6行

通信なしの架空CSVを作る:make_gp_sample.py

掲載コードを保存:make_gp_sample.py

"""架空の学習用レビュー。通常の入力CSVを上書きしません。"""
import pandas as pd
from gp_common import Run

def sample(root="outputs/gp_runs"):
    rows = [
        ["001", '楽しい!音楽も「良い」\n物語が好き', "5", "2026-01-31T23:30:00+00:00", "1.10", "1.10"],
        ["002", "楽しいが重い", "2", "2026-02-01T00:30:00+09:00", "1.1", "1.1"],
        ["003", "楽しくない", "1", "", "1.10", "1.10"],
        ["004", "未知語だけ", "9", "2026-02-02", "", "2.0"],
        ["", "良い 悪い", "3", "invalid", "", ""],
        ["", "", "", "2026-02-03T12:00:00+00:00", "1.1", "1.1"],
    ]
    run = Run("fictional-sample", settings={"fictional": True}, root=root)
    run.csv("google_play_reviews.csv", pd.DataFrame(rows, columns=["reviewId", "content", "score", "at", "reviewCreatedVersion", "appVersion"]))
    return run.finish(fictional=True, note="架空の6行。実アプリの評価ではありません。")

if __name__ == "__main__":
    sample()

架空の6行で、実アプリの評価ではありません。通常の入力CSVは上書きしません。表示された実行フォルダー内のgoogle_play_reviews.csvを次の入力に指定します。先頭2行はUTCでは同じ1月、4行目は時刻帯不明の2月です。日付有効4行、星評価有効4行、両方有効2行、バージョン件数は全6行になります。

件数と分母を分けて集計CSVを保存

投稿日件数は日付だけ、平均評価・星構成は有効評価も必要です。バージョン件数には日付や評価の欠損行を残し、星構成の分母は有効評価に限ります。分母0の平均を0点へ補完しません。reviewCreatedVersionを優先する理由は「レビューに付随した版」を数えるためです。1.2.7のappVersionは同じ抽出元ですが、由来不明の旧CSVまで保証できないため自動補完せずunknownとします。1.10と1.1は別文字列です。

件数と評価を表に集計する:aggregate_gp_reviews.py

掲載コードを保存:aggregate_gp_reviews.py

import argparse
import pandas as pd
from gp_common import Run, load_reviews

def aggregate(path="outputs/google_play_reviews.csv", root="outputs/gp_runs"):
    df = load_reviews(path)
    run = Run("review-aggregates", path, {"timezone": "aware:UTC; naive:unknown wall-clock, separated", "version": "reviewCreatedVersion; no fallback"}, root)
    run.csv("reviews_audit.csv", df)
    valid_date = df["day"].ne("")
    valid_score = df["score_valid"].notna()
    for period in ["day", "month"]:
        table = df[valid_date].groupby(["date_basis", period], dropna=False).agg(
            review_count=("row_id", "size"), valid_score_count=("score_valid", "count"),
            mean_score=("score_valid", "mean")).reset_index()
        run.csv(period + ".csv", table)
    version = df.groupby(["version", "version_source"], dropna=False).agg(
        review_count=("row_id", "size"), valid_score_count=("score_valid", "count"),
        mean_score=("score_valid", "mean")).reset_index()
    run.csv("versions.csv", version)
    for name, groups, mask in [("stars", [], valid_score),
                               ("daily_stars", ["date_basis", "day"], valid_score & valid_date),
                               ("monthly_stars", ["date_basis", "month"], valid_score & valid_date),
                               ("version_stars", ["version"], valid_score)]:
        cols = groups + ["score_valid"]
        counts = df[mask].groupby(cols).size().reset_index(name="count")
        if groups:
            counts["denominator"] = counts.groupby(groups)["count"].transform("sum")
        else:
            counts["denominator"] = counts["count"].sum()
        counts["ratio"] = counts["count"] / counts["denominator"]
        run.csv(name + ".csv", counts)
    summary = {"input_rows": len(df), "date_valid": int(valid_date.sum()),
               "score_valid": int(valid_score.sum()), "date_and_score_valid": int((valid_date & valid_score).sum()),
               "date_excluded": int((~valid_date).sum()), "score_excluded": int((~valid_score).sum()),
               "version_count_population": len(df), "unknown_version": int(df["version"].eq("unknown").sum())}
    print(summary)
    return run.finish(populations=summary)

if __name__ == "__main__":
    p = argparse.ArgumentParser(); p.add_argument("input", nargs="?", default="outputs/google_play_reviews.csv")
    aggregate(p.parse_args().input)

day.csv・month.csv・stars.csv・daily_stars.csv・monthly_stars.csv・versions.csv・version_stars.csvとreviews_audit.csvを保存します。metadataのpopulationsに母集団と除外件数を残します。日付有効と評価有効の除外は重なるため、除外件数同士を足して総数にはしません。未取得期間の行を0件で埋めず、ストア全体の投稿数とも扱いません。

別のPython実行からHTMLへ

保存表からHTMLを作る:plot_gp_saved.py

掲載コードを保存:plot_gp_saved.py

"""保存済み集計だけを読む。API・学習モデルは不要。"""
import argparse
import pandas as pd
import plotly.express as px
from gp_common import Run, checked_run

def plot(folder, root="outputs/gp_runs"):
    folder, meta = checked_run(folder)
    run = Run("saved-plots", folder / "metadata.json", {"source_run": meta["run_id"], "offline_html": True}, root)
    specs = [("month.csv", "month", "review_count", "date_basis", "取得CSV内の月別レビュー件数"),
             ("day.csv", "day", "mean_score", "date_basis", "有効評価の平均(0点補完なし)"),
             ("stars.csv", "score_valid", "count", None, "有効な星評価の件数"),
             ("versions.csv", "version", "review_count", None, "取得CSV内のバージョン別件数"),
             ("daily_stars.csv", "day", "ratio", "score_valid", "日別の有効評価構成比(分母を確認)"),
             ("monthly_stars.csv", "month", "ratio", "score_valid", "月別の有効評価構成比(分母を確認)"),
             ("version_stars.csv", "version", "ratio", "score_valid", "バージョン別の有効評価構成比"),
             ("sentiment_monthly.csv", "month", "count", "label", "辞書判定の月別件数"),
             ("term_frequency.csv", "term", "occurrences", None, "単語の延べ出現回数"),
             ("lda_monthly.csv", "month", "count", "topic_id", "LDA月別割当件数"),
             ("bertopic_monthly.csv", "month", "count", "topic_id", "BERTopic月別割当件数(外れ値除外)")]
    for name, x, y, color, title in specs:
        if name not in meta["files"]:
            continue
        d = pd.read_csv(folder / name, dtype={x: str}, keep_default_na=False)
        if d.empty:
            run.meta["skipped"][name] = "集計対象0件"
            continue
        d[y] = pd.to_numeric(d[y], errors="coerce")
        if color:
            d[color] = d[color].astype(str)
        if "date_basis" in d:
            d["date_basis"] = d["date_basis"].replace({"timezone_unknown_wall_clock": "時刻帯不明"})
        fig = px.bar(d, x=x, y=y, color=color, title=title,
                     facet_row="date_basis" if "date_basis" in d and color != "date_basis" else None,
                     hover_data=[c for c in d.columns if c not in [x, y, color]])
        fig.update_layout(barmode="stack" if y == "ratio" else "group",
                          legend=dict(orientation="h", y=-0.3, x=0), legend_title_text="",
                          title_font_size=13)
        fig.update_xaxes(type="category", title_text={"month": "投稿日(月)", "day": "投稿日", "version": "バージョン", "term": "語", "score_valid": "星評価"}.get(x,x))
        fig.update_yaxes(title_text={"count": "件数", "review_count": "件数", "mean_score": "平均評価", "ratio": "構成比", "occurrences": "出現回数"}.get(y,y))
        if y == "ratio":
            fig.update_yaxes(tickformat=".0%")
        run.figure(name.replace(".csv", ".html"), fig)
    return run.finish()

if __name__ == "__main__":
    p = argparse.ArgumentParser(); p.add_argument("run_folder"); plot(p.parse_args().run_folder)

今回生成した集計CSVのhashを照合してから、別フォルダーへHTMLを作ります。ブラウザで開いて件数とCSVを比べてください。Plotly本体を含むHTMLはファイルが大きくなりますが、ローカル出力は外部CDNなしで表示できます。0件の図は理由を残して省略します。

改訂コードの架空サンプル見本

下の見本は上記6行から実際に生成したものです。UTCと時刻帯不明の集計は別系列です。

従来の分析グラフ(保存済みの例)

以下は以前の取得条件・コードで作成した保存済みの実分析例です。今回の改訂コードで再取得・再集計した結果ではありません。読者のローカルCSVがこの見本へ送信・反映される仕組みではありません。旧見本には外部Plotly CDNへ接続するものがあり、オフライン用の新しい出力HTMLとは異なります。値を比較する際は元の取得範囲を確認してください。

レビュー件数と平均スコア推移(日次・月次切り替え)
星評価構成比(日次・月次切り替え)
バージョン別レビュー件数推移
バージョン別 星評価構成比

-Google Playデータ取得, データ取得