Dash app with Overview, Content, Followers, Visitors, Competitors, and Growth tabs. Includes weekly-pace tracking, PDF report generation, data upload with live refresh, and a static HTML export builder for sharing via NextCloud.
252 lines
12 KiB
Python
252 lines
12 KiB
Python
"""Builds a leadership-facing PDF summary of LinkedIn performance for a chosen
|
|
timeframe. Charts are rendered to PNG via kaleido and embedded as images (reportlab
|
|
has no native Plotly support).
|
|
|
|
Deliberately has zero import-time dependency on app.py — app.py is run directly
|
|
(`python3 app.py`), so it's registered in sys.modules as "__main__", not "app". If this
|
|
module did `import app`, Python wouldn't recognize that as the same module and would
|
|
re-execute app.py from scratch as a second copy, which is still mid-import when this
|
|
module's functions get called — a circular-import AttributeError. Passing everything
|
|
in as arguments avoids that entirely.
|
|
"""
|
|
import io
|
|
|
|
import pandas as pd
|
|
import plotly.express as px
|
|
from reportlab.lib import colors
|
|
from reportlab.lib.pagesizes import A4
|
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
|
from reportlab.lib.units import cm
|
|
from reportlab.platypus import (SimpleDocTemplate, Table, TableStyle, Paragraph,
|
|
Spacer, Image, HRFlowable)
|
|
|
|
NAVY = colors.HexColor("#1b3a5c")
|
|
LIGHT_GRAY = colors.HexColor("#f0f4f8")
|
|
TEXT_GRAY = colors.HexColor("#6b7a8d")
|
|
|
|
|
|
def report_month_options(fts_df):
|
|
"""'All Time', then one entry per year, then one entry per (year, month) — lets the
|
|
report be scoped to as broad or as narrow a window as the export data supports."""
|
|
import calendar as calmod
|
|
dates = fts_df["Date"]
|
|
years = sorted({d.year for d in dates})
|
|
months = sorted({(d.year, d.month) for d in dates})
|
|
options = [{"label": "All Time", "value": "all"}]
|
|
options += [{"label": str(y), "value": f"year-{y}"} for y in years]
|
|
options += [{"label": f"{calmod.month_name[m]} {y}", "value": f"{y}-{m:02d}"} for y, m in months]
|
|
return options
|
|
|
|
|
|
def _filtered(df, date_col, value):
|
|
if not value or value == "all":
|
|
return df
|
|
if value.startswith("year-"):
|
|
year = int(value.split("-", 1)[1])
|
|
return df[df[date_col].dt.year == year]
|
|
year, month = (int(p) for p in value.split("-"))
|
|
return df[(df[date_col].dt.year == year) & (df[date_col].dt.month == month)]
|
|
|
|
|
|
def _period_label(value, fts_df):
|
|
if not value or value == "all":
|
|
return "All Time"
|
|
return next((o["label"] for o in report_month_options(fts_df) if o["value"] == value), value)
|
|
|
|
|
|
def _fig_to_image(fig, width_cm=17, height_px=300, width_px=900):
|
|
buf = io.BytesIO()
|
|
fig.write_image(buf, format="png", width=width_px, height=height_px, scale=2)
|
|
buf.seek(0)
|
|
aspect = height_px / width_px
|
|
return Image(buf, width=width_cm * cm, height=width_cm * cm * aspect)
|
|
|
|
|
|
def build_report_pdf(ctx, timeframe_value="all"):
|
|
"""ctx is a dict bundling everything the report needs from the live app:
|
|
D, posts, comp_ordered, current_total_followers, daily_followers_figure (the
|
|
figure-builder function), std_fn (the _std styling helper), TMPL, FORMAT_COLORS."""
|
|
D = ctx["D"]
|
|
posts = ctx["posts"]
|
|
comp_ordered = ctx["comp_ordered"]
|
|
current_total_followers = ctx["current_total_followers"]
|
|
daily_followers_figure = ctx["daily_followers_figure"]
|
|
std_fn = ctx["std_fn"]
|
|
tmpl = ctx["TMPL"]
|
|
format_colors = ctx["FORMAT_COLORS"]
|
|
growth_snapshot = ctx["growth_snapshot"]
|
|
|
|
fts_f = _filtered(D["fts"], "Date", timeframe_value)
|
|
posts_f = _filtered(posts, "Created date", timeframe_value)
|
|
cts_f = _filtered(D["cts"], "Date", timeframe_value)
|
|
vts_f = _filtered(D["vts"], "Date", timeframe_value)
|
|
|
|
new_followers = int(fts_f["Total followers"].sum()) if not fts_f.empty else 0
|
|
total_impressions = int(cts_f["Impressions (total)"].sum()) if not cts_f.empty else 0
|
|
total_page_views = int(vts_f["Total page views (total)"].sum()) if not vts_f.empty else 0
|
|
posts_published = len(posts_f)
|
|
best_eng = float(posts_f["Eng %"].max()) if len(posts_f) else 0.0
|
|
avg_eng_series = cts_f["Engagement rate (total)"].replace(0, float("nan")) if not cts_f.empty else pd.Series(dtype=float)
|
|
avg_eng = float(avg_eng_series.mean()) if not avg_eng_series.dropna().empty else 0.0
|
|
|
|
styles = getSampleStyleSheet()
|
|
title_style = ParagraphStyle("TXTitle", parent=styles["Title"], textColor=NAVY, fontSize=20, spaceAfter=2)
|
|
subtitle_style = ParagraphStyle("TXSubtitle", parent=styles["Normal"], textColor=TEXT_GRAY, fontSize=10, spaceAfter=16)
|
|
h2_style = ParagraphStyle("TXH2", parent=styles["Heading2"], textColor=NAVY, fontSize=13, spaceBefore=16, spaceAfter=8)
|
|
body_style = ParagraphStyle("TXBody", parent=styles["Normal"], fontSize=9, textColor=colors.HexColor("#333333"))
|
|
note_style = ParagraphStyle("TXNote", parent=styles["Normal"], fontSize=8, textColor=TEXT_GRAY, spaceBefore=4)
|
|
|
|
period_label = _period_label(timeframe_value, D["fts"])
|
|
|
|
story = []
|
|
story.append(Paragraph("TXproducts, LinkedIn Performance Report", title_style))
|
|
story.append(Paragraph(
|
|
f"Period: {period_label} | Generated {pd.Timestamp.now().strftime('%B %d, %Y')} "
|
|
f" | Data as of {D['last_updated'].strftime('%B %d, %Y')}",
|
|
subtitle_style))
|
|
story.append(HRFlowable(width="100%", color=NAVY, thickness=1.2))
|
|
|
|
# --- KPI summary table ---
|
|
story.append(Paragraph("Key Metrics", h2_style))
|
|
kpi_data = [
|
|
["Total Followers\n(as of today)", "New Followers", "Posts Published", "Total Impressions",
|
|
"Total Page Views", "Best Post Eng.", "Avg. Eng. Rate"],
|
|
[f"{current_total_followers:,}", f"{new_followers:,}", f"{posts_published:,}",
|
|
f"{total_impressions:,}", f"{total_page_views:,}", f"{best_eng:.1f}%",
|
|
f"{avg_eng:.2f}%" if avg_eng else "—"],
|
|
]
|
|
kpi_table = Table(kpi_data, colWidths=[2.6 * cm] * 7)
|
|
kpi_table.setStyle(TableStyle([
|
|
("BACKGROUND", (0, 0), (-1, 0), LIGHT_GRAY),
|
|
("TEXTCOLOR", (0, 0), (-1, 0), TEXT_GRAY),
|
|
("FONTSIZE", (0, 0), (-1, 0), 7),
|
|
("FONTSIZE", (0, 1), (-1, 1), 12),
|
|
("FONTNAME", (0, 1), (-1, 1), "Helvetica-Bold"),
|
|
("TEXTCOLOR", (0, 1), (-1, 1), NAVY),
|
|
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
|
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e7ef")),
|
|
("TOPPADDING", (0, 0), (-1, -1), 6),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
|
]))
|
|
story.append(kpi_table)
|
|
|
|
# --- Weekly growth pace ---
|
|
story.append(Paragraph("Weekly Growth Pace", h2_style))
|
|
on_track = growth_snapshot["on_track"]
|
|
if on_track is None:
|
|
status_color, status_label = TEXT_GRAY, "Building History"
|
|
elif on_track:
|
|
status_color, status_label = colors.HexColor("#52C41A"), "On Track"
|
|
else:
|
|
status_color, status_label = colors.HexColor("#d43f3a"), "Behind"
|
|
weekly_pct = growth_snapshot["week_to_date_pct"]
|
|
target_pct = growth_snapshot["rolling_best_weekly_pct"]
|
|
objective_data = [
|
|
["This Week", f"Target (best of {growth_snapshot['rolling_best_weeks']} wks)", "Pace Status"],
|
|
[f"{weekly_pct:+.2f}%" if weekly_pct is not None else "—",
|
|
f"{target_pct:+.2f}%" if target_pct is not None else "—",
|
|
status_label],
|
|
]
|
|
objective_table = Table(objective_data, colWidths=[5 * cm, 6 * cm, 4 * cm])
|
|
objective_table.setStyle(TableStyle([
|
|
("BACKGROUND", (0, 0), (-1, 0), LIGHT_GRAY),
|
|
("TEXTCOLOR", (0, 0), (-1, 0), TEXT_GRAY),
|
|
("FONTSIZE", (0, 0), (-1, 0), 8),
|
|
("FONTSIZE", (0, 1), (-1, 1), 11),
|
|
("FONTNAME", (0, 1), (-1, 1), "Helvetica-Bold"),
|
|
("TEXTCOLOR", (0, 1), (-1, 1), NAVY),
|
|
("TEXTCOLOR", (2, 1), (2, 1), status_color),
|
|
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
|
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e7ef")),
|
|
("TOPPADDING", (0, 0), (-1, -1), 6),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
|
]))
|
|
story.append(objective_table)
|
|
story.append(Paragraph(
|
|
f"Target is a rolling personal best, the highest trailing-7-day growth rate seen in "
|
|
f"the last {growth_snapshot['rolling_best_weeks']} weeks, not a fixed percentage. It "
|
|
f"rises automatically as performance improves, but a one-off spike ages out of the "
|
|
f"window after {growth_snapshot['rolling_best_weeks']} weeks rather than setting an "
|
|
f"unreachable target permanently.",
|
|
note_style))
|
|
|
|
# --- Follower growth chart ---
|
|
if not fts_f.empty:
|
|
story.append(Paragraph("Follower Growth", h2_style))
|
|
fig = daily_followers_figure(fts_f, posts_f)
|
|
fig.update_layout(showlegend=True, font=dict(size=14))
|
|
story.append(_fig_to_image(fig))
|
|
|
|
# --- Top posts table ---
|
|
story.append(Paragraph("Top Posts" + ("" if timeframe_value == "all" else f", {period_label}"), h2_style))
|
|
if len(posts_f):
|
|
top_posts = posts_f.sort_values("Eng %", ascending=False).head(5)
|
|
rows = [["Post", "Date", "Type", "Impressions", "Eng %", "CTR %"]]
|
|
for _, r in top_posts.iterrows():
|
|
rows.append([
|
|
Paragraph(str(r["Short title"])[:60], body_style),
|
|
r["Created date"].strftime("%d/%m/%Y"),
|
|
r["Content Type"],
|
|
f"{int(r['Impressions']):,}",
|
|
f"{r['Eng %']:.1f}%",
|
|
f"{r['CTR %']:.1f}%",
|
|
])
|
|
posts_table = Table(rows, colWidths=[7 * cm, 2.2 * cm, 2.2 * cm, 2.4 * cm, 1.8 * cm, 1.8 * cm])
|
|
posts_table.setStyle(TableStyle([
|
|
("BACKGROUND", (0, 0), (-1, 0), NAVY),
|
|
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
|
("FONTSIZE", (0, 0), (-1, -1), 8),
|
|
("ALIGN", (1, 0), (-1, -1), "CENTER"),
|
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
|
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e7ef")),
|
|
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, LIGHT_GRAY]),
|
|
("TOPPADDING", (0, 0), (-1, -1), 5),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
|
]))
|
|
story.append(posts_table)
|
|
else:
|
|
story.append(Paragraph("No posts published in this period.", body_style))
|
|
|
|
# --- Content format performance ---
|
|
if len(posts_f):
|
|
story.append(Paragraph("Impressions by Content Format", h2_style))
|
|
fmt_imp = (posts_f.groupby("Content Type", as_index=False)["Impressions"].sum().sort_values("Impressions"))
|
|
fig2 = std_fn(px.bar(
|
|
fmt_imp, x="Impressions", y="Content Type", orientation="h",
|
|
template=tmpl, color="Content Type", color_discrete_map=format_colors))
|
|
fig2.update_layout(showlegend=False, font=dict(size=14))
|
|
story.append(_fig_to_image(fig2, height_px=220))
|
|
|
|
# --- Competitor snapshot ---
|
|
story.append(Paragraph("Competitor Snapshot", h2_style))
|
|
story.append(Paragraph(
|
|
"Cumulative export snapshot, not specific to the selected period.", note_style))
|
|
comp_rows = [["Page", "New Followers", "Posts", "Comments", "Comments/day", "Reactions"]]
|
|
for _, r in comp_ordered.iterrows():
|
|
label = r["Page"] + (" (You)" if r["Page"] == "TXproducts" else "")
|
|
comp_rows.append([label, r["New Followers"], r["Posts"], r["Comments"], r["Comments per day"], r["Reactions"]])
|
|
comp_table = Table(comp_rows, colWidths=[5 * cm, 2.6 * cm, 2 * cm, 2.2 * cm, 2.6 * cm, 2.2 * cm])
|
|
comp_table.setStyle(TableStyle([
|
|
("BACKGROUND", (0, 0), (-1, 0), NAVY),
|
|
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
|
("FONTSIZE", (0, 0), (-1, -1), 8),
|
|
("ALIGN", (1, 0), (-1, -1), "CENTER"),
|
|
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e7ef")),
|
|
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, LIGHT_GRAY]),
|
|
("TOPPADDING", (0, 0), (-1, -1), 5),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
|
]))
|
|
story.append(comp_table)
|
|
|
|
story.append(Spacer(1, 16))
|
|
story.append(Paragraph("TXproducts · Internal Report · Generated automatically from LinkedIn Page Analytics exports.", note_style))
|
|
|
|
buf = io.BytesIO()
|
|
doc = SimpleDocTemplate(buf, pagesize=A4, topMargin=1.5 * cm, bottomMargin=1.5 * cm,
|
|
leftMargin=1.5 * cm, rightMargin=1.5 * cm)
|
|
doc.build(story)
|
|
buf.seek(0)
|
|
return buf.getvalue()
|