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:
1 parent
3de9143e46
commit
0335e17e89
2 files changed
+24
-3
No files matched your search
@@ -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
@@ -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 "—",
|
||||||
|
|||||||
Reference in new issue
Block a user