diff --git a/app.py b/app.py index 2536a22..be98e4d 100644 --- a/app.py +++ b/app.py @@ -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) diff --git a/post_details_tracker.py b/post_details_tracker.py index b9edb54..128078a 100644 --- a/post_details_tracker.py +++ b/post_details_tracker.py @@ -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__.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 diff --git a/team_exports.py b/team_exports.py index 8da9758..ab9b7fd 100644 --- a/team_exports.py +++ b/team_exports.py @@ -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"),