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>
This commit is contained in:
beantownbabeandClaude Sonnet 5 committed 2026-10-30 13:01:34 +01:00
1 parent 60f75ba8b5
commit 3de9143e46
3 files changed
+104 -68

No files matched your search

+15 -11
View File
@@ -2849,13 +2849,16 @@ def _team_engagement_rate_figure(engagement_df):
return fig
def _team_top_posts_table(top_posts_df):
"""Impressions/Engagements come straight from the export; Profile Viewers,
Followers Gained, and the Reactions/Comments/Reposts breakdown are hand-logged
per post (post_details_tracker.py) since none of that exists in the export at
all - "—" for any post not yet opened and logged. The two engagement numbers
(exported total vs. hand-logged breakdown) can legitimately disagree - see that
module's own note - so both are shown rather than one silently overriding the other."""
def _team_top_posts_table(top_posts_df, post_details):
"""Impressions/Engagements come straight from the Aggregate Analytics export;
Profile Viewers, Followers Gained, and the Reactions/Comments/Reposts breakdown
come from post_details_tracker.py's auto-parse of each post's own "Export"
button (SinglePostAnalytics_*.xlsx) - "—" for any post not yet exported that
way. The two engagement numbers (aggregate export's total vs. the per-post
export's own breakdown sum) have been observed to disagree for the same post
(one real case: 25 vs 20) - both are shown rather than one silently overriding
the other. post_details is the dict from post_details_tracker.load_post_details(),
built once per render rather than re-scanning the filesystem per table row."""
if top_posts_df.empty:
return html.Div("No posts in this window.", className="text-muted")
rows = top_posts_df.copy()
@@ -2870,7 +2873,7 @@ def _team_top_posts_table(top_posts_df):
table_rows = []
for _, r in rows.iterrows():
details = post_details_tracker.for_url(r["Post URL"]) or {}
details = post_details.get(r["Post URL"]) or {}
table_rows.append(html.Tr([
html.Td(r["Post Publish Date"]),
html.Td(dbc.Badge(r["Type"], color="secondary" if r["Type"] == "Repost" else "primary")),
@@ -2890,7 +2893,7 @@ def _team_top_posts_table(top_posts_df):
]])),
html.Tbody(table_rows),
html.Tfoot(html.Tr(html.Td(
"* hand-logged per post, opened individually — not in the LinkedIn export",
"* from each post's own \"Export\" button (SinglePostAnalytics file) — not in the Aggregate Analytics export",
colSpan=10, style={"fontSize": "0.7rem", "color": "#6b7a8d", "fontStyle": "italic"}))),
], bordered=True, size="sm", style={"fontSize": "0.85rem"})
@@ -2958,9 +2961,10 @@ def _build_person_dashboard(name, data):
_team_demographics_table(data["content_demographics"])), md=3),
], className="gx-3 mb-3")
post_details = post_details_tracker.load_post_details(DATA_DIR, UPLOAD_DIR)
posts_row = dbc.Row([
dbc.Col(section("Posts This Window — Originals vs Reposts",
_team_top_posts_table(data["top_posts"])), md=12),
_team_top_posts_table(data["top_posts"], post_details)), md=12),
], className="gx-3")
return html.Div([stat_row, source_note, calendar_row, charts_row, posts_row])
@@ -3043,7 +3047,7 @@ def _download_team_report(_n_clicks, name):
data = team_exports.load_person_data(name, DATA_DIR, UPLOAD_DIR)
if not data:
raise PreventUpdate
docx_bytes = team_exports.build_person_report_docx(name, data)
docx_bytes = team_exports.build_person_report_docx(name, data, DATA_DIR, UPLOAD_DIR)
filename = f"LinkedIn_Report_{name.replace(' ', '_')}.docx"
return dcc.send_bytes(lambda buf: buf.write(docx_bytes), filename=filename)
+83 -53
View File
@@ -1,58 +1,88 @@
"""Hand-logged per-post detail, keyed by Post URL - the breakdown LinkedIn only shows
when you open an individual post's own analytics panel, not anywhere in the Aggregate
Analytics export. team_exports.py's "Engagements" column is one combined number
(reactions + comments + reposts + saves + sends all blended); this is that same
total broken into its real parts, plus two fields with no export equivalent at all
(Profile viewers from this post, Followers gained from this post).
"""Per-post detail, auto-parsed from LinkedIn's "Export" button on an individual
post's own analytics page ("SinglePostAnalytics_<Full Name>_<id>.xlsx") - the one
and only sheet, "Post analytics", is a key/value list rather than a table: Post URL
and date up top, then Discovery (Impressions, Members reached), Profile activity
(Profile viewers/Followers gained from this post), and Engagement (Social
engagements plus the Reactions/Comments/Reposts/Saves/Sends/Link/Premium-button
breakdown that's all blended into one "Engagements" number in the Aggregate
Analytics file). A "Post viewer demographics" section follows for posts with
enough reach to populate it.
Note: the per-post breakdown's own sum (reactions+comments+reposts+saves+sends) has
been observed to NOT always match the export's own "Engagements" figure for the same
post (one real case: export said 25, the post's own panel summed to 20) - log exactly
what the post's panel shows, don't try to reconcile it against the export number.
Add an entry here by Post URL as each post gets opened and read - same "add an
entry, no code change needed" pattern as conversation_tracker.py/visibility_tracker.py.
Started as a hand-maintained dict; replaced once it turned out every individual
post's analytics page has its own Export button giving this exact breakdown - no
manual reading-off-the-screen needed after all. Matched to a post by the file's own
"Post URL" row, not by the numeric ID in the filename (confirmed those two IDs
don't actually match - the filename's id and the post's own activity id in the URL
are a different LinkedIn numbering scheme), so the matching is self-describing and
doesn't depend on guessing a naming convention.
"""
# "https://www.linkedin.com/posts/...": {
# "profile_viewers": 3,
# "followers_gained": 0,
# "reactions": 15,
# "comments": 5,
# "reposts": 0,
# "saves": 0,
# "sends": 0,
# },
POST_DETAILS = {
"https://www.linkedin.com/posts/mckinley-black-557816412_successful-sales-marketing-share-7478384260220121102-9K7G": {
"profile_viewers": 3,
"followers_gained": 0,
"reactions": 15,
"comments": 5,
"reposts": 0,
"saves": 0,
"sends": 0,
},
"https://www.linkedin.com/posts/mckinley-black-557816412_so-proud-to-be-on-the-team-ugcPost-7480961758367555584-CMnh": {
"profile_viewers": 0,
"followers_gained": 0,
"reactions": 1,
"comments": 0,
"reposts": 0,
"saves": 0,
"sends": 0,
},
"https://www.linkedin.com/posts/mckinley-black-557816412_i-love-learning-and-its-incredible-for-share-7518995227668529152-vHD1": {
"profile_viewers": 1,
"followers_gained": 0,
"reactions": 1,
"comments": 0,
"reposts": 0,
"saves": 0,
"sends": 0,
},
}
import os
import pandas as pd
def for_url(url):
return POST_DETAILS.get(url)
def _is_single_post_export(fname):
lower = fname.lower()
if lower.startswith("~$") or lower.startswith("."):
return False
if "singlepostanalytics_" not in lower:
return False
return lower.endswith((".xlsx", ".xls"))
def _find_files(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_single_post_export(fname):
candidates.append(os.path.join(folder, fname))
return sorted(set(candidates), key=os.path.getmtime)
def _parse_one(fp):
raw = pd.read_excel(fp, sheet_name="Post analytics", header=None)
kv = {}
for _, row in raw.iterrows():
key, val = row[0], row[1]
if pd.notna(key) and pd.notna(val):
kv[str(key).strip()] = val
def _int(key):
v = kv.get(key)
try:
return int(v)
except (TypeError, ValueError):
return None
url = kv.get("Post URL")
if not url:
return None, None
return url, {
"impressions": _int("Impressions"),
"members_reached": _int("Members reached"),
"profile_viewers": _int("Profile viewers from this post"),
"followers_gained": _int("Followers gained from this post"),
"social_engagements": _int("Social engagements"),
"reactions": _int("Reactions"),
"comments": _int("Comments"),
"reposts": _int("Reposts"),
"saves": _int("Saves"),
"sends": _int("Sends on LinkedIn"),
"link_engagements": _int("Link engagements"),
"premium_button_engagements": _int("Premium custom button engagements"),
"source_file": os.path.basename(fp),
}
def load_post_details(data_dir, upload_dir):
"""{post_url: {...}} across every SinglePostAnalytics file found - if the same
post was exported more than once, the most recently modified file wins."""
details = {}
for fp in _find_files(data_dir, upload_dir):
url, parsed = _parse_one(fp)
if url:
details[url] = parsed
return details
+6 -4
View File
@@ -176,13 +176,15 @@ def _engagement_rate_stats(top_posts_df):
return rated["rate"].max(), rated["rate"].mean(), len(rated)
def build_person_report_docx(name, data):
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."""
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 ("", "")
@@ -223,7 +225,7 @@ def build_person_report_docx(name, data):
"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_tracker.for_url(r["Post URL"]) or {}
d = post_details.get(r["Post URL"]) or {}
rows.append([
r["Post Publish Date"].strftime("%b %d, %Y"),
post_type,
@@ -237,7 +239,7 @@ def build_person_report_docx(name, data):
r["Post URL"],
])
report._docx_table(doc, rows, font_size=6)
doc.add_paragraph("* hand-logged per post, opened individually — not in the LinkedIn export",
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"),