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 return fig
def _team_top_posts_table(top_posts_df): def _team_top_posts_table(top_posts_df, post_details):
"""Impressions/Engagements come straight from the export; Profile Viewers, """Impressions/Engagements come straight from the Aggregate Analytics export;
Followers Gained, and the Reactions/Comments/Reposts breakdown are hand-logged Profile Viewers, Followers Gained, and the Reactions/Comments/Reposts breakdown
per post (post_details_tracker.py) since none of that exists in the export at come from post_details_tracker.py's auto-parse of each post's own "Export"
all - "—" for any post not yet opened and logged. The two engagement numbers button (SinglePostAnalytics_*.xlsx) - "—" for any post not yet exported that
(exported total vs. hand-logged breakdown) can legitimately disagree - see that way. The two engagement numbers (aggregate export's total vs. the per-post
module's own note - so both are shown rather than one silently overriding the other.""" 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: if top_posts_df.empty:
return html.Div("No posts in this window.", className="text-muted") return html.Div("No posts in this window.", className="text-muted")
rows = top_posts_df.copy() rows = top_posts_df.copy()
@@ -2870,7 +2873,7 @@ def _team_top_posts_table(top_posts_df):
table_rows = [] table_rows = []
for _, r in rows.iterrows(): 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([ table_rows.append(html.Tr([
html.Td(r["Post Publish Date"]), html.Td(r["Post Publish Date"]),
html.Td(dbc.Badge(r["Type"], color="secondary" if r["Type"] == "Repost" else "primary")), 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.Tbody(table_rows),
html.Tfoot(html.Tr(html.Td( 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"}))), colSpan=10, style={"fontSize": "0.7rem", "color": "#6b7a8d", "fontStyle": "italic"}))),
], bordered=True, size="sm", style={"fontSize": "0.85rem"}) ], 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), _team_demographics_table(data["content_demographics"])), md=3),
], className="gx-3 mb-3") ], className="gx-3 mb-3")
post_details = post_details_tracker.load_post_details(DATA_DIR, UPLOAD_DIR)
posts_row = dbc.Row([ posts_row = dbc.Row([
dbc.Col(section("Posts This Window — Originals vs Reposts", 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") ], className="gx-3")
return html.Div([stat_row, source_note, calendar_row, charts_row, posts_row]) 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) data = team_exports.load_person_data(name, DATA_DIR, UPLOAD_DIR)
if not data: if not data:
raise PreventUpdate 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" filename = f"LinkedIn_Report_{name.replace(' ', '_')}.docx"
return dcc.send_bytes(lambda buf: buf.write(docx_bytes), filename=filename) 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 """Per-post detail, auto-parsed from LinkedIn's "Export" button on an individual
when you open an individual post's own analytics panel, not anywhere in the Aggregate post's own analytics page ("SinglePostAnalytics_<Full Name>_<id>.xlsx") - the one
Analytics export. team_exports.py's "Engagements" column is one combined number and only sheet, "Post analytics", is a key/value list rather than a table: Post URL
(reactions + comments + reposts + saves + sends all blended); this is that same and date up top, then Discovery (Impressions, Members reached), Profile activity
total broken into its real parts, plus two fields with no export equivalent at all (Profile viewers/Followers gained from this post), and Engagement (Social
(Profile viewers from this post, Followers gained from this post). 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 Started as a hand-maintained dict; replaced once it turned out every individual
been observed to NOT always match the export's own "Engagements" figure for the same post's analytics page has its own Export button giving this exact breakdown - no
post (one real case: export said 25, the post's own panel summed to 20) - log exactly manual reading-off-the-screen needed after all. Matched to a post by the file's own
what the post's panel shows, don't try to reconcile it against the export number. "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
Add an entry here by Post URL as each post gets opened and read - same "add an are a different LinkedIn numbering scheme), so the matching is self-describing and
entry, no code change needed" pattern as conversation_tracker.py/visibility_tracker.py. doesn't depend on guessing a naming convention.
""" """
# "https://www.linkedin.com/posts/...": { import os
# "profile_viewers": 3,
# "followers_gained": 0, import pandas as pd
# "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,
},
}
def for_url(url): def _is_single_post_export(fname):
return POST_DETAILS.get(url) 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) 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 """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 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 (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/ 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 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"] discovery = data["discovery"]
window_label = discovery.get("date_range", "") window_label = discovery.get("date_range", "")
_, window_end = (window_label.split(" - ") + [""])[:2] if window_label else ("", "") _, 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"]] "Followers Gained*", "Reactions*", "Comments*", "Reposts*", "Link"]]
for _, r in posts.iterrows(): for _, r in posts.iterrows():
post_type = "Repost" if r["Is Repost"] else ("Original" if r["Is Repost"] is False else "Unknown") 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([ rows.append([
r["Post Publish Date"].strftime("%b %d, %Y"), r["Post Publish Date"].strftime("%b %d, %Y"),
post_type, post_type,
@@ -237,7 +239,7 @@ def build_person_report_docx(name, data):
r["Post URL"], r["Post URL"],
]) ])
report._docx_table(doc, rows, font_size=6) 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") style="Caption")
for heading, key in [("Audience Demographics (who sees it)", "audience_demographics"), for heading, key in [("Audience Demographics (who sees it)", "audience_demographics"),