550 lines
21 KiB
Python
550 lines
21 KiB
Python
"""
|
|
Report Analyzer
|
|
Reads the CSV output from ip_geo_report.py and generates:
|
|
1. Monthly total visit count with MoM change
|
|
2. Monthly unique country count with MoM change
|
|
3. Top countries per month
|
|
4. Export to CSV + print to console
|
|
"""
|
|
|
|
import os
|
|
import csv
|
|
import argparse
|
|
from datetime import datetime
|
|
from collections import defaultdict
|
|
from typing import Dict, List, Tuple, Optional
|
|
|
|
try:
|
|
import matplotlib.pyplot as plt
|
|
import matplotlib.ticker as mticker
|
|
from matplotlib.gridspec import GridSpec
|
|
HAS_MATPLOTLIB = True
|
|
except ImportError:
|
|
HAS_MATPLOTLIB = False
|
|
|
|
# ─── Config ───────────────────────────────────────────────────────────────────
|
|
|
|
REPORT_DIR = "./report"
|
|
OUTPUT_DIR = "./analysis"
|
|
TOP_N_COUNTRIES = 10 # top N countries to show per month
|
|
|
|
# ─── Data Loader ──────────────────────────────────────────────────────────────
|
|
|
|
def load_report(path: str) -> List[dict]:
|
|
"""
|
|
Load report CSV generated by ip_geo_report.py
|
|
Expected columns: Country, Date, Visit Count
|
|
"""
|
|
rows = []
|
|
with open(path, newline="", encoding="utf-8") as f:
|
|
reader = csv.DictReader(f)
|
|
for row in reader:
|
|
try:
|
|
rows.append({
|
|
"country": row["Country"].strip(),
|
|
"date": row["Date"].strip(),
|
|
"count": int(row["Visit Count"]),
|
|
"year_month": parse_year_month(row["Date"]),
|
|
})
|
|
except (KeyError, ValueError):
|
|
continue
|
|
print(f"Loaded {len(rows):,} rows from {path}")
|
|
return rows
|
|
|
|
|
|
def parse_year_month(date_str: str) -> str:
|
|
"""
|
|
Parse date string to YYYY-MM regardless of format:
|
|
- 2023-08-01 → 2023-08
|
|
- 20230801 → 2023-08
|
|
- 2023/08/01 → 2023-08
|
|
- 2023-08-01 00:00:00 → 2023-08
|
|
"""
|
|
date_str = date_str.strip().split(" ")[0] # strip time part
|
|
date_str = date_str.replace("/", "-") # normalize slashes
|
|
|
|
if "-" in date_str:
|
|
# YYYY-MM-DD
|
|
return date_str[:7]
|
|
elif len(date_str) == 8:
|
|
# YYYYMMDD
|
|
return f"{date_str[:4]}-{date_str[4:6]}"
|
|
else:
|
|
# fallback — try common formats
|
|
for fmt in ("%Y%m%d", "%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y"):
|
|
try:
|
|
return datetime.strptime(date_str, fmt).strftime("%Y-%m")
|
|
except ValueError:
|
|
continue
|
|
return date_str[:7] # last resort
|
|
"""Find the most recent report_*.csv in report dir."""
|
|
files = [
|
|
f for f in os.listdir(report_dir)
|
|
if f.startswith("report_") and f.endswith(".csv")
|
|
]
|
|
if not files:
|
|
return None
|
|
files.sort(reverse=True)
|
|
return os.path.join(report_dir, files[0])
|
|
|
|
|
|
# ─── Aggregation ──────────────────────────────────────────────────────────────
|
|
|
|
def aggregate_monthly(rows: List[dict]) -> Dict[str, dict]:
|
|
"""
|
|
Aggregate by year_month:
|
|
{
|
|
"2025-01": {
|
|
"total_visits": 12345,
|
|
"countries": {"United States": 5000, "Taiwan": 3000, ...},
|
|
"unique_countries": 42
|
|
}
|
|
}
|
|
"""
|
|
monthly: Dict[str, dict] = defaultdict(lambda: {
|
|
"total_visits": 0,
|
|
"countries": defaultdict(int)
|
|
})
|
|
|
|
for row in rows:
|
|
ym = row["year_month"]
|
|
monthly[ym]["total_visits"] += row["count"]
|
|
monthly[ym]["countries"][row["country"]] += row["count"]
|
|
|
|
# Compute unique country count per month
|
|
result = {}
|
|
for ym in sorted(monthly.keys()):
|
|
data = monthly[ym]
|
|
result[ym] = {
|
|
"total_visits": data["total_visits"],
|
|
"countries": dict(data["countries"]),
|
|
"unique_countries": len(data["countries"]),
|
|
}
|
|
|
|
return result
|
|
|
|
|
|
# ─── MoM Calculation ──────────────────────────────────────────────────────────
|
|
|
|
def calc_mom(current: int, previous: int) -> Tuple[float, str]:
|
|
"""
|
|
Returns (pct_change, formatted_string)
|
|
e.g. (12.5, '+12.5%') or (-3.2, '-3.2%') or (0.0, 'N/A')
|
|
"""
|
|
if previous == 0:
|
|
return 0.0, "N/A"
|
|
pct = (current - previous) / previous * 100
|
|
sign = "+" if pct >= 0 else ""
|
|
return pct, f"{sign}{pct:.1f}%"
|
|
|
|
|
|
def build_monthly_stats(monthly: Dict[str, dict]) -> List[dict]:
|
|
"""
|
|
Build a flat list of monthly stats with MoM columns.
|
|
"""
|
|
months = sorted(monthly.keys())
|
|
records = []
|
|
|
|
for i, ym in enumerate(months):
|
|
prev_ym = months[i - 1] if i > 0 else None
|
|
curr_data = monthly[ym]
|
|
prev_data = monthly[prev_ym] if prev_ym else None
|
|
|
|
curr_visits = curr_data["total_visits"]
|
|
curr_countries = curr_data["unique_countries"]
|
|
prev_visits = prev_data["total_visits"] if prev_data else 0
|
|
prev_countries = prev_data["unique_countries"] if prev_data else 0
|
|
|
|
visits_mom_pct, visits_mom_str = calc_mom(curr_visits, prev_visits)
|
|
countries_mom_pct, countries_mom_str = calc_mom(curr_countries, prev_countries)
|
|
|
|
# Top N countries this month
|
|
top = sorted(
|
|
curr_data["countries"].items(),
|
|
key=lambda x: -x[1]
|
|
)[:TOP_N_COUNTRIES]
|
|
|
|
records.append({
|
|
"year_month": ym,
|
|
"total_visits": curr_visits,
|
|
"visits_prev_month": prev_visits,
|
|
"visits_mom": visits_mom_str,
|
|
"visits_mom_pct": visits_mom_pct,
|
|
"unique_countries": curr_countries,
|
|
"countries_prev_month": prev_countries,
|
|
"countries_mom": countries_mom_str,
|
|
"countries_mom_pct": countries_mom_pct,
|
|
"top_countries": top,
|
|
})
|
|
|
|
return records
|
|
|
|
|
|
# ─── Export ───────────────────────────────────────────────────────────────────
|
|
|
|
def export_monthly_summary(records: List[dict], output_dir: str) -> str:
|
|
"""Export monthly summary CSV."""
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
path = os.path.join(output_dir, f"monthly_summary_{datetime.today().strftime('%Y%m%d')}.csv")
|
|
|
|
with open(path, "w", newline="", encoding="utf-8") as f:
|
|
writer = csv.writer(f)
|
|
writer.writerow([
|
|
"Year-Month",
|
|
"Total Visits",
|
|
"Prev Month Visits",
|
|
"Visits MoM",
|
|
"Unique Countries",
|
|
"Prev Month Countries",
|
|
"Countries MoM",
|
|
"Top 1 Country",
|
|
"Top 1 Visits",
|
|
"Top 2 Country",
|
|
"Top 2 Visits",
|
|
"Top 3 Country",
|
|
"Top 3 Visits",
|
|
])
|
|
for r in records:
|
|
top = r["top_countries"]
|
|
def tc(n, field):
|
|
return top[n][field] if len(top) > n else ""
|
|
writer.writerow([
|
|
r["year_month"],
|
|
r["total_visits"],
|
|
r["visits_prev_month"],
|
|
r["visits_mom"],
|
|
r["unique_countries"],
|
|
r["countries_prev_month"],
|
|
r["countries_mom"],
|
|
tc(0, 0), tc(0, 1),
|
|
tc(1, 0), tc(1, 1),
|
|
tc(2, 0), tc(2, 1),
|
|
])
|
|
|
|
print(f"Monthly summary saved: {path}")
|
|
return path
|
|
|
|
|
|
def export_top_countries(records: List[dict], output_dir: str) -> str:
|
|
"""Export per-month top countries CSV."""
|
|
path = os.path.join(output_dir, f"top_countries_{datetime.today().strftime('%Y%m%d')}.csv")
|
|
|
|
with open(path, "w", newline="", encoding="utf-8") as f:
|
|
writer = csv.writer(f)
|
|
writer.writerow(["Year-Month", "Rank", "Country", "Visits", "% of Month"])
|
|
for r in records:
|
|
total = r["total_visits"]
|
|
for rank, (country, visits) in enumerate(r["top_countries"], 1):
|
|
pct = f"{visits/total*100:.1f}%" if total else "0%"
|
|
writer.writerow([r["year_month"], rank, country, visits, pct])
|
|
|
|
print(f"Top countries saved: {path}")
|
|
return path
|
|
|
|
|
|
# ─── Console Report ───────────────────────────────────────────────────────────
|
|
|
|
def _mom_arrow(pct: float) -> str:
|
|
if pct > 0: return "↑"
|
|
if pct < 0: return "↓"
|
|
return "→"
|
|
|
|
def _bar(value: int, max_value: int, width: int = 20) -> str:
|
|
if max_value == 0:
|
|
return ""
|
|
filled = int(value / max_value * width)
|
|
return "█" * filled + "░" * (width - filled)
|
|
|
|
def print_console_report(records: List[dict]):
|
|
"""Print a formatted report to the terminal."""
|
|
|
|
max_visits = max(r["total_visits"] for r in records) if records else 1
|
|
max_countries = max(r["unique_countries"] for r in records) if records else 1
|
|
|
|
# ── Monthly visits ────────────────────────────────────────────────────────
|
|
print()
|
|
print("═" * 72)
|
|
print(" 月份訪問量統計 Monthly Visit Count")
|
|
print("═" * 72)
|
|
print(f" {'月份':<10} {'訪問量':>12} {'MoM':>10} {'趨勢'}")
|
|
print(" " + "─" * 68)
|
|
|
|
for r in records:
|
|
arrow = _mom_arrow(r["visits_mom_pct"])
|
|
bar = _bar(r["total_visits"], max_visits)
|
|
print(
|
|
f" {r['year_month']:<10} "
|
|
f"{r['total_visits']:>12,} "
|
|
f"{r['visits_mom']:>10} "
|
|
f"{arrow} {bar}"
|
|
)
|
|
|
|
print()
|
|
|
|
# ── Monthly unique countries ───────────────────────────────────────────────
|
|
print("═" * 72)
|
|
print(" 月份不重複國家數 Monthly Unique Country Count")
|
|
print("═" * 72)
|
|
print(f" {'月份':<10} {'國家數':>10} {'MoM':>10} {'趨勢'}")
|
|
print(" " + "─" * 68)
|
|
|
|
for r in records:
|
|
arrow = _mom_arrow(r["countries_mom_pct"])
|
|
bar = _bar(r["unique_countries"], max_countries)
|
|
print(
|
|
f" {r['year_month']:<10} "
|
|
f"{r['unique_countries']:>10,} "
|
|
f"{r['countries_mom']:>10} "
|
|
f"{arrow} {bar}"
|
|
)
|
|
|
|
print()
|
|
|
|
# ── Top countries per month ────────────────────────────────────────────────
|
|
print("═" * 72)
|
|
print(f" 各月 Top {TOP_N_COUNTRIES} 國家 Top Countries per Month")
|
|
print("═" * 72)
|
|
|
|
for r in records:
|
|
total = r["total_visits"]
|
|
print(f"\n {r['year_month']} (共 {total:,} 次訪問)")
|
|
print(f" {'排名':>4} {'國家':<28} {'訪問量':>10} {'佔比':>6}")
|
|
print(" " + "─" * 56)
|
|
for rank, (country, visits) in enumerate(r["top_countries"], 1):
|
|
pct = f"{visits/total*100:.1f}%" if total else "0%"
|
|
print(f" {rank:>4}. {country:<28} {visits:>10,} {pct:>6}")
|
|
|
|
print()
|
|
|
|
# ── MoM Summary ───────────────────────────────────────────────────────────
|
|
if len(records) >= 2:
|
|
latest = records[-1]
|
|
prev = records[-2]
|
|
print("═" * 72)
|
|
print(" 最新月份 MoM 摘要 Latest Month MoM Summary")
|
|
print("═" * 72)
|
|
print(
|
|
f" {prev['year_month']} → {latest['year_month']}\n"
|
|
f" 訪問量:{prev['total_visits']:,} → {latest['total_visits']:,} "
|
|
f"({latest['visits_mom']})\n"
|
|
f" 國家數:{prev['unique_countries']} → {latest['unique_countries']} "
|
|
f"({latest['countries_mom']})"
|
|
)
|
|
print()
|
|
|
|
|
|
# ─── Visualization ────────────────────────────────────────────────────────────
|
|
|
|
def generate_charts(records: List[dict], output_dir: str) -> str:
|
|
"""
|
|
Generate a 4-panel chart:
|
|
1. Monthly total visits (bar + MoM line)
|
|
2. Monthly unique countries (bar + MoM line)
|
|
3. MoM % change comparison (line chart)
|
|
4. Top 5 countries stacked bar (latest 6 months)
|
|
"""
|
|
if not HAS_MATPLOTLIB:
|
|
print("matplotlib not installed — skipping charts.")
|
|
print("Install with: pip install matplotlib")
|
|
return ""
|
|
|
|
months = [r["year_month"] for r in records]
|
|
visits = [r["total_visits"] for r in records]
|
|
ctries = [r["unique_countries"] for r in records]
|
|
v_mom = [r["visits_mom_pct"] for r in records]
|
|
c_mom = [r["countries_mom_pct"] for r in records]
|
|
|
|
# replace 0.0 (N/A first month) with nan for cleaner line
|
|
# fill_between requires float nan, not None
|
|
v_mom_plot = [v if i > 0 else float("nan") for i, v in enumerate(v_mom)]
|
|
c_mom_plot = [v if i > 0 else float("nan") for i, v in enumerate(c_mom)]
|
|
|
|
fig = plt.figure(figsize=(16, 12))
|
|
fig.suptitle("IP Geo Report — Monthly Analysis", fontsize=16, fontweight="bold", y=0.98)
|
|
gs = GridSpec(2, 2, figure=fig, hspace=0.45, wspace=0.35)
|
|
|
|
colors = {
|
|
"visits": "#4C6EF5",
|
|
"countries": "#20C997",
|
|
"mom_pos": "#51CF66",
|
|
"mom_neg": "#FF6B6B",
|
|
"neutral": "#ADB5BD",
|
|
}
|
|
|
|
x = range(len(months))
|
|
|
|
# ── Panel 1: Monthly visits bar ───────────────────────────────────────────
|
|
ax1 = fig.add_subplot(gs[0, 0])
|
|
bars = ax1.bar(x, visits, color=colors["visits"], alpha=0.85, zorder=2)
|
|
ax1.set_title("Monthly Total Visits", fontweight="bold")
|
|
ax1.set_xticks(list(x))
|
|
ax1.set_xticklabels(months, rotation=45, ha="right", fontsize=8)
|
|
ax1.yaxis.set_major_formatter(mticker.FuncFormatter(lambda v, _: f"{v/1000:.0f}K" if v >= 1000 else str(int(v))))
|
|
ax1.set_ylabel("Visits")
|
|
ax1.grid(axis="y", linestyle="--", alpha=0.4, zorder=1)
|
|
ax1.spines[["top","right"]].set_visible(False)
|
|
|
|
# MoM % on secondary axis
|
|
ax1b = ax1.twinx()
|
|
ax1b.plot(list(x), v_mom_plot, color="#FF6B6B", marker="o", linewidth=1.8,
|
|
markersize=5, linestyle="--", label="MoM %", zorder=3)
|
|
ax1b.axhline(0, color="#FF6B6B", linewidth=0.6, linestyle=":")
|
|
ax1b.set_ylabel("MoM %", color="#FF6B6B", fontsize=9)
|
|
ax1b.tick_params(axis="y", labelcolor="#FF6B6B")
|
|
ax1b.yaxis.set_major_formatter(mticker.FuncFormatter(lambda v, _: f"{v:+.0f}%"))
|
|
|
|
# value labels on bars
|
|
for bar, val in zip(bars, visits):
|
|
ax1.text(bar.get_x() + bar.get_width()/2, bar.get_height() + max(visits)*0.01,
|
|
f"{val:,}", ha="center", va="bottom", fontsize=7, color="#333")
|
|
|
|
# ── Panel 2: Monthly unique countries bar ─────────────────────────────────
|
|
ax2 = fig.add_subplot(gs[0, 1])
|
|
bars2 = ax2.bar(x, ctries, color=colors["countries"], alpha=0.85, zorder=2)
|
|
ax2.set_title("Monthly Unique Countries", fontweight="bold")
|
|
ax2.set_xticks(list(x))
|
|
ax2.set_xticklabels(months, rotation=45, ha="right", fontsize=8)
|
|
ax2.set_ylabel("Countries")
|
|
ax2.grid(axis="y", linestyle="--", alpha=0.4, zorder=1)
|
|
ax2.spines[["top","right"]].set_visible(False)
|
|
|
|
ax2b = ax2.twinx()
|
|
ax2b.plot(list(x), c_mom_plot, color="#FF922B", marker="s", linewidth=1.8,
|
|
markersize=5, linestyle="--", label="MoM %", zorder=3)
|
|
ax2b.axhline(0, color="#FF922B", linewidth=0.6, linestyle=":")
|
|
ax2b.set_ylabel("MoM %", color="#FF922B", fontsize=9)
|
|
ax2b.tick_params(axis="y", labelcolor="#FF922B")
|
|
ax2b.yaxis.set_major_formatter(mticker.FuncFormatter(lambda v, _: f"{v:+.0f}%"))
|
|
|
|
for bar, val in zip(bars2, ctries):
|
|
ax2.text(bar.get_x() + bar.get_width()/2, bar.get_height() + max(ctries)*0.02,
|
|
str(val), ha="center", va="bottom", fontsize=7, color="#333")
|
|
|
|
# ── Panel 3: MoM % comparison line chart ─────────────────────────────────
|
|
ax3 = fig.add_subplot(gs[1, 0])
|
|
ax3.plot(list(x), v_mom_plot, color=colors["visits"], marker="o", linewidth=2,
|
|
markersize=6, label="Visits MoM %")
|
|
ax3.plot(list(x), c_mom_plot, color=colors["countries"], marker="s", linewidth=2,
|
|
markersize=6, label="Countries MoM %", linestyle="--")
|
|
ax3.axhline(0, color=colors["neutral"], linewidth=1, linestyle=":")
|
|
|
|
# shade positive / negative regions
|
|
import math
|
|
x_num = list(x)
|
|
ax3.fill_between(x_num, v_mom_plot, 0,
|
|
where=[not math.isnan(v) and v > 0 for v in v_mom_plot],
|
|
alpha=0.12, color=colors["mom_pos"], interpolate=True)
|
|
ax3.fill_between(x_num, v_mom_plot, 0,
|
|
where=[not math.isnan(v) and v < 0 for v in v_mom_plot],
|
|
alpha=0.12, color=colors["mom_neg"], interpolate=True)
|
|
|
|
ax3.set_title("MoM % Change Comparison", fontweight="bold")
|
|
ax3.set_xticks(list(x))
|
|
ax3.set_xticklabels(months, rotation=45, ha="right", fontsize=8)
|
|
ax3.set_ylabel("MoM %")
|
|
ax3.yaxis.set_major_formatter(mticker.FuncFormatter(lambda v, _: f"{v:+.0f}%"))
|
|
ax3.legend(fontsize=8)
|
|
ax3.grid(linestyle="--", alpha=0.4)
|
|
ax3.spines[["top","right"]].set_visible(False)
|
|
|
|
# ── Panel 4: Top 5 countries stacked bar (latest 6 months) ───────────────
|
|
ax4 = fig.add_subplot(gs[1, 1])
|
|
last = records[-6:] if len(records) >= 6 else records
|
|
top_months = [r["year_month"] for r in last]
|
|
|
|
# Collect global top 5 countries across shown months
|
|
country_totals: Dict[str, int] = defaultdict(int)
|
|
for r in last:
|
|
for country, cnt in r["top_countries"]:
|
|
country_totals[country] += cnt
|
|
top5 = [c for c, _ in sorted(country_totals.items(), key=lambda x: -x[1])[:5]]
|
|
|
|
palette = ["#4C6EF5","#20C997","#FF922B","#CC5DE8","#FF6B6B"]
|
|
bottoms = [0] * len(last)
|
|
|
|
for i, country in enumerate(top5):
|
|
vals = []
|
|
for r in last:
|
|
cmap = dict(r["top_countries"])
|
|
vals.append(cmap.get(country, 0))
|
|
ax4.bar(range(len(last)), vals, bottom=bottoms,
|
|
label=country, color=palette[i % len(palette)], alpha=0.88)
|
|
bottoms = [b + v for b, v in zip(bottoms, vals)]
|
|
|
|
ax4.set_title(f"Top 5 Countries — Last {len(last)} Months", fontweight="bold")
|
|
ax4.set_xticks(range(len(last)))
|
|
ax4.set_xticklabels(top_months, rotation=45, ha="right", fontsize=8)
|
|
ax4.yaxis.set_major_formatter(mticker.FuncFormatter(lambda v, _: f"{v/1000:.0f}K" if v >= 1000 else str(int(v))))
|
|
ax4.set_ylabel("Visits")
|
|
ax4.legend(fontsize=7, loc="upper left")
|
|
ax4.grid(axis="y", linestyle="--", alpha=0.4, zorder=1)
|
|
ax4.spines[["top","right"]].set_visible(False)
|
|
|
|
# ── Save ─────────────────────────────────────────────────────────────────
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
path = os.path.join(output_dir, f"analysis_chart_{datetime.today().strftime('%Y%m%d')}.png")
|
|
fig.savefig(path, dpi=150, bbox_inches="tight")
|
|
plt.close(fig)
|
|
print(f"Chart saved: {path}")
|
|
return path
|
|
|
|
|
|
# ─── Entry Point ──────────────────────────────────────────────────────────────
|
|
|
|
def main():
|
|
global TOP_N_COUNTRIES
|
|
parser = argparse.ArgumentParser(description="IP Geo Report Analyzer")
|
|
parser.add_argument(
|
|
"--input", "-i",
|
|
help="Path to report CSV (default: latest in ./report/)",
|
|
default=None
|
|
)
|
|
parser.add_argument(
|
|
"--output", "-o",
|
|
help="Output directory (default: ./analysis/)",
|
|
default=OUTPUT_DIR
|
|
)
|
|
parser.add_argument(
|
|
"--top", "-n",
|
|
help=f"Top N countries per month (default: {TOP_N_COUNTRIES})",
|
|
type=int,
|
|
default=TOP_N_COUNTRIES
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
# Find input file
|
|
input_path = args.input
|
|
if not input_path:
|
|
input_path = find_latest_report(REPORT_DIR)
|
|
if not input_path:
|
|
print(f"No report_*.csv found in {REPORT_DIR}")
|
|
print("Run ip_geo_report.py first, or specify --input path")
|
|
return
|
|
|
|
print(f"Analyzing: {input_path}")
|
|
|
|
# Load → aggregate → build stats
|
|
rows = load_report(input_path)
|
|
monthly = aggregate_monthly(rows)
|
|
records = build_monthly_stats(monthly)
|
|
|
|
if not records:
|
|
print("No data found in report.")
|
|
return
|
|
|
|
# Print to console
|
|
print_console_report(records)
|
|
|
|
# Export CSVs
|
|
export_monthly_summary(records, args.output)
|
|
export_top_countries(records, args.output)
|
|
|
|
# Generate chart
|
|
generate_charts(records, args.output)
|
|
|
|
print(f"\nAnalysis complete. Files saved to: {args.output}/")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|