Build real Team tab parsing from LinkedIn's personal Aggregate Analytics export
Replaces the empty placeholder with actual parsing of the "AggregateAnalytics_ <Name>_<start>_<end>.xlsx" personal-profile export (DISCOVERY, ENGAGEMENT, TOP POSTS, FOLLOWERS, and both DEMOGRAPHICS sheets). Weekly submissions are a new ~365-day trailing window each time, so the daily series (engagement, followers) merge across every file on disk the same way the Page's own Followers export does - newest file wins on an overlapping date. Reposts vs original posts are distinguished from the post URL itself (.../share-<id> vs .../ugcPost-<id>), since LinkedIn's "Engagements" column doesn't separate them - this was the open design question about measuring repost/comment activity, and the data turns out to answer it directly. Verified against Mckinley's own first real export (2026-10-30): Impressions, Followers, and Engagements all cross-check against the file's own DISCOVERY summary row. The other four team members still show the "no export yet" placeholder until their first Monday submission lands. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
436e52254a
commit
db4587f75e
2 files changed
+246
-22
No files matched your search
@@ -2682,23 +2682,122 @@ def _switch_to_insights_page(pathname, selected_month):
|
||||
return classes + [content, hero_title, _hero_subtitle_children(active), _hero_actions(active, month_value)]
|
||||
|
||||
|
||||
def _team_stat_tile(value, label):
|
||||
return dbc.Col(dbc.Card(dbc.CardBody([
|
||||
html.Div(str(value), style={"fontSize": "1.8rem", "fontWeight": "800", "color": BLUE}),
|
||||
html.Div(label, style={"fontSize": "0.78rem", "color": "#6b7a8d"}),
|
||||
]), className="shadow-sm text-center"), md=3)
|
||||
|
||||
|
||||
def _team_engagement_figure(engagement_df):
|
||||
fig = go.Figure()
|
||||
fig.add_trace(go.Scatter(x=engagement_df["Date"], y=engagement_df["Impressions"],
|
||||
name="Impressions", mode="lines", line=dict(color=BLUE, width=2)))
|
||||
fig.add_trace(go.Scatter(x=engagement_df["Date"], y=engagement_df["Engagements"],
|
||||
name="Engagements", mode="lines", line=dict(color=ORANGE, width=2),
|
||||
yaxis="y2"))
|
||||
fig.update_layout(yaxis=dict(title="Impressions"),
|
||||
yaxis2=dict(title="Engagements", overlaying="y", side="right"),
|
||||
template=TMPL)
|
||||
return fig
|
||||
|
||||
|
||||
def _team_followers_figure(followers_daily_df):
|
||||
fig = px.bar(followers_daily_df, x="Date", y="New followers", template=TMPL)
|
||||
fig.update_traces(marker_color=GREEN)
|
||||
return fig
|
||||
|
||||
|
||||
def _team_top_posts_table(top_posts_df):
|
||||
if top_posts_df.empty:
|
||||
return html.Div("No posts in this window.", className="text-muted")
|
||||
rows = top_posts_df.copy()
|
||||
rows["Post"] = rows.apply(
|
||||
lambda r: html.A("View post ↗", href=r["Post URL"], target="_blank", rel="noopener noreferrer"),
|
||||
axis=1)
|
||||
rows["Type"] = rows["Is Repost"].map({True: "Repost", False: "Original"}).fillna("Unknown")
|
||||
rows["Post Publish Date"] = rows["Post Publish Date"].dt.strftime("%b %d, %Y")
|
||||
table_rows = [
|
||||
html.Tr([
|
||||
html.Td(r["Post Publish Date"]),
|
||||
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 "—"),
|
||||
html.Td(r["Post"]),
|
||||
]) for _, r in rows.iterrows()
|
||||
]
|
||||
return dbc.Table([
|
||||
html.Thead(html.Tr([html.Th(c) for c in ["Date", "Type", "Impressions", "Engagements", "Link"]])),
|
||||
html.Tbody(table_rows),
|
||||
], bordered=True, size="sm", style={"fontSize": "0.85rem"})
|
||||
|
||||
|
||||
def _team_demographics_table(demo_df):
|
||||
if demo_df.empty:
|
||||
return html.Div("No data.", className="text-muted")
|
||||
rows = [html.Tr([html.Td(r["Top Demographics"]), html.Td(r["Value"]), html.Td(f"{r['Percentage']:.0f}%")])
|
||||
for _, r in demo_df.iterrows()]
|
||||
return dbc.Table([
|
||||
html.Thead(html.Tr([html.Th(c) for c in ["Category", "Value", "%"]])),
|
||||
html.Tbody(rows),
|
||||
], bordered=True, size="sm", style={"fontSize": "0.8rem"})
|
||||
|
||||
|
||||
def _build_person_dashboard(name, data):
|
||||
discovery = data["discovery"]
|
||||
total_engagements = int(data["engagement"]["Engagements"].sum())
|
||||
stat_row = dbc.Row([
|
||||
_team_stat_tile(discovery.get("Impressions", 0), "Impressions"),
|
||||
_team_stat_tile(discovery.get("Members reached", 0), "Members Reached"),
|
||||
_team_stat_tile(data["followers_total"], "Total Followers"),
|
||||
_team_stat_tile(total_engagements, "Engagements"),
|
||||
], className="gx-3 mb-3")
|
||||
|
||||
source_note = html.Div(
|
||||
f"Source: {', '.join(data['source_files'])} — window {discovery.get('date_range', '')}",
|
||||
style={"fontSize": "0.72rem", "color": "#6b7a8d", "marginBottom": "0.75rem"})
|
||||
|
||||
charts_row = dbc.Row([
|
||||
dbc.Col(section("Daily Impressions & Engagements",
|
||||
G(_team_engagement_figure(data["engagement"]), style={"height": "260px"})), md=6),
|
||||
dbc.Col(section("Daily New Followers",
|
||||
G(_team_followers_figure(data["followers_daily"]), style={"height": "260px"})), md=6),
|
||||
], className="gx-3 mb-3")
|
||||
|
||||
posts_row = dbc.Row([
|
||||
dbc.Col(section("Posts This Window — Originals vs Reposts",
|
||||
_team_top_posts_table(data["top_posts"])), md=12),
|
||||
], className="gx-3 mb-3")
|
||||
|
||||
demo_row = dbc.Row([
|
||||
dbc.Col(section("Audience Demographics (who sees it)",
|
||||
_team_demographics_table(data["audience_demographics"])), md=6),
|
||||
dbc.Col(section("Content Demographics (who engages)",
|
||||
_team_demographics_table(data["content_demographics"])), md=6),
|
||||
], className="gx-3")
|
||||
|
||||
return html.Div([stat_row, source_note, charts_row, posts_row, demo_row])
|
||||
|
||||
|
||||
@app.callback(
|
||||
Output("team-content-area", "children"),
|
||||
Input("team-member-select", "value"),
|
||||
)
|
||||
def _render_team_tab(name):
|
||||
"""Starts empty for everyone - personal-profile exports begin December 2026 (see
|
||||
team_exports.py). Shows the same clean "nothing yet" treatment as the Engagement
|
||||
Response tab's theme/conversation cards until a person's real data lands."""
|
||||
data = team_exports.exports_for(name)
|
||||
"""Mckinley's own export landed 2026-10-30 (see team_exports.py); Aleksandra,
|
||||
Daniel, Niklas, and Peter are still pending their first Monday submission.
|
||||
Shows the same clean "nothing yet" treatment as the Engagement Response tab
|
||||
until a given person's first export lands - no code change needed when it does,
|
||||
team_exports.load_person_data() picks it up automatically by filename."""
|
||||
data = team_exports.load_person_data(name, DATA_DIR, UPLOAD_DIR)
|
||||
if not data:
|
||||
return dbc.Alert(
|
||||
f"No personal LinkedIn export data for {name} yet - this starts December 2026. "
|
||||
"Add an entry to team_exports.TEAM_EXPORTS as each person's export data comes in, "
|
||||
"no other code changes needed for it to show up here.",
|
||||
f"No personal LinkedIn export data for {name} yet. Drop their "
|
||||
f"\"AggregateAnalytics_{name}_...\" export into the project folder and it will "
|
||||
"show up here automatically on the next refresh - no code change needed.",
|
||||
color="info",
|
||||
)
|
||||
return html.Div(f"Export data for {name}: {data}")
|
||||
return _build_person_dashboard(name, data)
|
||||
|
||||
|
||||
@app.callback(
|
||||
|
||||
+139
-14
@@ -1,15 +1,27 @@
|
||||
"""Per-team-member personal LinkedIn export data, starting December 2026. The Page-level
|
||||
exports (Content/Followers/Visitors/Competitors) only cover the TXproducts Company Page -
|
||||
this is for each team member's own personal profile activity (their own post performance,
|
||||
follower growth, etc.), tracked separately starting when that analysis begins.
|
||||
"""Per-team-member personal LinkedIn export data, starting with Mckinley's own export
|
||||
on 2026-10-30 (Aleksandra, Daniel, Niklas, Peter to follow, submitted every Monday).
|
||||
|
||||
Empty until December 2026 - add each person's export data here as it starts coming in,
|
||||
no other code changes needed for it to show up in the Team tab. The exact per-person
|
||||
schema isn't fixed yet (personal-profile exports aren't the same shape as the Page's own
|
||||
exports), so this starts as a simple hand-maintained dict keyed by name; replace a
|
||||
person's entry with whatever structure their real export data turns out to need.
|
||||
The Page-level exports (Content/Followers/Visitors/Competitors) only cover the
|
||||
TXproducts Company Page - this parses each team member's own "Aggregate Analytics"
|
||||
export instead (LinkedIn's personal-profile analytics download: DISCOVERY, ENGAGEMENT,
|
||||
TOP POSTS, FOLLOWERS, AUDIENCE DEMOGRAPHICS, CONTENT DEMOGRAPHICS sheets), named
|
||||
"AggregateAnalytics_<Full Name>_<start>_<end>.xlsx". Each weekly submission is a new
|
||||
~365-day trailing window that heavily overlaps the previous one, so the daily series
|
||||
(ENGAGEMENT, FOLLOWERS) are merged across every file on disk the same way the Page's
|
||||
own Followers export is - newest file wins on an overlapping date - while the
|
||||
point-in-time snapshots (TOP POSTS, both DEMOGRAPHICS sheets, the running follower
|
||||
total) always come from the single newest file.
|
||||
|
||||
TEAM_MEMBERS is the roster shown in the Team tab's dropdown regardless of whether a
|
||||
person has submitted an export yet; load_person_data() returns None for anyone who
|
||||
hasn't, and app.py renders a placeholder for that case.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
import pandas as pd
|
||||
|
||||
TEAM_MEMBERS = [
|
||||
"Peter Gaal",
|
||||
"Daniel Schmidt",
|
||||
@@ -18,10 +30,123 @@ TEAM_MEMBERS = [
|
||||
"Mckinley Black",
|
||||
]
|
||||
|
||||
# "Peter Gaal": {"exports": [...], "notes": "..."},
|
||||
TEAM_EXPORTS = {
|
||||
}
|
||||
|
||||
def _is_personal_export(fname, name):
|
||||
lower = fname.lower()
|
||||
if lower.startswith("~$") or lower.startswith("."):
|
||||
return False
|
||||
if not lower.startswith("aggregateanalytics_"):
|
||||
return False
|
||||
if not lower.endswith((".xlsx", ".xls")):
|
||||
return False
|
||||
return name.lower() in lower
|
||||
|
||||
|
||||
def exports_for(name):
|
||||
return TEAM_EXPORTS.get(name)
|
||||
def _find_person_files(name, 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_personal_export(fname, name):
|
||||
candidates.append(os.path.join(folder, fname))
|
||||
return sorted(set(candidates), key=os.path.getmtime)
|
||||
|
||||
|
||||
def _parse_discovery(fp):
|
||||
raw = pd.read_excel(fp, sheet_name="DISCOVERY", header=None)
|
||||
out = {"date_range": raw.iloc[0, 1] if len(raw) else None}
|
||||
for i in range(1, len(raw)):
|
||||
key, val = raw.iloc[i, 0], raw.iloc[i, 1]
|
||||
if pd.notna(key):
|
||||
out[key] = int(val)
|
||||
return out
|
||||
|
||||
|
||||
def _parse_engagement(fp):
|
||||
df = pd.read_excel(fp, sheet_name="ENGAGEMENT")
|
||||
df["Date"] = pd.to_datetime(df["Date"])
|
||||
df["Impressions"] = pd.to_numeric(df["Impressions"])
|
||||
df["Engagements"] = pd.to_numeric(df["Engagements"])
|
||||
return df
|
||||
|
||||
|
||||
def _parse_followers(fp):
|
||||
raw = pd.read_excel(fp, sheet_name="FOLLOWERS", header=None)
|
||||
total = int(raw.iloc[0, 1])
|
||||
daily = pd.read_excel(fp, sheet_name="FOLLOWERS", header=2)
|
||||
daily["Date"] = pd.to_datetime(daily["Date"])
|
||||
daily["New followers"] = pd.to_numeric(daily["New followers"])
|
||||
return total, daily
|
||||
|
||||
|
||||
_POST_TYPE_RE = re.compile(r"-(share|ugcpost)-\d+-", re.I)
|
||||
|
||||
|
||||
def _post_is_repost(url):
|
||||
"""LinkedIn's own URL convention: .../posts/<slug>-share-<id>-<code> is a repost
|
||||
of someone else's content, .../posts/<slug>-ugcPost-<id>-<code> is an original
|
||||
post - the only way to tell them apart, since "Engagements" doesn't split out
|
||||
which activity type a post is."""
|
||||
m = _POST_TYPE_RE.search(url or "")
|
||||
return m.group(1).lower() == "share" if m else None
|
||||
|
||||
|
||||
def _parse_top_posts(fp):
|
||||
raw = pd.read_excel(fp, sheet_name="TOP POSTS", header=None)
|
||||
body = raw.iloc[3:]
|
||||
eng = body.iloc[:, [0, 1, 2]].dropna(how="all")
|
||||
eng.columns = ["Post URL", "Post Publish Date", "Engagements"]
|
||||
imp = body.iloc[:, [4, 5, 6]].dropna(how="all")
|
||||
imp.columns = ["Post URL", "Post Publish Date", "Impressions"]
|
||||
merged = pd.merge(eng, imp[["Post URL", "Impressions"]], on="Post URL", how="outer")
|
||||
if merged.empty:
|
||||
return merged
|
||||
merged["Post Publish Date"] = pd.to_datetime(merged["Post Publish Date"])
|
||||
merged["Engagements"] = pd.to_numeric(merged["Engagements"])
|
||||
merged["Impressions"] = pd.to_numeric(merged["Impressions"])
|
||||
merged["Is Repost"] = merged["Post URL"].apply(_post_is_repost)
|
||||
return merged.sort_values("Post Publish Date", ascending=False).reset_index(drop=True)
|
||||
|
||||
|
||||
def _parse_demographics(fp, sheet_name):
|
||||
df = pd.read_excel(fp, sheet_name=sheet_name)
|
||||
df["Percentage"] = pd.to_numeric(df["Percentage"].astype(str).str.rstrip("%"))
|
||||
return df
|
||||
|
||||
|
||||
def load_person_data(name, data_dir, upload_dir):
|
||||
files = _find_person_files(name, data_dir, upload_dir)
|
||||
if not files:
|
||||
return None
|
||||
|
||||
eng_frames, fol_frames = [], []
|
||||
for fp in files:
|
||||
mtime = os.path.getmtime(fp)
|
||||
e = _parse_engagement(fp)
|
||||
e["_src"] = mtime
|
||||
eng_frames.append(e)
|
||||
_, daily = _parse_followers(fp)
|
||||
daily["_src"] = mtime
|
||||
fol_frames.append(daily)
|
||||
|
||||
engagement = (pd.concat(eng_frames)
|
||||
.sort_values("_src").drop_duplicates(subset=["Date"], keep="last")
|
||||
.drop(columns=["_src"]).sort_values("Date").reset_index(drop=True))
|
||||
followers_daily = (pd.concat(fol_frames)
|
||||
.sort_values("_src").drop_duplicates(subset=["Date"], keep="last")
|
||||
.drop(columns=["_src"]).sort_values("Date").reset_index(drop=True))
|
||||
|
||||
latest = files[-1]
|
||||
followers_total, _ = _parse_followers(latest)
|
||||
|
||||
return {
|
||||
"discovery": _parse_discovery(latest),
|
||||
"engagement": engagement,
|
||||
"followers_daily": followers_daily,
|
||||
"followers_total": followers_total,
|
||||
"top_posts": _parse_top_posts(latest),
|
||||
"audience_demographics": _parse_demographics(latest, "AUDIENCE DEMOGRAPHICS"),
|
||||
"content_demographics": _parse_demographics(latest, "CONTENT DEMOGRAPHICS"),
|
||||
"source_files": [os.path.basename(fp) for fp in files],
|
||||
}
|
||||
Reference in new issue
Block a user