feat: fetch all Facebook Graph API data and display on social audit card
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

- Page info: description, emails, hours, location, ratings, check-ins, founded, mission
- Post stats: likes, comments, shares per post, post types, last 5 posts with titles
- Insights: impressions, page views, engagements, fan adds/removes, reactions
- Rich card display: contact info, ratings, engagement badges, insights panel, recent posts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-12 12:38:35 +01:00
parent f8dacd264f
commit 10b116cd6a
2 changed files with 174 additions and 30 deletions

View File

@ -47,17 +47,24 @@ class FacebookGraphService:
def get_page_info(self, page_id: str) -> Optional[Dict]:
"""Get detailed page information."""
return self._get(page_id, {
'fields': 'id,name,fan_count,category,link,about,website,phone,single_line_address,followers_count,picture,cover'
'fields': 'id,name,fan_count,category,link,about,description,website,phone,emails,'
'single_line_address,location,hours,followers_count,picture,cover,'
'rating_count,overall_star_rating,were_here_count,founded,mission'
})
def get_page_posts_stats(self, page_id: str) -> Dict:
"""Get post counts and last post date from page feed."""
result = {'posts_30d': 0, 'posts_365d': 0, 'last_post_date': None}
"""Get post counts, engagement stats and last post date from page feed."""
result = {
'posts_30d': 0, 'posts_365d': 0, 'last_post_date': None,
'total_likes': 0, 'total_comments': 0, 'total_shares': 0,
'post_types': {}, # e.g. {'photo': 5, 'video': 2, 'link': 3}
'recent_posts': [], # last 5 posts with title/type/date/engagement
}
now = datetime.now()
since_365d = int((now - timedelta(days=365)).timestamp())
data = self._get(f'{page_id}/feed', {
'fields': 'created_time',
'fields': 'created_time,message,type,shares,likes.summary(true),comments.summary(true)',
'since': since_365d,
'limit': 100,
})
@ -68,17 +75,46 @@ class FacebookGraphService:
cutoff_30d = now - timedelta(days=30)
for post in posts:
ct = post.get('created_time', '')
if ct:
try:
post_date = datetime.fromisoformat(ct.replace('+0000', '+00:00').replace('Z', '+00:00'))
post_date_naive = post_date.replace(tzinfo=None)
result['posts_365d'] += 1
if post_date_naive >= cutoff_30d:
result['posts_30d'] += 1
if result['last_post_date'] is None or post_date_naive > result['last_post_date']:
result['last_post_date'] = post_date_naive
except (ValueError, TypeError):
result['posts_365d'] += 1
if not ct:
continue
try:
post_date = datetime.fromisoformat(ct.replace('+0000', '+00:00').replace('Z', '+00:00'))
post_date_naive = post_date.replace(tzinfo=None)
except (ValueError, TypeError):
result['posts_365d'] += 1
continue
result['posts_365d'] += 1
if post_date_naive >= cutoff_30d:
result['posts_30d'] += 1
if result['last_post_date'] is None or post_date_naive > result['last_post_date']:
result['last_post_date'] = post_date_naive
# Engagement
likes = post.get('likes', {}).get('summary', {}).get('total_count', 0)
comments = post.get('comments', {}).get('summary', {}).get('total_count', 0)
shares = post.get('shares', {}).get('count', 0)
result['total_likes'] += likes
result['total_comments'] += comments
result['total_shares'] += shares
# Post types
ptype = post.get('type', 'status')
result['post_types'][ptype] = result['post_types'].get(ptype, 0) + 1
# Recent posts (top 5 by date)
if len(result['recent_posts']) < 5:
msg = post.get('message', '')
title = (msg[:80] + '...') if len(msg) > 80 else msg
result['recent_posts'].append({
'date': post_date_naive.strftime('%Y-%m-%d'),
'title': title,
'type': ptype,
'likes': likes,
'comments': comments,
'shares': shares,
})
return result
def get_page_insights(self, page_id: str, days: int = 28) -> Dict:
@ -90,7 +126,7 @@ class FacebookGraphService:
since = datetime.now() - timedelta(days=days)
until = datetime.now()
metrics = 'page_impressions,page_engaged_users,page_fans,page_views_total'
metrics = 'page_impressions,page_engaged_users,page_fans,page_views_total,page_post_engagements,page_fan_adds,page_fan_removes,page_actions_post_reactions_total'
data = self._get(f'{page_id}/insights', {
'metric': metrics,
'period': 'day',
@ -506,9 +542,16 @@ def sync_facebook_to_social_media(db, company_id: int) -> dict:
csm.check_status = 'ok'
csm.page_name = page_info.get('name', '')
csm.followers_count = followers if followers > 0 else csm.followers_count
csm.has_bio = bool(page_info.get('about'))
csm.profile_description = (page_info.get('about') or '')[:500]
if engagement_rate is not None:
csm.has_bio = bool(page_info.get('about') or page_info.get('description'))
# Prefer 'about' (short), fallback to 'description' (long)
bio_text = page_info.get('about') or page_info.get('description') or ''
csm.profile_description = bio_text[:500]
# Engagement rate: prefer per-post calculation over insights
if post_stats.get('posts_365d', 0) > 0 and followers > 0:
total_engagement = post_stats['total_likes'] + post_stats['total_comments'] + post_stats['total_shares']
avg_engagement_per_post = total_engagement / post_stats['posts_365d']
csm.engagement_rate = round((avg_engagement_per_post / followers) * 100, 2)
elif engagement_rate is not None:
csm.engagement_rate = engagement_rate
csm.profile_completeness_score = completeness
csm.has_profile_photo = bool(page_info.get('picture', {}).get('data', {}).get('url'))
@ -523,16 +566,43 @@ def sync_facebook_to_social_media(db, company_id: int) -> dict:
csm.verified_at = now
csm.last_checked_at = now
# Store extra API fields in content_types JSONB (no migration needed)
# Store all API fields in content_types JSONB (no migration needed)
extra = csm.content_types or {}
if page_info.get('category'):
extra['category'] = page_info['category']
if page_info.get('website'):
extra['website'] = page_info['website']
if page_info.get('phone'):
extra['phone'] = page_info['phone']
# Page info fields
for key in ('category', 'website', 'phone', 'founded', 'mission'):
if page_info.get(key):
extra[key] = page_info[key]
if page_info.get('single_line_address'):
extra['address'] = page_info['single_line_address']
if page_info.get('description'):
extra['description'] = page_info['description'][:500]
if page_info.get('emails'):
extra['emails'] = page_info['emails']
if page_info.get('hours'):
extra['hours'] = page_info['hours']
if page_info.get('location'):
loc = page_info['location']
extra['location'] = {k: loc[k] for k in ('city', 'country', 'latitude', 'longitude', 'street', 'zip') if k in loc}
if page_info.get('rating_count'):
extra['rating_count'] = page_info['rating_count']
if page_info.get('overall_star_rating'):
extra['overall_star_rating'] = page_info['overall_star_rating']
if page_info.get('were_here_count'):
extra['were_here_count'] = page_info['were_here_count']
# Post engagement stats
extra['total_likes'] = post_stats.get('total_likes', 0)
extra['total_comments'] = post_stats.get('total_comments', 0)
extra['total_shares'] = post_stats.get('total_shares', 0)
if post_stats.get('post_types'):
extra['post_types'] = post_stats['post_types']
if post_stats.get('recent_posts'):
extra['recent_posts'] = post_stats['recent_posts']
# Insights
for key in ('page_impressions', 'page_engaged_users', 'page_views_total',
'page_post_engagements', 'page_fan_adds', 'page_fan_removes',
'page_actions_post_reactions_total'):
if insights.get(key):
extra[f'insights_{key}'] = insights[key]
csm.content_types = extra
# Append to followers_history

View File

@ -724,15 +724,89 @@
{% endif %}
{% if p.content_types %}
<div style="margin-top: var(--spacing-sm); display: flex; gap: var(--spacing-sm); flex-wrap: wrap;">
{% for ctype, count in p.content_types.items() %}
<span style="background: var(--background); padding: 2px 8px; border-radius: var(--radius-sm); font-size: 11px; color: var(--text-secondary);">
{{ ctype }}: {{ count }}
{% set ct = p.content_types %}
<!-- Contact & Business Info -->
{% set info_fields = [] %}
{% if ct.get('phone') %}{% if info_fields.append(('📞', ct.phone)) %}{% endif %}{% endif %}
{% if ct.get('emails') %}{% for e in ct.emails %}{% if info_fields.append(('✉️', e)) %}{% endif %}{% endfor %}{% endif %}
{% if ct.get('address') %}{% if info_fields.append(('📍', ct.address)) %}{% endif %}{% endif %}
{% if ct.get('website') %}{% if info_fields.append(('🌐', ct.website)) %}{% endif %}{% endif %}
{% if ct.get('category') %}{% if info_fields.append(('🏷️', ct.category)) %}{% endif %}{% endif %}
{% if ct.get('founded') %}{% if info_fields.append(('📅', 'Założono: ' ~ ct.founded)) %}{% endif %}{% endif %}
{% if ct.get('hours') %}{% if info_fields.append(('🕐', 'Godziny otwarcia ustawione')) %}{% endif %}{% endif %}
{% if info_fields %}
<div style="margin-top: var(--spacing-sm); display: flex; gap: var(--spacing-xs); flex-wrap: wrap;">
{% for icon, val in info_fields %}
<span style="background: var(--background); padding: 3px 10px; border-radius: var(--radius); font-size: 12px; color: var(--text-secondary); border: 1px solid var(--border-color, #e5e7eb);">
{{ icon }} {{ val }}
</span>
{% endfor %}
</div>
{% endif %}
<!-- Ratings & Check-ins -->
{% if ct.get('overall_star_rating') or ct.get('were_here_count') or ct.get('rating_count') %}
<div style="margin-top: var(--spacing-sm); display: flex; gap: var(--spacing-md); flex-wrap: wrap; font-size: var(--font-size-sm);">
{% if ct.get('overall_star_rating') %}
<span>⭐ {{ ct.overall_star_rating }}/5 {% if ct.get('rating_count') %}({{ ct.rating_count }} ocen){% endif %}</span>
{% endif %}
{% if ct.get('were_here_count') %}
<span>📌 {{ "{:,}".format(ct.were_here_count).replace(",", " ") }} zameldowań</span>
{% endif %}
</div>
{% endif %}
<!-- Engagement stats -->
{% if ct.get('total_likes') or ct.get('total_comments') or ct.get('total_shares') %}
<div style="margin-top: var(--spacing-sm); display: flex; gap: var(--spacing-sm); flex-wrap: wrap;">
<span style="background: #dbeafe; color: #1d4ed8; padding: 3px 10px; border-radius: var(--radius); font-size: 12px; font-weight: 500;">👍 {{ ct.total_likes }} polubień</span>
<span style="background: #dcfce7; color: #166534; padding: 3px 10px; border-radius: var(--radius); font-size: 12px; font-weight: 500;">💬 {{ ct.total_comments }} komentarzy</span>
<span style="background: #fef3c7; color: #92400e; padding: 3px 10px; border-radius: var(--radius); font-size: 12px; font-weight: 500;">🔄 {{ ct.total_shares }} udostępnień</span>
</div>
{% endif %}
<!-- Post types breakdown -->
{% if ct.get('post_types') %}
<div style="margin-top: var(--spacing-sm); display: flex; gap: var(--spacing-xs); flex-wrap: wrap;">
{% for ptype, count in ct.post_types.items() %}
<span style="background: var(--background); padding: 2px 8px; border-radius: var(--radius-sm); font-size: 11px; color: var(--text-secondary);">
{{ ptype }}: {{ count }}
</span>
{% endfor %}
</div>
{% endif %}
<!-- Insights -->
{% set has_insights = ct.get('insights_page_impressions') or ct.get('insights_page_views_total') or ct.get('insights_page_post_engagements') %}
{% if has_insights %}
<div style="margin-top: var(--spacing-sm); padding: var(--spacing-sm) var(--spacing-md); background: #f0f9ff; border-radius: var(--radius); border: 1px solid #bae6fd;">
<div style="font-size: 11px; font-weight: 600; color: #0369a1; margin-bottom: 4px;">📊 Insights (28 dni)</div>
<div style="display: flex; gap: var(--spacing-md); flex-wrap: wrap; font-size: var(--font-size-sm); color: #0c4a6e;">
{% if ct.get('insights_page_impressions') %}<span>{{ "{:,}".format(ct.insights_page_impressions).replace(",", " ") }} wyświetleń</span>{% endif %}
{% if ct.get('insights_page_views_total') %}<span>{{ "{:,}".format(ct.insights_page_views_total).replace(",", " ") }} odsłon strony</span>{% endif %}
{% if ct.get('insights_page_post_engagements') %}<span>{{ "{:,}".format(ct.insights_page_post_engagements).replace(",", " ") }} interakcji</span>{% endif %}
{% if ct.get('insights_page_fan_adds') %}<span>+{{ ct.insights_page_fan_adds }} nowych fanów</span>{% endif %}
{% if ct.get('insights_page_fan_removes') %}<span>-{{ ct.insights_page_fan_removes }} utraconych</span>{% endif %}
</div>
</div>
{% endif %}
<!-- Recent posts -->
{% if ct.get('recent_posts') %}
<div style="margin-top: var(--spacing-sm);">
<div style="font-size: 11px; font-weight: 600; color: var(--text-secondary); margin-bottom: 4px;">Ostatnie posty</div>
{% for rp in ct.recent_posts %}
<div style="padding: 4px 0; border-bottom: 1px solid #f3f4f6; font-size: 12px; display: flex; gap: var(--spacing-sm); align-items: baseline;">
<span style="color: var(--text-secondary); white-space: nowrap;">{{ rp.date }}</span>
<span style="color: var(--text-secondary); font-size: 10px; text-transform: uppercase;">{{ rp.type }}</span>
<span style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ rp.title or '(bez tekstu)' }}</span>
<span style="white-space: nowrap; color: var(--text-secondary); font-size: 11px;">👍{{ rp.likes }} 💬{{ rp.comments }} 🔄{{ rp.shares }}</span>
</div>
{% endfor %}
</div>
{% endif %}
{% endif %}
<!-- Data Provenance Section -->
<div class="provenance-section">
<div class="provenance-header">