Files
LinkedIn-Analytics-Dashboard/team_exports.py
T
beantownbabeandClaude Sonnet 5 3de9143e46 Rebuild post_details_tracker.py as an auto-parser - no hand-logging needed
Turns out every individual post's own analytics page has an "Export" button
(SinglePostAnalytics_<Name>_<id>.xlsx) giving exactly the detail that was being
hand-logged: Profile Viewers/Followers Gained from the post, and the Reactions/
Comments/Reposts/Saves/Sends/Link/Premium-button breakdown behind the Aggregate
Analytics file's single blended "Engagements" number.

Matches each file to a post by its own "Post URL" field, not the numeric id in
the filename - confirmed those two ids are actually different LinkedIn schemes,
so filename-based matching would have been wrong. Loaded once per render
(load_post_details) rather than re-parsed per table row.

All 4 of Mckinley's real posts are now auto-covered (55/80/17/365 impressions),
replacing the 1-of-4 hand-logged coverage from before. Kept showing both
engagement numbers side by side in the table/report - the per-post export's own
breakdown sum has been observed to disagree with the aggregate export's
Engagements figure for the same post more than once now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-30 13:01:34 +01:00

259 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Per-team-member personal LinkedIn export data, starting with Mckinley's own export
on 2026-10-30 (Aleksandra, Daniel, Niklas, Peter to follow, submitted every Monday).
The Page-level exports (Content/Followers/Visitors/Competitors) only cover the
TXproducts Company Page - this parses each team member's own "Aggregate Analytics"
export instead (LinkedIn's personal-profile analytics download: DISCOVERY, ENGAGEMENT,
TOP POSTS, FOLLOWERS, AUDIENCE DEMOGRAPHICS, CONTENT DEMOGRAPHICS sheets), named
"AggregateAnalytics_<Full Name>_<start>_<end>.xlsx". Each weekly submission is a new
~365-day trailing window that heavily overlaps the previous one, so the daily series
(ENGAGEMENT, FOLLOWERS) are merged across every file on disk the same way the Page's
own Followers export is - newest file wins on an overlapping date - while the
point-in-time snapshots (TOP POSTS, both DEMOGRAPHICS sheets, the running follower
total) always come from the single newest file.
TEAM_MEMBERS is the roster shown in the Team tab's dropdown regardless of whether a
person has submitted an export yet; load_person_data() returns None for anyone who
hasn't, and app.py renders a placeholder for that case.
"""
import io
import os
import re
import pandas as pd
from docx import Document
from docx.oxml.ns import qn
import report
import post_details_tracker
TEAM_MEMBERS = [
"Peter Gaal",
"Daniel Schmidt",
"Niklas Meidow",
"Aleksandra Kotenko",
"Mckinley Black",
]
# Personal-profile baseline, distinct from the Page's own app.BASELINE_DATE (2026-06-16) -
# that's when Page-level tracking began, unrelated to any individual's own history.
# Mckinley confirmed directly: everyone started this work in June 2026 with 0 followers,
# so this is a fixed, human-confirmed anchor the same way BASELINE_FOLLOWERS is for the
# Page, not something derived from the export's own ~365-day rolling window edge.
TEAM_BASELINE_DATE = "2026-06-01"
TEAM_BASELINE_FOLLOWERS = 0
def _is_personal_export(fname, name):
lower = fname.lower()
if lower.startswith("~$") or lower.startswith("."):
return False
if not lower.startswith("aggregateanalytics_"):
return False
if not lower.endswith((".xlsx", ".xls")):
return False
return name.lower() in lower
def _find_person_files(name, data_dir, upload_dir):
candidates = []
for folder in (data_dir, upload_dir):
if not os.path.isdir(folder):
continue
for fname in os.listdir(folder):
if _is_personal_export(fname, name):
candidates.append(os.path.join(folder, fname))
return sorted(set(candidates), key=os.path.getmtime)
def _parse_discovery(fp):
raw = pd.read_excel(fp, sheet_name="DISCOVERY", header=None)
out = {"date_range": raw.iloc[0, 1] if len(raw) else None}
for i in range(1, len(raw)):
key, val = raw.iloc[i, 0], raw.iloc[i, 1]
if pd.notna(key):
out[key] = int(val)
return out
def _parse_engagement(fp):
df = pd.read_excel(fp, sheet_name="ENGAGEMENT")
df["Date"] = pd.to_datetime(df["Date"])
df["Impressions"] = pd.to_numeric(df["Impressions"])
df["Engagements"] = pd.to_numeric(df["Engagements"])
return df
def _parse_followers(fp):
raw = pd.read_excel(fp, sheet_name="FOLLOWERS", header=None)
total = int(raw.iloc[0, 1])
daily = pd.read_excel(fp, sheet_name="FOLLOWERS", header=2)
daily["Date"] = pd.to_datetime(daily["Date"])
daily["New followers"] = pd.to_numeric(daily["New followers"])
return total, daily
_POST_TYPE_RE = re.compile(r"-(share|ugcpost)-\d+-", re.I)
def _post_is_repost(url):
"""LinkedIn's own URL convention: .../posts/<slug>-share-<id>-<code> is a repost
of someone else's content, .../posts/<slug>-ugcPost-<id>-<code> is an original
post - the only way to tell them apart, since "Engagements" doesn't split out
which activity type a post is."""
m = _POST_TYPE_RE.search(url or "")
return m.group(1).lower() == "share" if m else None
def _parse_top_posts(fp):
raw = pd.read_excel(fp, sheet_name="TOP POSTS", header=None)
body = raw.iloc[3:]
eng = body.iloc[:, [0, 1, 2]].dropna(how="all")
eng.columns = ["Post URL", "Post Publish Date", "Engagements"]
imp = body.iloc[:, [4, 5, 6]].dropna(how="all")
imp.columns = ["Post URL", "Post Publish Date", "Impressions"]
merged = pd.merge(eng, imp[["Post URL", "Impressions"]], on="Post URL", how="outer")
if merged.empty:
return merged
merged["Post Publish Date"] = pd.to_datetime(merged["Post Publish Date"])
merged["Engagements"] = pd.to_numeric(merged["Engagements"])
merged["Impressions"] = pd.to_numeric(merged["Impressions"])
merged["Is Repost"] = merged["Post URL"].apply(_post_is_repost)
return merged.sort_values("Post Publish Date", ascending=False).reset_index(drop=True)
def _parse_demographics(fp, sheet_name):
df = pd.read_excel(fp, sheet_name=sheet_name)
df["Percentage"] = pd.to_numeric(df["Percentage"].astype(str).str.rstrip("%"))
return df
def load_person_data(name, data_dir, upload_dir):
files = _find_person_files(name, data_dir, upload_dir)
if not files:
return None
eng_frames, fol_frames = [], []
for fp in files:
mtime = os.path.getmtime(fp)
e = _parse_engagement(fp)
e["_src"] = mtime
eng_frames.append(e)
_, daily = _parse_followers(fp)
daily["_src"] = mtime
fol_frames.append(daily)
engagement = (pd.concat(eng_frames)
.sort_values("_src").drop_duplicates(subset=["Date"], keep="last")
.drop(columns=["_src"]).sort_values("Date").reset_index(drop=True))
followers_daily = (pd.concat(fol_frames)
.sort_values("_src").drop_duplicates(subset=["Date"], keep="last")
.drop(columns=["_src"]).sort_values("Date").reset_index(drop=True))
latest = files[-1]
followers_total, _ = _parse_followers(latest)
return {
"discovery": _parse_discovery(latest),
"engagement": engagement,
"followers_daily": followers_daily,
"followers_total": followers_total,
"top_posts": _parse_top_posts(latest),
"audience_demographics": _parse_demographics(latest, "AUDIENCE DEMOGRAPHICS"),
"content_demographics": _parse_demographics(latest, "CONTENT DEMOGRAPHICS"),
"source_files": [os.path.basename(fp) for fp in files],
}
def _engagement_rate_stats(top_posts_df):
if top_posts_df.empty:
return None, None, 0
rated = top_posts_df[top_posts_df["Impressions"] > 0].copy()
if rated.empty:
return None, None, 0
rated["rate"] = rated["Engagements"] / rated["Impressions"] * 100
return rated["rate"].max(), rated["rate"].mean(), len(rated)
def build_person_report_docx(name, data, data_dir, upload_dir):
"""A send-to-the-person summary of their own analyzed LinkedIn data - Mckinley's
own review, not a raw dump of the export file. Reuses report._docx_table/_docx_shade
(the same table styling as the Page-level monthly report) rather than duplicating
that OOXML-shading logic here. Starting/New Followers use TEAM_BASELINE_DATE/
TEAM_BASELINE_FOLLOWERS (everyone started this work in June 2026 at 0, confirmed
directly), not the export's own ~365-day rolling window edge. data_dir/upload_dir
locate any SinglePostAnalytics_*.xlsx files for the per-post detail table."""
post_details = post_details_tracker.load_post_details(data_dir, upload_dir)
discovery = data["discovery"]
window_label = discovery.get("date_range", "")
_, window_end = (window_label.split(" - ") + [""])[:2] if window_label else ("", "")
baseline_ts = pd.Timestamp(TEAM_BASELINE_DATE)
since_baseline = data["followers_daily"][data["followers_daily"]["Date"] >= baseline_ts]
new_followers_window = int(since_baseline["New followers"].sum())
starting_followers = TEAM_BASELINE_FOLLOWERS
baseline_label = baseline_ts.strftime("%b %d, %Y")
best_eng, avg_eng, rated_count = _engagement_rate_stats(data["top_posts"])
doc = Document()
zoom_el = doc.settings.element.find(qn("w:zoom"))
if zoom_el is not None and zoom_el.get(qn("w:percent")) is None:
zoom_el.set(qn("w:percent"), "100")
doc.add_heading(f"{name} — LinkedIn Personal Profile Report", level=0)
doc.add_paragraph(f"Window: {window_label}")
doc.add_heading("Summary", level=1)
report._docx_table(doc, [
["Metric", "Value", "Note"],
["Starting Followers", f"{starting_followers:,}", f"as of {baseline_label}"],
["Total Followers", f"{data['followers_total']:,}", f"as of {window_end}"],
["New Followers", f"{new_followers_window:,}", f"{baseline_label} – {window_end}"],
["Total Impressions", f"{discovery.get('Impressions', 0):,}", window_label],
["Members Reached", f"{discovery.get('Members reached', 0):,}", window_label],
["Best Post Real Eng.", f"{best_eng:.2f}%" if best_eng is not None else "—",
f"across {rated_count} posts"],
["Avg. Eng. Rate", f"{avg_eng:.2f}%" if avg_eng is not None else "—", "raw, organic"],
])
doc.add_heading("Posts This Window", level=1)
posts = data["top_posts"]
if posts.empty:
doc.add_paragraph("No posts in this window.")
else:
rows = [["Date", "Type", "Impressions", "Engagements (export)", "Profile Viewers*",
"Followers Gained*", "Reactions*", "Comments*", "Reposts*", "Link"]]
for _, r in posts.iterrows():
post_type = "Repost" if r["Is Repost"] else ("Original" if r["Is Repost"] is False else "Unknown")
d = post_details.get(r["Post URL"]) or {}
rows.append([
r["Post Publish Date"].strftime("%b %d, %Y"),
post_type,
int(r["Impressions"]) if pd.notna(r["Impressions"]) else "—",
int(r["Engagements"]) if pd.notna(r["Engagements"]) else "—",
d.get("profile_viewers", "—"),
d.get("followers_gained", "—"),
d.get("reactions", "—"),
d.get("comments", "—"),
d.get("reposts", "—"),
r["Post URL"],
])
report._docx_table(doc, rows, font_size=6)
doc.add_paragraph("* from each post's own \"Export\" button (SinglePostAnalytics file) — not in the Aggregate Analytics export",
style="Caption")
for heading, key in [("Audience Demographics (who sees it)", "audience_demographics"),
("Content Demographics (who engages)", "content_demographics")]:
doc.add_heading(heading, level=1)
demo = data[key]
if demo.empty:
doc.add_paragraph("No data.")
continue
rows = [["Category", "Value", "%"]]
rows += [[r["Top Demographics"], r["Value"], f"{r['Percentage']:.0f}%"] for _, r in demo.iterrows()]
report._docx_table(doc, rows)
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()