Files
LinkedIn-Analytics-Dashboard/follower_directory_data.py
T
beantownbabeandClaude Sonnet 5 f9f27401db Add Reports tab: monthly campaign progress reports
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>
2026-08-05 09:56:19 +02:00

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),
}