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:
1 parent
60f75ba8b5
commit
3de9143e46
3 files changed
+103
-67
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
+82
-52
@@ -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 _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 for_url(url):
|
||||
return POST_DETAILS.get(url)
|
||||
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
@@ -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"),
|
||||
|
||||
Reference in new issue
Block a user