diff --git a/app.py b/app.py index be98e4d..110b02e 100644 --- a/app.py +++ b/app.py @@ -2876,6 +2876,7 @@ def _team_top_posts_table(top_posts_df, post_details): details = post_details.get(r["Post URL"]) or {} table_rows.append(html.Tr([ 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(int(r["Impressions"]) if pd.notna(r["Impressions"]) 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([ 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", ]])), html.Tbody(table_rows), html.Tfoot(html.Tr(html.Td( "* 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"}) diff --git a/team_exports.py b/team_exports.py index ab9b7fd..30f98d5 100644 --- a/team_exports.py +++ b/team_exports.py @@ -20,6 +20,7 @@ hasn't, and app.py renders a placeholder for that case. import io import os import re +from urllib.parse import unquote import pandas as pd from docx import Document @@ -106,6 +107,24 @@ def _post_is_repost(url): 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): raw = pd.read_excel(fp, sheet_name="TOP POSTS", header=None) body = raw.iloc[3:] @@ -221,13 +240,14 @@ def build_person_report_docx(name, data, data_dir, upload_dir): if posts.empty: doc.add_paragraph("No posts in this window.") else: - rows = [["Date", "Type", "Impressions", "Engagements (export)", "Profile Viewers*", + rows = [["Date", "Description", "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.get(r["Post URL"]) or {} rows.append([ r["Post Publish Date"].strftime("%b %d, %Y"), + post_title_from_url(r["Post URL"]), post_type, int(r["Impressions"]) if pd.notna(r["Impressions"]) else "—", int(r["Engagements"]) if pd.notna(r["Engagements"]) else "—",