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
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:
parent
f8dacd264f
commit
10b116cd6a
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user