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:
1 parent
833cfc6388
commit
ea7aa06484
3 files changed
+80
-7
No files matched your search
@@ -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"})
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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")]:
|
||||
|
||||
Reference in new issue
Block a user