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>
89 lines
3.3 KiB
Python
89 lines
3.3 KiB
Python
"""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.
|
|
|
|
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.
|
|
"""
|
|
|
|
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 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
|