added post insight feature

This commit is contained in:
2026-02-25 15:07:53 +01:00
parent 7c9866c0a6
commit a3ea774b58
9 changed files with 992 additions and 1 deletions

View File

@@ -0,0 +1,122 @@
-- Migration: Add LinkedIn post insights tables (daily snapshots)
-- Description: Stores scraped post stats separately from linkedin_posts
CREATE TABLE IF NOT EXISTS linkedin_post_insights_posts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
linkedin_account_id UUID REFERENCES linkedin_accounts(id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Identity
post_urn TEXT NOT NULL,
post_url TEXT,
-- Content
post_text TEXT,
post_date TIMESTAMP WITH TIME ZONE,
author_username TEXT,
-- Latest known totals (optional convenience)
total_reactions INTEGER DEFAULT 0,
likes INTEGER DEFAULT 0,
comments INTEGER DEFAULT 0,
shares INTEGER DEFAULT 0,
reactions_breakdown JSONB DEFAULT '{}'::JSONB,
-- Raw data snapshot
raw_data JSONB,
first_seen_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(user_id, post_urn)
);
CREATE TABLE IF NOT EXISTS linkedin_post_insights_daily (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
post_id UUID NOT NULL REFERENCES linkedin_post_insights_posts(id) ON DELETE CASCADE,
snapshot_date DATE NOT NULL,
total_reactions INTEGER DEFAULT 0,
likes INTEGER DEFAULT 0,
comments INTEGER DEFAULT 0,
shares INTEGER DEFAULT 0,
reactions_breakdown JSONB DEFAULT '{}'::JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(post_id, snapshot_date)
);
CREATE INDEX IF NOT EXISTS idx_post_insights_posts_user_id ON linkedin_post_insights_posts(user_id);
CREATE INDEX IF NOT EXISTS idx_post_insights_posts_post_date ON linkedin_post_insights_posts(post_date);
CREATE INDEX IF NOT EXISTS idx_post_insights_daily_user_id ON linkedin_post_insights_daily(user_id);
CREATE INDEX IF NOT EXISTS idx_post_insights_daily_snapshot_date ON linkedin_post_insights_daily(snapshot_date);
-- Triggers for updated_at
CREATE OR REPLACE FUNCTION update_linkedin_post_insights_posts_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS update_linkedin_post_insights_posts_updated_at ON linkedin_post_insights_posts;
CREATE TRIGGER update_linkedin_post_insights_posts_updated_at
BEFORE UPDATE ON linkedin_post_insights_posts
FOR EACH ROW
EXECUTE FUNCTION update_linkedin_post_insights_posts_updated_at();
CREATE OR REPLACE FUNCTION update_linkedin_post_insights_daily_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS update_linkedin_post_insights_daily_updated_at ON linkedin_post_insights_daily;
CREATE TRIGGER update_linkedin_post_insights_daily_updated_at
BEFORE UPDATE ON linkedin_post_insights_daily
FOR EACH ROW
EXECUTE FUNCTION update_linkedin_post_insights_daily_updated_at();
-- Enable RLS
ALTER TABLE linkedin_post_insights_posts ENABLE ROW LEVEL SECURITY;
ALTER TABLE linkedin_post_insights_daily ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own post insights posts"
ON linkedin_post_insights_posts FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Users can insert own post insights posts"
ON linkedin_post_insights_posts FOR INSERT
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own post insights posts"
ON linkedin_post_insights_posts FOR UPDATE
USING (auth.uid() = user_id);
CREATE POLICY "Users can delete own post insights posts"
ON linkedin_post_insights_posts FOR DELETE
USING (auth.uid() = user_id);
CREATE POLICY "Users can view own post insights daily"
ON linkedin_post_insights_daily FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Users can insert own post insights daily"
ON linkedin_post_insights_daily FOR INSERT
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own post insights daily"
ON linkedin_post_insights_daily FOR UPDATE
USING (auth.uid() = user_id);
CREATE POLICY "Users can delete own post insights daily"
ON linkedin_post_insights_daily FOR DELETE
USING (auth.uid() = user_id);

View File

