feat: Instagram Graph API integration via Facebook OAuth
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
- Add sync_instagram_to_social_media() using Facebook Page token to access linked Instagram Business accounts - Fetch profile info, recent media, engagement, insights - Auto-sync Instagram during enrichment scans for OAuth companies - Add /api/oauth/meta/sync-instagram endpoint for manual refresh - Display Instagram extra data (media types, reach, recent posts) on audit detail cards Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
660ed68a0d
commit
bc18999f28
@ -935,6 +935,36 @@ def _run_enrichment_background(company_ids, platforms_filter=None):
|
||||
'reason': f"Graph API: {str(e)[:100]}",
|
||||
})
|
||||
|
||||
# Check if company has Instagram linked via Facebook OAuth
|
||||
ig_synced = False
|
||||
if fb_config and (not platforms_filter or 'instagram' in platforms_filter):
|
||||
try:
|
||||
from facebook_graph_service import sync_instagram_to_social_media
|
||||
ig_result = sync_instagram_to_social_media(db, company_id)
|
||||
if ig_result.get('success'):
|
||||
ig_data = ig_result.get('data', {})
|
||||
ig_synced = True
|
||||
company_result['profiles'].append({
|
||||
'profile_id': None,
|
||||
'platform': 'instagram',
|
||||
'url': f"Instagram: @{ig_data.get('username', '?')}",
|
||||
'source': 'instagram_api',
|
||||
'status': 'synced_api',
|
||||
'reason': f"Graph API: {ig_data.get('followers_count') or 0} obserwujących, {ig_data.get('media_count') or 0} postów",
|
||||
})
|
||||
company_result['has_changes'] = True
|
||||
elif ig_result.get('error') != 'no_ig_linked':
|
||||
company_result['profiles'].append({
|
||||
'profile_id': None,
|
||||
'platform': 'instagram',
|
||||
'url': 'Instagram Business',
|
||||
'source': 'instagram_api',
|
||||
'status': 'error',
|
||||
'reason': f"Graph API: {ig_result.get('message', 'nieznany błąd')}",
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Instagram API sync failed for {company.name}: {e}")
|
||||
|
||||
for profile in profiles:
|
||||
profile_result = {
|
||||
'profile_id': profile.id,
|
||||
@ -950,6 +980,13 @@ def _run_enrichment_background(company_ids, platforms_filter=None):
|
||||
company_result['profiles'].append(profile_result)
|
||||
continue
|
||||
|
||||
# Skip scraping Instagram if we already synced via API
|
||||
if ig_synced and profile.platform.lower() == 'instagram':
|
||||
profile_result['status'] = 'no_changes'
|
||||
profile_result['reason'] = 'Zsynchronizowano przez Graph API'
|
||||
company_result['profiles'].append(profile_result)
|
||||
continue
|
||||
|
||||
try:
|
||||
enriched = enricher.enrich_profile(profile.platform, profile.url)
|
||||
if enriched:
|
||||
|
||||
@ -441,3 +441,37 @@ def oauth_sync_facebook_data():
|
||||
return jsonify({'success': False, 'error': 'Błąd synchronizacji danych Facebook'}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/oauth/meta/sync-instagram', methods=['POST'])
|
||||
@login_required
|
||||
def oauth_sync_instagram_data():
|
||||
"""Manually refresh Instagram stats for a company.
|
||||
|
||||
POST /api/oauth/meta/sync-instagram
|
||||
Body: {"company_id": 123}
|
||||
|
||||
Uses the Facebook Page token to access the linked Instagram Business account.
|
||||
"""
|
||||
data = request.get_json()
|
||||
if not data or not data.get('company_id'):
|
||||
return jsonify({'success': False, 'error': 'company_id jest wymagany'}), 400
|
||||
|
||||
company_id = data['company_id']
|
||||
|
||||
if not current_user.can_edit_company(company_id):
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from facebook_graph_service import sync_instagram_to_social_media
|
||||
result = sync_instagram_to_social_media(db, company_id)
|
||||
if result.get('success'):
|
||||
return jsonify(result)
|
||||
else:
|
||||
return jsonify(result), 400
|
||||
except Exception as e:
|
||||
logger.error(f"IG manual sync error: {e}")
|
||||
return jsonify({'success': False, 'error': 'Błąd synchronizacji danych Instagram'}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@ -170,6 +170,19 @@ class FacebookGraphService:
|
||||
return data['instagram_business_account'].get('id')
|
||||
return None
|
||||
|
||||
def get_ig_account_info(self, ig_account_id: str) -> Dict:
|
||||
"""Get Instagram Business account profile info.
|
||||
|
||||
Returns username, name, bio, followers, following, media count, profile pic, website.
|
||||
"""
|
||||
data = self._get(ig_account_id, {
|
||||
'fields': (
|
||||
'username,name,biography,followers_count,follows_count,'
|
||||
'media_count,profile_picture_url,website,ig_id'
|
||||
)
|
||||
})
|
||||
return data or {}
|
||||
|
||||
def get_ig_media_insights(self, ig_account_id: str, days: int = 28) -> Dict:
|
||||
"""Get Instagram account insights.
|
||||
|
||||
@ -186,26 +199,42 @@ class FacebookGraphService:
|
||||
result['media_count'] = account_data.get('media_count', 0)
|
||||
result['username'] = account_data.get('username', '')
|
||||
|
||||
# Account insights (reach, impressions)
|
||||
# Account insights (reach, impressions) — fetch individually for robustness
|
||||
since = datetime.now() - timedelta(days=days)
|
||||
until = datetime.now()
|
||||
insights_data = self._get(f'{ig_account_id}/insights', {
|
||||
'metric': 'impressions,reach,follower_count',
|
||||
'period': 'day',
|
||||
'since': int(since.timestamp()),
|
||||
'until': int(until.timestamp()),
|
||||
})
|
||||
|
||||
if insights_data:
|
||||
for metric in insights_data.get('data', []):
|
||||
name = metric.get('name', '')
|
||||
values = metric.get('values', [])
|
||||
if values:
|
||||
total = sum(v.get('value', 0) for v in values if isinstance(v.get('value'), (int, float)))
|
||||
result[f'ig_{name}_total'] = total
|
||||
for metric_name in ('impressions', 'reach', 'follower_count'):
|
||||
try:
|
||||
insights_data = self._get(f'{ig_account_id}/insights', {
|
||||
'metric': metric_name,
|
||||
'period': 'day',
|
||||
'since': int(since.timestamp()),
|
||||
'until': int(until.timestamp()),
|
||||
})
|
||||
if insights_data:
|
||||
for metric in insights_data.get('data', []):
|
||||
name = metric.get('name', '')
|
||||
values = metric.get('values', [])
|
||||
if values:
|
||||
total = sum(v.get('value', 0) for v in values if isinstance(v.get('value'), (int, float)))
|
||||
result[f'ig_{name}_total'] = total
|
||||
except Exception as e:
|
||||
logger.debug(f"IG insight {metric_name} failed: {e}")
|
||||
|
||||
return result
|
||||
|
||||
def get_ig_recent_media(self, ig_account_id: str, limit: int = 25) -> list:
|
||||
"""Get recent media from Instagram Business account.
|
||||
|
||||
Returns list of media with engagement data.
|
||||
"""
|
||||
data = self._get(f'{ig_account_id}/media', {
|
||||
'fields': 'id,caption,media_type,media_url,permalink,timestamp,like_count,comments_count',
|
||||
'limit': limit,
|
||||
})
|
||||
if not data:
|
||||
return []
|
||||
return data.get('data', [])
|
||||
|
||||
# ============================================================
|
||||
# PUBLISHING METHODS (Social Publisher)
|
||||
# ============================================================
|
||||
@ -661,3 +690,215 @@ def sync_facebook_to_social_media(db, company_id: int) -> dict:
|
||||
'source': 'facebook_api',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def sync_instagram_to_social_media(db, company_id: int) -> dict:
|
||||
"""Fetch Instagram stats via Graph API and upsert into CompanySocialMedia.
|
||||
|
||||
Uses the Facebook Page token to access the linked Instagram Business account.
|
||||
Requires:
|
||||
- Active Facebook OAuth with instagram_basic permission
|
||||
- Instagram Business/Creator account linked to a Facebook Page
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy session
|
||||
company_id: Company ID to sync data for
|
||||
|
||||
Returns:
|
||||
dict with 'success' bool and either 'data' or 'error'
|
||||
"""
|
||||
from database import SocialMediaConfig, CompanySocialMedia
|
||||
|
||||
# 1. Get Facebook page config (Instagram uses the same Page token)
|
||||
fb_config = db.query(SocialMediaConfig).filter(
|
||||
SocialMediaConfig.platform == 'facebook',
|
||||
SocialMediaConfig.company_id == company_id,
|
||||
SocialMediaConfig.is_active == True,
|
||||
).first()
|
||||
|
||||
if not fb_config or not fb_config.page_id or not fb_config.access_token:
|
||||
return {'success': False, 'error': 'no_fb_config',
|
||||
'message': 'Brak skonfigurowanej strony Facebook (wymagana do Instagram API)'}
|
||||
|
||||
token = fb_config.access_token
|
||||
fb = FacebookGraphService(token)
|
||||
|
||||
# 2. Get linked Instagram Business account ID
|
||||
ig_account_id = fb.get_instagram_account(fb_config.page_id)
|
||||
if not ig_account_id:
|
||||
return {'success': False, 'error': 'no_ig_linked',
|
||||
'message': 'Brak powiązanego konta Instagram Business ze stroną Facebook'}
|
||||
|
||||
# 3. Fetch Instagram profile data
|
||||
ig_info = fb.get_ig_account_info(ig_account_id)
|
||||
if not ig_info:
|
||||
return {'success': False, 'error': 'api_failed',
|
||||
'message': 'Nie udało się pobrać danych profilu Instagram'}
|
||||
|
||||
# 4. Fetch insights (best-effort)
|
||||
ig_insights = fb.get_ig_media_insights(ig_account_id, 28)
|
||||
|
||||
# 5. Fetch recent media for engagement calculation
|
||||
recent_media = fb.get_ig_recent_media(ig_account_id, 25)
|
||||
|
||||
# 6. Calculate metrics
|
||||
followers = ig_info.get('followers_count', 0)
|
||||
media_count = ig_info.get('media_count', 0)
|
||||
username = ig_info.get('username', '')
|
||||
|
||||
# Engagement rate from recent posts
|
||||
engagement_rate = None
|
||||
posts_30d = 0
|
||||
posts_365d = 0
|
||||
last_post_date = None
|
||||
total_engagement = 0
|
||||
recent_posts_data = []
|
||||
|
||||
now = datetime.now()
|
||||
for media in recent_media:
|
||||
ts = media.get('timestamp', '')
|
||||
try:
|
||||
media_date = datetime.fromisoformat(ts.replace('Z', '+00:00')).replace(tzinfo=None)
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
|
||||
days_ago = (now - media_date).days
|
||||
if days_ago <= 365:
|
||||
posts_365d += 1
|
||||
likes = media.get('like_count', 0)
|
||||
comments = media.get('comments_count', 0)
|
||||
total_engagement += likes + comments
|
||||
|
||||
if days_ago <= 30:
|
||||
posts_30d += 1
|
||||
|
||||
if last_post_date is None or media_date > last_post_date:
|
||||
last_post_date = media_date
|
||||
|
||||
if len(recent_posts_data) < 5:
|
||||
caption = media.get('caption', '') or ''
|
||||
recent_posts_data.append({
|
||||
'date': media_date.strftime('%Y-%m-%d'),
|
||||
'type': media.get('media_type', 'UNKNOWN'),
|
||||
'likes': likes,
|
||||
'comments': comments,
|
||||
'caption': caption[:100],
|
||||
'permalink': media.get('permalink', ''),
|
||||
})
|
||||
|
||||
if posts_365d > 0 and followers > 0:
|
||||
avg_engagement = total_engagement / posts_365d
|
||||
engagement_rate = round((avg_engagement / followers) * 100, 2)
|
||||
|
||||
# Profile completeness
|
||||
completeness = 0
|
||||
if ig_info.get('biography'):
|
||||
completeness += 20
|
||||
if ig_info.get('website'):
|
||||
completeness += 20
|
||||
if ig_info.get('profile_picture_url'):
|
||||
completeness += 20
|
||||
if followers > 10:
|
||||
completeness += 20
|
||||
if posts_30d > 0:
|
||||
completeness += 20
|
||||
|
||||
# 7. Upsert CompanySocialMedia record
|
||||
existing = db.query(CompanySocialMedia).filter(
|
||||
CompanySocialMedia.company_id == company_id,
|
||||
CompanySocialMedia.platform == 'instagram',
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
csm = existing
|
||||
else:
|
||||
csm = CompanySocialMedia(
|
||||
company_id=company_id,
|
||||
platform='instagram',
|
||||
url=f'https://instagram.com/{username}' if username else f'https://instagram.com/',
|
||||
)
|
||||
db.add(csm)
|
||||
|
||||
if username:
|
||||
csm.url = f'https://instagram.com/{username}'
|
||||
csm.source = 'instagram_api'
|
||||
csm.is_valid = True
|
||||
csm.check_status = 'ok'
|
||||
csm.page_name = ig_info.get('name', '') or username
|
||||
csm.followers_count = followers if followers > 0 else csm.followers_count
|
||||
csm.has_bio = bool(ig_info.get('biography'))
|
||||
csm.profile_description = (ig_info.get('biography') or '')[:500]
|
||||
csm.has_profile_photo = bool(ig_info.get('profile_picture_url'))
|
||||
csm.engagement_rate = engagement_rate
|
||||
csm.profile_completeness_score = completeness
|
||||
csm.posts_count_30d = posts_30d
|
||||
csm.posts_count_365d = posts_365d
|
||||
if last_post_date:
|
||||
csm.last_post_date = last_post_date
|
||||
csm.posting_frequency_score = min(10, posts_30d) if posts_30d > 0 else 0
|
||||
csm.verified_at = now
|
||||
csm.last_checked_at = now
|
||||
|
||||
# Extra data in content_types JSONB
|
||||
extra = dict(csm.content_types or {})
|
||||
extra['ig_account_id'] = ig_account_id
|
||||
extra['media_count'] = media_count
|
||||
extra['follows_count'] = ig_info.get('follows_count', 0)
|
||||
if ig_info.get('website'):
|
||||
extra['website'] = ig_info['website']
|
||||
if ig_info.get('profile_picture_url'):
|
||||
extra['profile_picture_url'] = ig_info['profile_picture_url']
|
||||
if recent_posts_data:
|
||||
extra['recent_posts'] = recent_posts_data
|
||||
# Media type breakdown from recent posts
|
||||
media_types = {}
|
||||
for media in recent_media:
|
||||
mt = media.get('media_type', 'UNKNOWN')
|
||||
media_types[mt] = media_types.get(mt, 0) + 1
|
||||
if media_types:
|
||||
extra['media_types'] = media_types
|
||||
extra['total_likes'] = sum(m.get('like_count', 0) for m in recent_media)
|
||||
extra['total_comments'] = sum(m.get('comments_count', 0) for m in recent_media)
|
||||
# Insights
|
||||
for key in ('ig_impressions_total', 'ig_reach_total', 'ig_follower_count_total'):
|
||||
if ig_insights.get(key):
|
||||
extra[key] = ig_insights[key]
|
||||
csm.content_types = extra
|
||||
|
||||
# Followers history
|
||||
history = list(csm.followers_history or [])
|
||||
if followers > 0:
|
||||
today_str = now.strftime('%Y-%m-%d')
|
||||
if not history or history[-1].get('date') != today_str:
|
||||
history.append({'date': today_str, 'count': followers})
|
||||
csm.followers_history = history
|
||||
|
||||
# Save Instagram config (so enrichment knows it's API-managed)
|
||||
ig_config = db.query(SocialMediaConfig).filter(
|
||||
SocialMediaConfig.platform == 'instagram',
|
||||
SocialMediaConfig.company_id == company_id,
|
||||
).first()
|
||||
if not ig_config:
|
||||
ig_config = SocialMediaConfig(platform='instagram', company_id=company_id)
|
||||
db.add(ig_config)
|
||||
ig_config.page_id = ig_account_id
|
||||
ig_config.page_name = username or ig_info.get('name', '')
|
||||
ig_config.access_token = token
|
||||
ig_config.is_active = True
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"IG sync OK for company {company_id}: @{username}, "
|
||||
f"{followers} followers, {media_count} posts, engagement={engagement_rate}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'data': {
|
||||
'username': username,
|
||||
'followers_count': followers,
|
||||
'media_count': media_count,
|
||||
'engagement_rate': engagement_rate,
|
||||
'profile_completeness_score': completeness,
|
||||
'source': 'instagram_api',
|
||||
}
|
||||
}
|
||||
|
||||
@ -833,6 +833,45 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Instagram extra data (from Graph API) -->
|
||||
{% if p.platform == 'instagram' and (ct.get('follows_count') or ct.get('media_count') or ct.get('media_types')) %}
|
||||
<div style="margin-top: var(--spacing-sm);">
|
||||
{% set ig_info = [] %}
|
||||
{% if ct.get('media_count') %}{% if ig_info.append(('📸', '{:,}'.format(ct.media_count).replace(',', ' ') ~ ' postów łącznie')) %}{% endif %}{% endif %}
|
||||
{% if ct.get('follows_count') %}{% if ig_info.append(('👤', '{:,}'.format(ct.follows_count).replace(',', ' ') ~ ' obserwowanych')) %}{% endif %}{% endif %}
|
||||
{% if ct.get('website') %}{% if ig_info.append(('🔗', ct.website)) %}{% endif %}{% endif %}
|
||||
{% if ct.get('media_types') %}
|
||||
{% set types_str = [] %}
|
||||
{% for mt, cnt in ct.media_types.items() %}{% if types_str.append(mt ~ ': ' ~ cnt) %}{% endif %}{% endfor %}
|
||||
{% if ig_info.append(('🎬', types_str|join(', '))) %}{% endif %}
|
||||
{% endif %}
|
||||
{% if ct.get('ig_reach_total') %}{% if ig_info.append(('📊', '{:,}'.format(ct.ig_reach_total).replace(',', ' ') ~ ' zasięg (28d)')) %}{% endif %}{% endif %}
|
||||
{% if ct.get('ig_impressions_total') %}{% if ig_info.append(('👁️', '{:,}'.format(ct.ig_impressions_total).replace(',', ' ') ~ ' wyświetleń (28d)')) %}{% endif %}{% endif %}
|
||||
{% if ig_info %}
|
||||
<div style="display: flex; gap: var(--spacing-xs); flex-wrap: wrap; margin-bottom: var(--spacing-sm);">
|
||||
{% for icon, val in ig_info %}
|
||||
<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 %}
|
||||
{% if ct.get('recent_posts') %}
|
||||
<div style="font-size: 11px; font-weight: 600; color: var(--text-secondary); margin-bottom: 4px;">Ostatnie posty</div>
|
||||
{% for post 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;">{{ post.date }}</span>
|
||||
<span style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ post.caption or '(bez opisu)' }}</span>
|
||||
<span style="white-space: nowrap; color: var(--text-secondary); font-size: 11px;">❤️{{ post.likes }} 💬{{ post.comments }}</span>
|
||||
{% if post.permalink %}
|
||||
<a href="{{ post.permalink }}" target="_blank" rel="noopener" style="font-size: 11px; color: #E4405F; white-space: nowrap;">↗ Zobacz</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Twitter/X extra data -->
|
||||
{% if p.platform == 'twitter' and (ct.get('following_count') or ct.get('location') or ct.get('media_count')) %}
|
||||
<div style="margin-top: var(--spacing-sm);">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user