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

- 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:
Maciej Pienczyn 2026-03-12 14:37:44 +01:00
parent 660ed68a0d
commit bc18999f28
4 changed files with 366 additions and 15 deletions

View File

@ -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:

View File

@ -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()

View File

@ -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',
}
}

View File

@ -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);">