""" 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()