New "Reports" tab for the 3-month LinkedIn campaign, combining dashboard data with the Follower Directory rather than a standalone HTML/PDF workflow. For each campaign month it shows KPI tiles (including a new Monthly Growth Rate, this month's net new followers as a percentage of the followers already on the page at the start of that month, resets every month against its own starting count), the follower growth chart, every post published that month, an Audience Composition breakdown sourced live from the Follower Directory (sector/seniority/country, both this month's cohort and the all-time snapshot), and curated monthly narrative notes (features shipped, data quality fixes, Directory updates, outstanding items). Reuses the existing reportlab PDF pipeline, extended with the same sections, and mirrored in the static exporter with live-only controls disabled. New files: monthly_report_notes.py (per-month curated narrative, July populated), follower_directory_data.py (parses the Directory's DATA array directly so numbers can't drift from the hand-maintained source of truth). Also fixes three pre-existing bugs found while building this: average engagement rate was displaying 100x too small (a 0-1 fraction shown as-is instead of scaled to a percentage), the Sector/Seniority/Country breakdown tables were silently truncated below their own stated totals (a hardcoded top-5 display cap plus a separate top-10 country cap inside composition_stats itself), and the static exporter had no support for dbc.Table, dropping the <table> tag entirely. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
50 lines
2.4 KiB
Python
50 lines
2.4 KiB
Python
"""Reads audience composition straight from assets/follower_directory.html's DATA array,
|
|
so the dashboard and PDF reports show the same numbers as the hand-maintained Follower
|
|
Study/Directory instead of a second, independently hand-computed set that can drift out
|
|
of sync with it.
|
|
|
|
Deliberately has zero import-time dependency on app.py, same reasoning as report.py's
|
|
module docstring: app.py runs as "__main__", so importing it here would re-execute it as
|
|
a second, still-mid-import copy.
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
from collections import Counter
|
|
|
|
|
|
def load_directory(assets_dir):
|
|
"""Parses the `const DATA = [...];` array out of follower_directory.html into a list
|
|
of dicts. Returns an empty list if the file is missing rather than raising, since a
|
|
fresh checkout or a stripped-down deployment may not have it yet."""
|
|
path = os.path.join(assets_dir, "follower_directory.html")
|
|
if not os.path.exists(path):
|
|
return []
|
|
text = open(path, encoding="utf-8").read()
|
|
match = re.search(r"const DATA = (\[.*?\]);", text, re.S)
|
|
if not match:
|
|
return []
|
|
return json.loads(match.group(1))
|
|
|
|
|
|
def composition_stats(data, month_value=None, top_countries=None):
|
|
"""Sector/seniority/function/country breakdowns, either over the whole audited set
|
|
(month_value=None) or restricted to the entries whose `followedSort` matches
|
|
month_value (e.g. "2026-07"), i.e. that month's own follower cohort as profiled so
|
|
far. A month's count only ever grows as later audit passes catch up, it is a
|
|
coverage figure, not a final one.
|
|
|
|
top_countries defaults to unbounded (None = every country shown): this was
|
|
previously hardcoded to 10, which silently dropped tail countries from a monthly
|
|
cohort's breakdown while the "total" count kept counting them, the table's own sum
|
|
fell short of the total it was supposed to explain (caught when adding Sabine E.
|
|
Herlitschka's July correction pushed the cohort to 11 distinct countries)."""
|
|
rows = data if month_value is None else [d for d in data if d.get("followedSort") == month_value]
|
|
return {
|
|
"total": len(rows),
|
|
"sector": Counter(d.get("sector", "Unknown") for d in rows),
|
|
"seniority": Counter(d.get("seniority", "Unknown") for d in rows),
|
|
"function": Counter(d.get("function", "Unknown") for d in rows),
|
|
"country": Counter(d.get("country") or "Unknown/blank" for d in rows).most_common(top_countries),
|
|
}
|