Add a 5-word Description column to the Posts table

LinkedIn's exports have no title/text field for a post anywhere (neither
Aggregate Analytics nor SinglePostAnalytics) - only the URL, whose slug is a
URL-safe truncation of the post's own opening words. post_title_from_url()
decodes it (handles accented names like Krutošíková), strips the trailing
-share-/-ugcPost-<id>-<code> suffix, and takes the first 5 words as a readable
stand-in for a title. Added to both the live dashboard's posts table and the
downloadable report.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
beantownbabeandClaude Sonnet 5 committed 2026-10-30 13:08:33 +01:00
1 parent 3de9143e46
commit 0335e17e89
2 files changed
+24 -3

No files matched your search

+3 -2
View File
@@ -2876,6 +2876,7 @@ def _team_top_posts_table(top_posts_df, post_details):
details = post_details.get(r["Post URL"]) or {} details = post_details.get(r["Post URL"]) or {}
table_rows.append(html.Tr([ table_rows.append(html.Tr([
html.Td(r["Post Publish Date"]), html.Td(r["Post Publish Date"]),
html.Td(team_exports.post_title_from_url(r["Post URL"])),
html.Td(dbc.Badge(r["Type"], color="secondary" if r["Type"] == "Repost" else "primary")), 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["Impressions"]) if pd.notna(r["Impressions"]) else "—"),
html.Td(int(r["Engagements"]) if pd.notna(r["Engagements"]) else "—"), html.Td(int(r["Engagements"]) if pd.notna(r["Engagements"]) else "—"),
@@ -2888,13 +2889,13 @@ def _team_top_posts_table(top_posts_df, post_details):
])) ]))
return dbc.Table([ return dbc.Table([
html.Thead(html.Tr([html.Th(c) for c in [ html.Thead(html.Tr([html.Th(c) for c in [
"Date", "Type", "Impressions", "Engagements (export)", "Profile Viewers*", "Date", "Description", "Type", "Impressions", "Engagements (export)", "Profile Viewers*",
"Followers Gained*", "Reactions*", "Comments*", "Reposts*", "Link", "Followers Gained*", "Reactions*", "Comments*", "Reposts*", "Link",
]])), ]])),
html.Tbody(table_rows), html.Tbody(table_rows),
html.Tfoot(html.Tr(html.Td( html.Tfoot(html.Tr(html.Td(
"* from each post's own \"Export\" button (SinglePostAnalytics file) — not in the Aggregate Analytics 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"}))), colSpan=11, style={"fontSize": "0.7rem", "color": "#6b7a8d", "fontStyle": "italic"}))),
], bordered=True, size="sm", style={"fontSize": "0.85rem"}) ], bordered=True, size="sm", style={"fontSize": "0.85rem"})
+21 -1
View File
@@ -20,6 +20,7 @@ hasn't, and app.py renders a placeholder for that case.
import io import io
import os import os
import re import re
from urllib.parse import unquote
import pandas as pd import pandas as pd
from docx import Document from docx import Document
@@ -106,6 +107,24 @@ def _post_is_repost(url):
return m.group(1).lower() == "share" if m else None return m.group(1).lower() == "share" if m else None
_POST_SLUG_RE = re.compile(r"_(.+?)-(?:share|ugcpost)-\d+-\w+$", re.I)
def post_title_from_url(url, word_count=5):
"""A short, readable stand-in for an actual post title - LinkedIn's export has
no title/text field anywhere (Aggregate Analytics or SinglePostAnalytics), only
the URL, whose slug is a URL-safe truncation of the post's own opening words.
Decodes %-escapes first (needed for names with accents, e.g. Krutošíková)."""
if not url:
return ""
m = _POST_SLUG_RE.search(unquote(url))
if not m:
return ""
words = m.group(1).replace("-", " ").split()
title = " ".join(words[:word_count])
return title[0].upper() + title[1:] if title else ""
def _parse_top_posts(fp): def _parse_top_posts(fp):
raw = pd.read_excel(fp, sheet_name="TOP POSTS", header=None) raw = pd.read_excel(fp, sheet_name="TOP POSTS", header=None)
body = raw.iloc[3:] body = raw.iloc[3:]
@@ -221,13 +240,14 @@ def build_person_report_docx(name, data, data_dir, upload_dir):
if posts.empty: if posts.empty:
doc.add_paragraph("No posts in this window.") doc.add_paragraph("No posts in this window.")
else: else:
rows = [["Date", "Type", "Impressions", "Engagements (export)", "Profile Viewers*", rows = [["Date", "Description", "Type", "Impressions", "Engagements (export)", "Profile Viewers*",
"Followers Gained*", "Reactions*", "Comments*", "Reposts*", "Link"]] "Followers Gained*", "Reactions*", "Comments*", "Reposts*", "Link"]]
for _, r in posts.iterrows(): for _, r in posts.iterrows():
post_type = "Repost" if r["Is Repost"] else ("Original" if r["Is Repost"] is False else "Unknown") post_type = "Repost" if r["Is Repost"] else ("Original" if r["Is Repost"] is False else "Unknown")
d = post_details.get(r["Post URL"]) or {} d = post_details.get(r["Post URL"]) or {}
rows.append([ rows.append([
r["Post Publish Date"].strftime("%b %d, %Y"), r["Post Publish Date"].strftime("%b %d, %Y"),
post_title_from_url(r["Post URL"]),
post_type, post_type,
int(r["Impressions"]) if pd.notna(r["Impressions"]) else "—", int(r["Impressions"]) if pd.notna(r["Impressions"]) else "—",
int(r["Engagements"]) if pd.notna(r["Engagements"]) else "—", int(r["Engagements"]) if pd.notna(r["Engagements"]) else "—",