@@ -194,6 +194,81 @@ class DatabaseClient:
) )
return [LinkedInPost(**item) for item in result.data] return [LinkedInPost(**item) for item in result.data]
# ==================== LINKEDIN POST INSIGHTS ====================
async def upsert_post_insights_posts(self, posts: List['LinkedInPostInsightPost']) -> List['LinkedInPostInsightPost']:
"""Upsert LinkedIn post insights posts."""
from datetime import datetime
from src.database.models import LinkedInPostInsightPost
data = []
for p in posts:
post_dict = p.model_dump(exclude={"id", "created_at", "updated_at", "first_seen_at"}, exclude_none=True)
post_dict["user_id"] = str(post_dict["user_id"])
if post_dict.get("linkedin_account_id"):
post_dict["linkedin_account_id"] = str(post_dict["linkedin_account_id"])
if "post_date" in post_dict and isinstance(post_dict["post_date"], datetime):
post_dict["post_date"] = post_dict["post_date"].isoformat()
data.append(post_dict)
if not data:
return []
result = await asyncio.to_thread(
lambda: self.client.table("linkedin_post_insights_posts").upsert(
data,
on_conflict="user_id,post_urn"
).execute()
)
return [LinkedInPostInsightPost(**item) for item in result.data]
async def upsert_post_insights_daily(self, snapshots: List['LinkedInPostInsightDaily']) -> List['LinkedInPostInsightDaily']:
"""Upsert daily snapshots for post insights."""
from src.database.models import LinkedInPostInsightDaily
data = []
for s in snapshots:
snap_dict = s.model_dump(exclude={"id", "created_at", "updated_at"}, exclude_none=True)
snap_dict["user_id"] = str(snap_dict["user_id"])
snap_dict["post_id"] = str(snap_dict["post_id"])
if "snapshot_date" in snap_dict and hasattr(snap_dict["snapshot_date"], "isoformat"):
snap_dict["snapshot_date"] = snap_dict["snapshot_date"].isoformat()
data.append(snap_dict)
if not data:
return []
result = await asyncio.to_thread(
lambda: self.client.table("linkedin_post_insights_daily").upsert(
data,
on_conflict="post_id,snapshot_date"
).execute()
)
return [LinkedInPostInsightDaily(**item) for item in result.data]
async def get_post_insights_posts(self, user_id: UUID) -> List['LinkedInPostInsightPost']:
"""Get all LinkedIn post insights posts for user."""
from src.database.models import LinkedInPostInsightPost
result = await asyncio.to_thread(
lambda: self.client.table("linkedin_post_insights_posts").select("*").eq(
"user_id", str(user_id)
).order("post_date", desc=True).execute()
)
return [LinkedInPostInsightPost(**item) for item in result.data]
async def get_post_insights_daily(self, user_id: UUID, since_date: Optional[str] = None) -> List['LinkedInPostInsightDaily']:
"""Get daily post insights snapshots for user."""
from src.database.models import LinkedInPostInsightDaily
def _query():
q = self.client.table("linkedin_post_insights_daily").select("*").eq("user_id", str(user_id))
if since_date:
q = q.gte("snapshot_date", since_date)
return q.order("snapshot_date", desc=False).execute()
result = await asyncio.to_thread(_query)
return [LinkedInPostInsightDaily(**item) for item in result.data]
async def update_post_classification( async def update_post_classification(
self, self,
post_id: UUID, post_id: UUID,
@@ -768,6 +843,19 @@ class DatabaseClient:
return LinkedInAccount(**result.data[0]) return LinkedInAccount(**result.data[0])
return None return None
async def list_linkedin_accounts(self, active_only: bool = True) -> list['LinkedInAccount']:
"""List LinkedIn accounts (optionally only active ones)."""
from src.database.models import LinkedInAccount
def _query():
q = self.client.table("linkedin_accounts").select("*")
if active_only:
q = q.eq("is_active", True)
return q.execute()
result = await asyncio.to_thread(_query)
return [LinkedInAccount(**item) for item in result.data]
async def create_linkedin_account(self, account: 'LinkedInAccount') -> 'LinkedInAccount': async def create_linkedin_account(self, account: 'LinkedInAccount') -> 'LinkedInAccount':
"""Create LinkedIn account connection.""" """Create LinkedIn account connection."""
from src.database.models import LinkedInAccount from src.database.models import LinkedInAccount

View File

@@ -289,6 +289,45 @@ class LinkedInPost(DBModel):
classification_confidence: Optional[float] = None classification_confidence: Optional[float] = None
class LinkedInPostInsightPost(DBModel):
"""LinkedIn post record for insights (separate from linkedin_posts)."""
id: Optional[UUID] = None
user_id: UUID
linkedin_account_id: Optional[UUID] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
post_urn: str
post_url: Optional[str] = None
post_text: Optional[str] = None
post_date: Optional[datetime] = None
author_username: Optional[str] = None
total_reactions: int = 0
likes: int = 0
comments: int = 0
shares: int = 0
reactions_breakdown: Dict[str, Any] = Field(default_factory=dict)
raw_data: Optional[Dict[str, Any]] = None
first_seen_at: Optional[datetime] = None
class LinkedInPostInsightDaily(DBModel):
"""Daily snapshot of LinkedIn post insights."""
id: Optional[UUID] = None
user_id: UUID
post_id: UUID
snapshot_date: date
total_reactions: int = 0
likes: int = 0
comments: int = 0
shares: int = 0
reactions_breakdown: Dict[str, Any] = Field(default_factory=dict)
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Topic(DBModel): class Topic(DBModel):
"""Topic model.""" """Topic model."""
id: Optional[UUID] = None id: Optional[UUID] = None

View File

@@ -0,0 +1,409 @@
"""Post insights scraping and aggregation service."""
from __future__ import annotations
from collections import defaultdict
from datetime import datetime, date, timezone, timedelta
from typing import Any, Dict, List, Optional, Tuple
from uuid import UUID
from loguru import logger
from src.database.models import LinkedInPostInsightPost, LinkedInPostInsightDaily
from src.scraper import scraper
def _parse_post_date(raw_post: Dict[str, Any]) -> Optional[datetime]:
posted_at = raw_post.get("posted_at")
if isinstance(posted_at, dict):
date_str = posted_at.get("date")
ts = posted_at.get("timestamp")
if date_str:
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
try:
return datetime.strptime(date_str, fmt)
except ValueError:
continue
try:
return datetime.fromisoformat(date_str)
except ValueError:
return None
if ts:
try:
return datetime.fromtimestamp(int(ts) / 1000, tz=timezone.utc)
except Exception:
return None
return None
def _extract_post_urn(raw_post: Dict[str, Any]) -> Optional[str]:
if raw_post.get("full_urn"):
return raw_post["full_urn"]
urn_obj = raw_post.get("urn") or {}
for key, prefix in (("activity_urn", "urn:li:activity:"), ("share_urn", "urn:li:share:"), ("ugcPost_urn", "urn:li:ugcPost:")):
val = urn_obj.get(key)
if val:
if str(val).startswith("urn:li:"):
return str(val)
return f"{prefix}{val}"
return None
def _extract_reactions(stats: Dict[str, Any]) -> Tuple[int, Dict[str, int]]:
if not stats:
return 0, {}
breakdown = {
"like": int(stats.get("like", 0) or 0),
"love": int(stats.get("love", 0) or 0),
"insight": int(stats.get("insight", 0) or 0),
"celebrate": int(stats.get("celebrate", 0) or 0),
"support": int(stats.get("support", 0) or 0),
"funny": int(stats.get("funny", 0) or 0),
}
total = stats.get("total_reactions")
if total is None:
total = sum(breakdown.values())
return int(total or 0), breakdown
def _extract_username_from_url(linkedin_url: Optional[str]) -> Optional[str]:
if not linkedin_url:
return None
import re
url = linkedin_url.rstrip("/")
match = re.search(r"/in/([^/?#]+)", url)
if match:
return match.group(1)
return None
def parse_apify_posts_for_insights(
raw_posts: List[Dict[str, Any]],
user_id: str,
linkedin_account_id: Optional[str]
) -> Tuple[List[LinkedInPostInsightPost], List[Dict[str, Any]]]:
"""Parse Apify posts into insights models plus raw metadata for snapshots."""
parsed: List[LinkedInPostInsightPost] = []
snapshot_meta: List[Dict[str, Any]] = []
for post in raw_posts:
post_urn = _extract_post_urn(post)
if not post_urn:
continue
stats = post.get("stats") or {}
total_reactions, breakdown = _extract_reactions(stats)
parsed_post = LinkedInPostInsightPost(
user_id=user_id,
linkedin_account_id=linkedin_account_id,
post_urn=post_urn,
post_url=post.get("url"),
post_text=post.get("text"),
post_date=_parse_post_date(post),
author_username=(post.get("author") or {}).get("username"),
total_reactions=total_reactions,
likes=int(stats.get("like", 0) or 0),
comments=int(stats.get("comments", 0) or 0),
shares=int(stats.get("reposts", 0) or 0),
reactions_breakdown=breakdown,
raw_data=post,
)
parsed.append(parsed_post)
snapshot_meta.append(
{
"post_urn": post_urn,
"total_reactions": total_reactions,
"likes": int(stats.get("like", 0) or 0),
"comments": int(stats.get("comments", 0) or 0),
"shares": int(stats.get("reposts", 0) or 0),
"reactions_breakdown": breakdown,
}
)
return parsed, snapshot_meta
async def refresh_post_insights_for_account(db, account) -> None:
"""Scrape posts for a connected LinkedIn account and store insights snapshots."""
vanity_name = None
try:
profile = await db.get_profile(account.user_id)
vanity_name = _extract_username_from_url(profile.linkedin_url if profile else None)
except Exception:
vanity_name = None
if not vanity_name:
logger.warning(f"LinkedIn account {account.id} missing profile link username; skipping insights scrape.")
return
linkedin_url = f"https://www.linkedin.com/in/{vanity_name}/"
logger.info(f"Scraping post insights for user {account.user_id} ({vanity_name})")
raw_posts = await scraper.scrape_posts(linkedin_url, limit=50)
if not raw_posts:
logger.info(f"No posts found for user {account.user_id}")
return
posts, snapshot_meta = parse_apify_posts_for_insights(
raw_posts,
user_id=str(account.user_id),
linkedin_account_id=str(account.id),
)
if not posts:
logger.info(f"No parsable posts for user {account.user_id}")
return
saved_posts = await db.upsert_post_insights_posts(posts)
post_id_by_urn = {p.post_urn: p.id for p in saved_posts}
today = date.today()
snapshots: List[LinkedInPostInsightDaily] = []
for meta in snapshot_meta:
post_id = post_id_by_urn.get(meta["post_urn"])
if not post_id:
continue
snapshots.append(
LinkedInPostInsightDaily(
user_id=account.user_id,
post_id=post_id,
snapshot_date=today,
total_reactions=meta["total_reactions"],
likes=meta["likes"],
comments=meta["comments"],
shares=meta["shares"],
reactions_breakdown=meta["reactions_breakdown"],
)
)
if snapshots:
await db.upsert_post_insights_daily(snapshots)
logger.info(f"Saved {len(snapshots)} post insight snapshots for user {account.user_id}")
async def refresh_all_post_insights(db) -> None:
"""Run daily refresh for all active LinkedIn accounts."""
accounts = await db.list_linkedin_accounts(active_only=True)
if not accounts:
return
for account in accounts:
try:
await refresh_post_insights_for_account(db, account)
except Exception as exc:
logger.error(f"Post insights refresh failed for {account.id}: {exc}")
def _word_count(text: Optional[str]) -> int:
if not text:
return 0
return len([w for w in text.split() if w.strip()])
def _to_naive(dt: datetime) -> datetime:
if dt.tzinfo is not None:
return dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt
def compute_post_insights(posts: List[LinkedInPostInsightPost], daily: List[LinkedInPostInsightDaily]) -> Dict[str, Any]:
"""Compute aggregated post insights for UI rendering."""
if not posts:
return {"has_data": False}
posts_by_id = {p.id: p for p in posts if p.id}
latest_by_post: Dict[str, LinkedInPostInsightDaily] = {}
daily_by_post: Dict[str, List[LinkedInPostInsightDaily]] = defaultdict(list)
for snap in daily:
if not snap.post_id:
continue
daily_by_post[str(snap.post_id)].append(snap)
current = latest_by_post.get(str(snap.post_id))
if not current or snap.snapshot_date > current.snapshot_date:
latest_by_post[str(snap.post_id)] = snap
# Aggregate totals using latest snapshot per post
total_posts = len(posts_by_id)
total_reactions = 0
total_likes = 0
total_comments = 0
total_shares = 0
reaction_mix = defaultdict(int)
for post_id, snap in latest_by_post.items():
total_reactions += snap.total_reactions or 0
total_likes += snap.likes or 0
total_comments += snap.comments or 0
total_shares += snap.shares or 0
for k, v in (snap.reactions_breakdown or {}).items():
reaction_mix[k] += int(v or 0)
avg_reactions = round(total_reactions / total_posts, 2) if total_posts else 0
avg_likes = round(total_likes / total_posts, 2) if total_posts else 0
avg_comments = round(total_comments / total_posts, 2) if total_posts else 0
avg_shares = round(total_shares / total_posts, 2) if total_posts else 0
# Daily deltas (last 30 days)
delta_by_date = defaultdict(lambda: {"reactions": 0, "likes": 0, "comments": 0, "shares": 0})
for post_id, snaps in daily_by_post.items():
snaps_sorted = sorted(snaps, key=lambda s: s.snapshot_date)
prev = None
for snap in snaps_sorted:
if prev:
day = snap.snapshot_date
delta_by_date[day]["reactions"] += max(0, (snap.total_reactions or 0) - (prev.total_reactions or 0))
delta_by_date[day]["likes"] += max(0, (snap.likes or 0) - (prev.likes or 0))
delta_by_date[day]["comments"] += max(0, (snap.comments or 0) - (prev.comments or 0))
delta_by_date[day]["shares"] += max(0, (snap.shares or 0) - (prev.shares or 0))
prev = snap
today = date.today()
last_7_days = [today - timedelta(days=i) for i in range(6, -1, -1)]
series = []
for day in last_7_days:
delta = delta_by_date.get(day, {"reactions": 0, "likes": 0, "comments": 0, "shares": 0})
series.append({"date": day, **delta})
max_reactions = max([d["reactions"] for d in series], default=0) or 1
last_7 = sum(d["reactions"] for d in series[-7:])
prev_7 = sum(d["reactions"] for d in series[-14:-7]) if len(series) >= 14 else 0
trend_pct = None
if prev_7:
trend_pct = round(((last_7 - prev_7) / prev_7) * 100, 1)
# Top posts by engagement score
top_posts = []
for post_id, snap in latest_by_post.items():
post = None
if post_id:
try:
post = posts_by_id.get(UUID(post_id))
except ValueError:
post = None
if not post:
continue
score = (snap.likes or 0) + (snap.comments or 0) * 2 + (snap.shares or 0) * 3
top_posts.append(
{
"post_url": post.post_url,
"post_date": post.post_date,
"text": post.post_text or "",
"likes": snap.likes or 0,
"comments": snap.comments or 0,
"shares": snap.shares or 0,
"total_reactions": snap.total_reactions or 0,
"engagement_score": score,
}
)
top_posts = sorted(top_posts, key=lambda x: x["engagement_score"], reverse=True)[:5]
# Posting cadence and weekday performance
weekday_stats = defaultdict(lambda: {"count": 0, "engagement": 0})
post_dates = []
for post in posts_by_id.values():
if not post.post_date:
continue
post_dates.append(_to_naive(post.post_date))
snap = latest_by_post.get(str(post.id))
score = 0
if snap:
score = (snap.likes or 0) + (snap.comments or 0) * 2 + (snap.shares or 0) * 3
weekday = post.post_date.strftime("%A")
weekday_stats[weekday]["count"] += 1
weekday_stats[weekday]["engagement"] += score
best_weekday = None
if weekday_stats:
best_weekday = max(
weekday_stats.items(),
key=lambda kv: (kv[1]["engagement"] / kv[1]["count"]) if kv[1]["count"] else 0
)[0]
weekday_breakdown = []
if weekday_stats:
order = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
for day in order:
stats = weekday_stats.get(day, {"count": 0, "engagement": 0})
avg = round(stats["engagement"] / stats["count"], 2) if stats["count"] else 0
weekday_breakdown.append({"day": day, "count": stats["count"], "avg_engagement": avg})
cadence_per_week = None
if post_dates:
earliest = min(post_dates)
weeks = max(1, int((datetime.now() - earliest).days / 7))
cadence_per_week = round(len(post_dates) / weeks, 2)
# Length buckets
length_buckets = {
"0-100": {"count": 0, "avg_engagement": 0},
"101-200": {"count": 0, "avg_engagement": 0},
"201-400": {"count": 0, "avg_engagement": 0},
"400+": {"count": 0, "avg_engagement": 0},
}
for post in posts_by_id.values():
wc = _word_count(post.post_text)
snap = latest_by_post.get(str(post.id))
score = 0
if snap:
score = (snap.likes or 0) + (snap.comments or 0) * 2 + (snap.shares or 0) * 3
if wc <= 100:
bucket = "0-100"
elif wc <= 200:
bucket = "101-200"
elif wc <= 400:
bucket = "201-400"
else:
bucket = "400+"
length_buckets[bucket]["count"] += 1
length_buckets[bucket]["avg_engagement"] += score
for bucket in length_buckets.values():
if bucket["count"]:
bucket["avg_engagement"] = round(bucket["avg_engagement"] / bucket["count"], 2)
latest_snapshot_date = None
if daily:
latest_snapshot_date = max(s.snapshot_date for s in daily)
reaction_mix_total = sum(reaction_mix.values()) or 1
reaction_mix_pct = [
{
"name": k,
"count": v,
"pct": round((v / reaction_mix_total) * 100, 1),
}
for k, v in sorted(reaction_mix.items(), key=lambda x: x[1], reverse=True)
]
series_chart = [
{
"date": item["date"].isoformat(),
"reactions": item["reactions"],
"likes": item["likes"],
"comments": item["comments"],
"shares": item["shares"],
}
for item in series
]
return {
"has_data": True,
"total_posts": total_posts,
"total_reactions": total_reactions,
"avg_reactions": avg_reactions,
"avg_likes": avg_likes,
"avg_comments": avg_comments,
"avg_shares": avg_shares,
"last_7_reactions": last_7,
"prev_7_reactions": prev_7,
"trend_pct": trend_pct,
"best_weekday": best_weekday,
"weekday_breakdown": weekday_breakdown,
"cadence_per_week": cadence_per_week,
"reaction_mix": reaction_mix_pct,
"series": series,
"series_chart": series_chart,
"series_max": max_reactions,
"top_posts": top_posts,
"length_buckets": length_buckets,
"latest_snapshot_date": latest_snapshot_date,
}

View File

@@ -71,6 +71,11 @@ class SchedulerService:
await self.db.cleanup_expired_email_tokens() await self.db.cleanup_expired_email_tokens()
except Exception as e: except Exception as e:
logger.error(f"Email token cleanup error: {e}") logger.error(f"Email token cleanup error: {e}")
try:
from src.services.post_insights_service import refresh_all_post_insights
await refresh_all_post_insights(self.db)
except Exception as e:
logger.error(f"Post insights daily job error: {e}")
_tick += 1 _tick += 1
await asyncio.sleep(self.check_interval) await asyncio.sleep(self.check_interval)

View File

@@ -160,6 +160,15 @@
<span class="sidebar-text">Unternehmensstrategie</span> <span class="sidebar-text">Unternehmensstrategie</span>
</a> </a>
{% endif %} {% endif %}
{% if session and session.account_type == 'employee' %}
<a href="/employee/insights" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'insights' %}active{% endif %}">
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
<span class="sidebar-text">Post Insights</span>
<span class="ml-auto text-[10px] font-semibold px-2 py-0.5 rounded-full bg-brand-highlight text-brand-bg-dark sidebar-text">Neu</span>
</a>
{% endif %}
</nav> </nav>
<div class="p-4 border-t border-gray-600 space-y-2"> <div class="p-4 border-t border-gray-600 space-y-2">

View File

@@ -6,6 +6,7 @@
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<h1 class="text-2xl font-bold text-white mb-6">Willkommen, {{ session.linkedin_name or session.customer_name or 'Mitarbeiter' }}!</h1> <h1 class="text-2xl font-bold text-white mb-6">Willkommen, {{ session.linkedin_name or session.customer_name or 'Mitarbeiter' }}!</h1>
<div>
<!-- Company Info Banner --> <!-- Company Info Banner -->
{% if session.company_name %} {% if session.company_name %}
<div class="bg-brand-highlight/10 border border-brand-highlight/30 rounded-xl p-6 mb-8"> <div class="bg-brand-highlight/10 border border-brand-highlight/30 rounded-xl p-6 mb-8">
@@ -143,5 +144,6 @@
<a href="/create" class="inline-block mt-4 text-brand-highlight hover:underline">Ersten Post erstellen</a> <a href="/create" class="inline-block mt-4 text-brand-highlight hover:underline">Ersten Post erstellen</a>
</div> </div>
{% endif %} {% endif %}
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,237 @@
{% extends "base.html" %}
{% block title %}Post Insights{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-white">Post Insights</h1>
<p class="text-gray-400 text-sm mt-1">Tägliche Auswertung deiner LinkedIn-Posts</p>
</div>
<button id="insights-refresh-btn" class="px-4 py-2 rounded-lg text-sm font-medium bg-brand-highlight/20 text-brand-highlight hover:bg-brand-highlight/30 transition-colors">
Jetzt aktualisieren
</button>
</div>
{% if not linkedin_account %}
<div class="card-bg border rounded-xl p-6 text-center">
<p class="text-gray-300 mb-2">Verbinde deinen LinkedIn Account, damit wir täglich Post-Insights aktualisieren können.</p>
<a href="/settings" class="inline-block mt-2 text-brand-highlight hover:underline">Zum LinkedIn Login</a>
</div>
{% elif not post_insights or not post_insights.has_data %}
<div class="card-bg border rounded-xl p-6 text-center">
<p class="text-gray-300">Noch keine Insights vorhanden. Der tägliche Import läuft in den nächsten 24 Stunden.</p>
</div>
{% else %}
<div class="grid md:grid-cols-3 gap-6 mb-8">
<div class="card-bg border rounded-xl p-6">
<p class="text-gray-400 text-sm">Posts getrackt</p>
<p class="text-3xl font-bold text-white">{{ post_insights.total_posts }}</p>
</div>
<div class="card-bg border rounded-xl p-6">
<p class="text-gray-400 text-sm">Ø Reaktionen/Post</p>
<p class="text-3xl font-bold text-white">{{ post_insights.avg_reactions }}</p>
<p class="text-xs text-gray-500 mt-1">Likes {{ post_insights.avg_likes }} · Comments {{ post_insights.avg_comments }} · Shares {{ post_insights.avg_shares }}</p>
</div>
<div class="card-bg border rounded-xl p-6">
<p class="text-gray-400 text-sm">Letzte 7 Tage</p>
<p class="text-3xl font-bold text-white">{{ post_insights.last_7_reactions }}</p>
{% if post_insights.trend_pct is not none %}
<p class="text-xs mt-1 {% if post_insights.trend_pct >= 0 %}text-green-400{% else %}text-red-400{% endif %}">
{% if post_insights.trend_pct >= 0 %}+{% endif %}{{ post_insights.trend_pct }}% vs. Vorwoche
</p>
{% endif %}
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Engagement-Entwicklung (7 Tage)</h2>
<div id="chart-engagement"></div>
</div>
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Reaktions-Mix</h2>
<div id="chart-reactions"></div>
</div>
</div>
<div class="grid md:grid-cols-2 gap-6 mb-8">
<div class="card-bg border rounded-xl p-6">
<h3 class="text-lg font-semibold text-white mb-4">Wochentag-Performance</h3>
<div id="chart-weekday"></div>
</div>
<div class="card-bg border rounded-xl p-6">
<h3 class="text-lg font-semibold text-white mb-4">Performance-Driver</h3>
<div class="text-sm text-gray-400 space-y-3">
<div class="flex items-center justify-between">
<span>Bester Wochentag</span>
<span class="text-gray-200">{{ post_insights.best_weekday or 'N/A' }}</span>
</div>
<div class="flex items-center justify-between">
<span>Posting-Kadenz</span>
<span class="text-gray-200">{{ post_insights.cadence_per_week or 'N/A' }} Posts/Woche</span>
</div>
<div class="flex items-center justify-between">
<span>Letzter Snapshot</span>
<span class="text-gray-200">{{ post_insights.latest_snapshot_date.strftime('%d.%m.%Y') if post_insights.latest_snapshot_date else 'N/A' }}</span>
</div>
</div>
</div>
</div>
<div class="card-bg border rounded-xl p-6 mb-8">
<h2 class="text-lg font-semibold text-white mb-4">Top Posts (Engagement)</h2>
<div class="space-y-3">
{% for post in post_insights.top_posts %}
<div class="p-4 bg-brand-bg-dark rounded-lg">
<p class="text-white text-sm line-clamp-2">{{ post.text[:180] }}{% if post.text|length > 180 %}...{% endif %}</p>
<div class="text-xs text-gray-500 mt-2 flex items-center gap-3">
<span>{{ post.post_date.strftime('%d.%m.%Y') if post.post_date else 'N/A' }}</span>
<span>Likes {{ post.likes }}</span>
<span>Comments {{ post.comments }}</span>
<span>Shares {{ post.shares }}</span>
<span>Score {{ post.engagement_score }}</span>
{% if post.post_url %}
<a href="{{ post.post_url }}" target="_blank" class="text-brand-highlight hover:underline">Öffnen</a>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Post-Länge vs. Engagement</h2>
<div id="chart-length"></div>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const refreshBtn = document.getElementById('insights-refresh-btn');
if (refreshBtn) {
refreshBtn.addEventListener('click', async () => {
refreshBtn.disabled = true;
refreshBtn.textContent = 'Aktualisiere...';
try {
const res = await fetch('/api/insights/refresh', { method: 'POST' });
if (res.ok) {
refreshBtn.textContent = 'Aktualisiert (heute)';
setTimeout(() => window.location.reload(), 800);
} else if (res.status === 429) {
refreshBtn.textContent = 'Heute bereits genutzt';
} else if (res.status === 400) {
refreshBtn.textContent = 'Kein LinkedIn Login';
} else {
refreshBtn.textContent = 'Fehler';
}
} catch (e) {
refreshBtn.textContent = 'Fehler';
} finally {
setTimeout(() => { refreshBtn.disabled = false; }, 2000);
}
});
}
{% if post_insights and post_insights.has_data %}
const seriesData = {{ post_insights.series_chart | tojson }};
const reactionMix = {{ post_insights.reaction_mix | tojson }};
const weekdayBreakdown = {{ post_insights.weekday_breakdown | tojson }};
const lengthBuckets = {{ post_insights.length_buckets | tojson }};
if (seriesData && seriesData.length) {
const dates = seriesData.map(d => d.date.slice(5, 10));
const reactions = seriesData.map(d => d.reactions);
const likes = seriesData.map(d => d.likes);
const comments = seriesData.map(d => d.comments);
const shares = seriesData.map(d => d.shares);
const engagementChart = new ApexCharts(document.querySelector('#chart-engagement'), {
chart: { type: 'area', height: 260, toolbar: { show: false }, foreColor: '#e5e7eb', background: 'transparent' },
stroke: { curve: 'smooth', width: 2 },
dataLabels: { enabled: false },
colors: ['#e94560', '#60a5fa', '#f59e0b', '#10b981'],
series: [
{ name: 'Reaktionen', data: reactions },
{ name: 'Likes', data: likes },
{ name: 'Comments', data: comments },
{ name: 'Shares', data: shares }
],
xaxis: { categories: dates, labels: { style: { colors: '#9ca3af' } } },
yaxis: { labels: { style: { colors: '#9ca3af' } } },
grid: { borderColor: 'rgba(233,69,96,0.12)' },
legend: { labels: { colors: '#e5e7eb' } },
fill: { type: 'gradient', gradient: { opacityFrom: 0.35, opacityTo: 0.05 } },
tooltip: { theme: 'dark' }
});
engagementChart.render();
}
if (reactionMix && reactionMix.length) {
const labels = reactionMix.map(r => r.name);
const values = reactionMix.map(r => r.count);
const reactionsChart = new ApexCharts(document.querySelector('#chart-reactions'), {
chart: { type: 'donut', height: 260, foreColor: '#e5e7eb', background: 'transparent' },
labels,
series: values,
legend: { labels: { colors: '#e5e7eb' } },
dataLabels: { style: { colors: ['#111827'] } },
colors: ['#e94560', '#60a5fa', '#f59e0b', '#10b981', '#a855f7', '#f97316'],
stroke: { colors: ['#1a1a2e'] }
});
reactionsChart.render();
}
if (weekdayBreakdown && weekdayBreakdown.length) {
const dayLabels = weekdayBreakdown.map(d => d.day.slice(0, 3));
const avgEngagement = weekdayBreakdown.map(d => d.avg_engagement);
const counts = weekdayBreakdown.map(d => d.count);
const weekdayChart = new ApexCharts(document.querySelector('#chart-weekday'), {
chart: { type: 'bar', height: 240, toolbar: { show: false }, foreColor: '#e5e7eb', background: 'transparent' },
series: [
{ name: 'Ø Engagement', data: avgEngagement },
{ name: 'Posts', data: counts }
],
plotOptions: { bar: { columnWidth: '45%', borderRadius: 4 } },
xaxis: { categories: dayLabels, labels: { style: { colors: '#9ca3af' } } },
yaxis: { labels: { style: { colors: '#9ca3af' } } },
grid: { borderColor: 'rgba(233,69,96,0.12)' },
colors: ['#e94560', '#60a5fa'],
legend: { labels: { colors: '#e5e7eb' } },
tooltip: { theme: 'dark' }
});
weekdayChart.render();
}
if (lengthBuckets) {
const bucketLabels = Object.keys(lengthBuckets);
const counts = bucketLabels.map(k => lengthBuckets[k].count || 0);
const avgScores = bucketLabels.map(k => lengthBuckets[k].avg_engagement || 0);
const lengthChart = new ApexCharts(document.querySelector('#chart-length'), {
chart: { type: 'bar', height: 240, toolbar: { show: false }, foreColor: '#e5e7eb', background: 'transparent' },
series: [
{ name: 'Posts', data: counts },
{ name: 'Ø Score', data: avgScores }
],
plotOptions: { bar: { columnWidth: '45%', borderRadius: 4 } },
xaxis: { categories: bucketLabels, labels: { style: { colors: '#9ca3af' } } },
yaxis: { labels: { style: { colors: '#9ca3af' } } },
grid: { borderColor: 'rgba(233,69,96,0.12)' },
colors: ['#10b981', '#f59e0b'],
legend: { labels: { colors: '#e5e7eb' } },
tooltip: { theme: 'dark' }
});
lengthChart.render();
}
{% endif %}
});
</script>
{% endblock %}

