added post insight feature
This commit is contained in:
122
config/migrate_add_post_insights.sql
Normal file
122
config/migrate_add_post_insights.sql
Normal 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);
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
409
src/services/post_insights_service.py
Normal file
409
src/services/post_insights_service.py
Normal 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,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
237
src/web/templates/user/employee_insights.html
Normal file
237
src/web/templates/user/employee_insights.html
Normal 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 %}
|
||||||
@@ -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
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user