保存済みの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 $csvTrue を確認してから進みます。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
"""記事掲載用の小さな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, metaAPIなしで試す架空6行
通信なしの架空CSVを作る: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
"""保存済み集計だけを読む。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とは異なります。値を比較する際は元の取得範囲を確認してください。