View File

@@ -44,6 +44,7 @@ from src.services.storage_service import storage
from src.services.link_extractor import LinkExtractor, LinkExtractionError from src.services.link_extractor import LinkExtractor, LinkExtractionError
from src.services.file_extractor import FileExtractor, FileExtractionError from src.services.file_extractor import FileExtractor, FileExtractionError
from src.agents.link_topic_builder import LinkTopicBuilderAgent from src.agents.link_topic_builder import LinkTopicBuilderAgent
from src.services.post_insights_service import compute_post_insights, refresh_post_insights_for_account
# Router for user frontend # Router for user frontend
user_router = APIRouter(tags=["user"]) user_router = APIRouter(tags=["user"])
@@ -2928,7 +2929,7 @@ async def linkedin_callback(
linkedin_vanity_name = None linkedin_vanity_name = None
try: try:
profile_response = await client.get( profile_response = await client.get(
"https://api.linkedin.com/v2/me", "https://api.linkedin.com/v2/me?projection=(id,vanityName,localizedFirstName,localizedLastName)",
headers={"Authorization": f"Bearer {access_token}"} headers={"Authorization": f"Bearer {access_token}"}
) )
if profile_response.status_code == 200: if profile_response.status_code == 200:
@@ -3016,6 +3017,41 @@ async def linkedin_disconnect(request: Request):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# ==================== POST INSIGHTS ====================
@user_router.post("/api/insights/refresh")
async def refresh_post_insights(request: Request):
"""Manually refresh post insights (max once per day)."""
session = require_user_session(request)
if not session:
raise HTTPException(status_code=401, detail="Not authenticated")
try:
user_id = UUID(session.user_id)
linkedin_account = await db.get_linkedin_account(user_id)
if not linkedin_account:
raise HTTPException(status_code=400, detail="LinkedIn account not connected")
profile = await db.get_profile(user_id)
metadata = profile.metadata or {}
today = datetime.now(timezone.utc).date().isoformat()
last_refresh = metadata.get("post_insights_manual_refresh_date")
if last_refresh == today:
raise HTTPException(status_code=429, detail="Manual refresh already used today")
await refresh_post_insights_for_account(db, linkedin_account)
metadata["post_insights_manual_refresh_date"] = today
await db.update_profile(user_id, {"metadata": metadata})
return {"success": True, "refreshed_at": today}
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to refresh post insights: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ==================== COMPANY MANAGEMENT ENDPOINTS ==================== # ==================== COMPANY MANAGEMENT ENDPOINTS ====================
@user_router.get("/company/strategy", response_class=HTMLResponse) @user_router.get("/company/strategy", response_class=HTMLResponse)
@@ -3579,6 +3615,50 @@ async def employee_strategy_page(request: Request):
}) })
@user_router.get("/employee/insights", response_class=HTMLResponse)
async def employee_insights_page(request: Request):
"""Employee post insights page."""
session = require_user_session(request)
if not session:
return RedirectResponse(url="/login", status_code=302)
if session.account_type != "employee":
return RedirectResponse(url="/", status_code=302)
try:
user_id = UUID(session.user_id)
profile_picture = await get_user_avatar(session, user_id)
linkedin_account = await db.get_linkedin_account(user_id)
post_insights = {"has_data": False}
if linkedin_account:
try:
from datetime import date, timedelta
since = (date.today() - timedelta(days=90)).isoformat()
insights_posts = await db.get_post_insights_posts(user_id)
insights_daily = await db.get_post_insights_daily(user_id, since_date=since)
post_insights = compute_post_insights(insights_posts, insights_daily)
except Exception as e:
logger.error(f"Error computing post insights: {e}")
return templates.TemplateResponse("employee_insights.html", {
"request": request,
"page": "insights",
"session": session,
"profile_picture": profile_picture,
"linkedin_account": linkedin_account,
"post_insights": post_insights
})
except Exception as e:
logger.error(f"Error loading insights: {e}")
return templates.TemplateResponse("employee_insights.html", {
"request": request,
"page": "insights",
"session": session,
"error": str(e)
})
# ============================================================================ # ============================================================================
# EMPLOYEE POST TYPES MANAGEMENT # EMPLOYEE POST TYPES MANAGEMENT
# ============================================================================ # ============================================================================