Add hand-logged per-post detail: Profile Viewers, Followers Gained, reaction breakdown

LinkedIn's export has no per-post Profile Viewers/Followers Gained attribution,
and its "Engagements" column is a single blended number - none of that exists
anywhere in the Aggregate Analytics file. Each individual post's own analytics
panel does show it, so this is a hand-logged, per-post-URL dict
(post_details_tracker.py, same add-an-entry pattern as the other two trackers).

Added as extra columns to the Posts table (both the live dashboard and the
downloadable report) alongside the exported Impressions/Engagements, rather
than replacing them - the two Engagement numbers have been observed to
genuinely disagree for the same post (export said 25, the post's own panel
summed reactions+comments+reposts+saves+sends to 20), so both are shown
instead of one silently overriding the other.

Seeded with one real logged post (the July 2 post: 3 profile viewers, 0
followers gained, 15 reactions, 5 comments).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
beantownbabeandClaude Sonnet 5 committed 2026-10-30 11:58:11 +01:00
1 parent 833cfc6388
commit ea7aa06484
3 files changed
+80 -7

No files matched your search

+28 -5
View File
@@ -19,6 +19,7 @@ import theme_tracker
import conversation_tracker
import team_exports
import visibility_tracker
import post_details_tracker
# Overridable via env var so the same code runs unmodified in Docker/internal deployments —
# defaults to this machine's path for local dev.
@@ -2819,6 +2820,12 @@ def _team_engagement_rate_figure(engagement_df):
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."""
if top_posts_df.empty:
return html.Div("No posts in this window.", className="text-muted")
rows = top_posts_df.copy()
@@ -2827,18 +2834,34 @@ def _team_top_posts_table(top_posts_df):
axis=1)
rows["Type"] = rows["Is Repost"].map({True: "Repost", False: "Original"}).fillna("Unknown")
rows["Post Publish Date"] = rows["Post Publish Date"].dt.strftime("%b %d, %Y")
table_rows = [
html.Tr([
def _cell(v):
return str(v) if v is not None else "—"
table_rows = []
for _, r in rows.iterrows():
details = post_details_tracker.for_url(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")),
html.Td(int(r["Impressions"]) if pd.notna(r["Impressions"]) else "—"),
html.Td(int(r["Engagements"]) if pd.notna(r["Engagements"]) else "—"),
html.Td(_cell(details.get("profile_viewers"))),
html.Td(_cell(details.get("followers_gained"))),
html.Td(_cell(details.get("reactions"))),
html.Td(_cell(details.get("comments"))),
html.Td(_cell(details.get("reposts"))),
html.Td(r["Post"]),
]) for _, r in rows.iterrows()
]
]))
return dbc.Table([
html.Thead(html.Tr([html.Th(c) for c in ["Date", "Type", "Impressions", "Engagements", "Link"]])),
html.Thead(html.Tr([html.Th(c) for c in [
"Date", "Type", "Impressions", "Engagements (export)", "Profile Viewers*",
"Followers Gained*", "Reactions*", "Comments*", "Reposts*", "Link",
]])),
html.Tbody(table_rows),
html.Tfoot(html.Tr(html.Td(
"* hand-logged per post, opened individually — not in the LinkedIn export",
colSpan=10, style={"fontSize": "0.7rem", "color": "#6b7a8d", "fontStyle": "italic"}))),
], bordered=True, size="sm", style={"fontSize": "0.85rem"})
+40
View File
@@ -0,0 +1,40 @@
"""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).
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.
"""
# "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,
},
}
def for_url(url):
return POST_DETAILS.get(url)
+12 -2
View File
@@ -26,6 +26,7 @@ from docx import Document
from docx.oxml.ns import qn
import report
import post_details_tracker
TEAM_MEMBERS = [
"Peter Gaal",
@@ -218,17 +219,26 @@ def build_person_report_docx(name, data):
if posts.empty:
doc.add_paragraph("No posts in this window.")
else:
rows = [["Date", "Type", "Impressions", "Engagements", "Link"]]
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_tracker.for_url(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)
report._docx_table(doc, rows, font_size=6)
doc.add_paragraph("* hand-logged per post, opened individually — not in the LinkedIn export",
style="Caption")
for heading, key in [("Audience Demographics (who sees it)", "audience_demographics"),
("Content Demographics (who engages)", "content_demographics")]: