feat: social media aggregator (YouTube, Instagram, TikTok, WordPress)
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
/**
|
||||
* Récupération des statistiques de followers pour chaque plateforme sociale.
|
||||
* Toutes les fonctions retournent null en cas d'échec — l'affichage reste optionnel.
|
||||
* Cache 24h pour limiter les appels externes.
|
||||
*/
|
||||
|
||||
function formatFollowerCount(int $n): string {
|
||||
if ($n >= 1_000_000) return number_format($n / 1_000_000, 1, '.', '') . 'M';
|
||||
if ($n >= 1_000) return number_format($n / 1_000, 1, '.', '') . 'K';
|
||||
return (string) $n;
|
||||
}
|
||||
|
||||
function _statsCurlFetch(string $url, array $headers = []): ?string {
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 8,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_USERAGENT => 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124.0 Safari/537.36',
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
return ($body && $httpCode === 200) ? $body : null;
|
||||
}
|
||||
|
||||
function getYouTubeSubscriberCount(): ?int {
|
||||
// Priority 1: YouTube Data API v3 (exact count)
|
||||
if (defined('YOUTUBE_API_KEY') && !empty(YOUTUBE_API_KEY)) {
|
||||
$channelId = getYouTubeChannelId();
|
||||
if ($channelId) {
|
||||
$cacheKey = 'stat_yt_' . $channelId;
|
||||
$cached = $GLOBALS['simple_api_cache']->get($cacheKey, []);
|
||||
if ($cached !== null) return (int) $cached ?: null;
|
||||
|
||||
$data = callYouTubeApi('channels', ['part' => 'statistics', 'id' => $channelId]);
|
||||
$count = isset($data['items'][0]['statistics']['subscriberCount'])
|
||||
? (int) $data['items'][0]['statistics']['subscriberCount']
|
||||
: null;
|
||||
|
||||
if ($count !== null) {
|
||||
$GLOBALS['simple_api_cache']->set($cacheKey, [], $count, 86400);
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: parse ytInitialData from public channel page
|
||||
$handle = '';
|
||||
if (defined('YOUTUBE_HANDLE') && !empty(YOUTUBE_HANDLE)) {
|
||||
$handle = ltrim(YOUTUBE_HANDLE, '@');
|
||||
} elseif (defined('YOUTUBE_CHANNEL_HANDLE') && !empty(YOUTUBE_CHANNEL_HANDLE)) {
|
||||
$handle = ltrim(YOUTUBE_CHANNEL_HANDLE, '@');
|
||||
}
|
||||
|
||||
$pageUrl = $handle
|
||||
? 'https://www.youtube.com/@' . urlencode($handle)
|
||||
: (defined('YOUTUBE_CHANNEL_ID') && YOUTUBE_CHANNEL_ID
|
||||
? 'https://www.youtube.com/channel/' . urlencode(YOUTUBE_CHANNEL_ID)
|
||||
: null);
|
||||
|
||||
if (!$pageUrl) return null;
|
||||
|
||||
$cacheKey = 'stat_yt_page_' . preg_replace('/[^a-z0-9]/i', '_', $handle ?: YOUTUBE_CHANNEL_ID);
|
||||
$cached = $GLOBALS['simple_api_cache']->get($cacheKey, []);
|
||||
if ($cached !== null) return (int) $cached ?: null;
|
||||
|
||||
$body = _statsCurlFetch($pageUrl, ['Accept-Language: fr-FR,fr;q=0.9,en;q=0.8']);
|
||||
if (!$body) return null;
|
||||
|
||||
// ytInitialData embeds exact subscriberCount as a quoted integer string
|
||||
if (preg_match('/"subscriberCount"\s*:\s*"(\d+)"/', $body, $m)) {
|
||||
$count = (int) $m[1];
|
||||
$GLOBALS['simple_api_cache']->set($cacheKey, [], $count, 86400);
|
||||
return $count;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getInstagramFollowers(): ?int {
|
||||
if (!defined('INSTAGRAM_HANDLE') || empty(INSTAGRAM_HANDLE)) return null;
|
||||
|
||||
$handle = ltrim(INSTAGRAM_HANDLE, '@');
|
||||
$cacheKey = 'stat_ig_' . $handle;
|
||||
$cached = $GLOBALS['simple_api_cache']->get($cacheKey, []);
|
||||
if ($cached !== null) return (int) $cached ?: null;
|
||||
|
||||
$body = _statsCurlFetch(
|
||||
'https://www.instagram.com/api/v1/users/web_profile_info/?username=' . urlencode($handle),
|
||||
['X-IG-App-ID: 936619743392459', 'Accept: application/json']
|
||||
);
|
||||
if (!$body) return null;
|
||||
|
||||
$data = json_decode($body, true);
|
||||
$count = $data['data']['user']['edge_followed_by']['count'] ?? null;
|
||||
|
||||
if ($count !== null) {
|
||||
$count = (int) $count;
|
||||
$GLOBALS['simple_api_cache']->set($cacheKey, [], $count, 86400);
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
function getTikTokFollowers(): ?int {
|
||||
if (!defined('TIKTOK_HANDLE') || empty(TIKTOK_HANDLE)) return null;
|
||||
|
||||
$handle = ltrim(TIKTOK_HANDLE, '@');
|
||||
$cacheKey = 'stat_tt_' . $handle;
|
||||
$cached = $GLOBALS['simple_api_cache']->get($cacheKey, []);
|
||||
if ($cached !== null) return (int) $cached ?: null;
|
||||
|
||||
$body = _statsCurlFetch('https://www.tiktok.com/embed/@' . urlencode($handle));
|
||||
if (!$body) return null;
|
||||
|
||||
preg_match('/"followerCount"\s*:\s*(\d+)/', $body, $m);
|
||||
$count = isset($m[1]) ? (int) $m[1] : null;
|
||||
|
||||
if ($count !== null) {
|
||||
$GLOBALS['simple_api_cache']->set($cacheKey, [], $count, 86400);
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
/**
|
||||
* Intégration YouTube Data API v3
|
||||
*/
|
||||
|
||||
function callYouTubeApi(string $endpoint, array $params = []): array {
|
||||
if (!defined('YOUTUBE_API_KEY') || empty(YOUTUBE_API_KEY)) return [];
|
||||
|
||||
$params['key'] = YOUTUBE_API_KEY;
|
||||
$url = 'https://www.googleapis.com/youtube/v3/' . $endpoint . '?' . http_build_query($params);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_CONNECTTIMEOUT => 8,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_USERAGENT => 'KaubuntuRe/2.0',
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || !empty($error) || $httpCode < 200 || $httpCode >= 300) {
|
||||
error_log('YouTube API error: ' . $error . ' (HTTP ' . $httpCode . ')');
|
||||
return [];
|
||||
}
|
||||
|
||||
return json_decode($response, true) ?: [];
|
||||
}
|
||||
|
||||
function getYouTubeChannelId(): string {
|
||||
if (defined('YOUTUBE_CHANNEL_ID') && !empty(YOUTUBE_CHANNEL_ID)) {
|
||||
return YOUTUBE_CHANNEL_ID;
|
||||
}
|
||||
|
||||
if (!defined('YOUTUBE_API_KEY') || empty(YOUTUBE_API_KEY)) return '';
|
||||
if (!defined('YOUTUBE_CHANNEL_HANDLE') || empty(YOUTUBE_CHANNEL_HANDLE)) return '';
|
||||
|
||||
$handle = ltrim(YOUTUBE_CHANNEL_HANDLE, '@');
|
||||
$cached = $GLOBALS['simple_api_cache']->get('yt_channel_id_' . $handle, []);
|
||||
if ($cached !== null) return (string) $cached;
|
||||
|
||||
$data = callYouTubeApi('channels', ['part' => 'snippet', 'forHandle' => $handle]);
|
||||
$channelId = $data['items'][0]['id'] ?? '';
|
||||
|
||||
if ($channelId) {
|
||||
$GLOBALS['simple_api_cache']->set('yt_channel_id_' . $handle, [], $channelId, 86400);
|
||||
}
|
||||
|
||||
return $channelId;
|
||||
}
|
||||
|
||||
function isYouTubeShort(string $isoDuration): bool {
|
||||
preg_match('/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/', $isoDuration, $m);
|
||||
$seconds = (int)($m[1] ?? 0) * 3600 + (int)($m[2] ?? 0) * 60 + (int)($m[3] ?? 0);
|
||||
return $seconds > 0 && $seconds <= 60;
|
||||
}
|
||||
|
||||
function getYouTubeLatestVideos(int $count = 6): array {
|
||||
// Priorité 1 : YouTube Data API v3
|
||||
if (defined('YOUTUBE_API_KEY') && !empty(YOUTUBE_API_KEY)) {
|
||||
$channelId = getYouTubeChannelId();
|
||||
if ($channelId) {
|
||||
$shortsTarget = min(3, $count - 1);
|
||||
$regularTarget = $count - $shortsTarget;
|
||||
|
||||
$cacheKey = 'yt_videos2_' . $channelId . '_' . $count;
|
||||
$cached = $GLOBALS['simple_api_cache']->get($cacheKey, []);
|
||||
if ($cached !== null) return $cached;
|
||||
|
||||
// On récupère 50 vidéos pour avoir suffisamment de chaque type
|
||||
$searchData = callYouTubeApi('search', [
|
||||
'part' => 'snippet',
|
||||
'channelId' => $channelId,
|
||||
'order' => 'date',
|
||||
'type' => 'video',
|
||||
'maxResults' => 50,
|
||||
]);
|
||||
|
||||
if (!empty($searchData['items'])) {
|
||||
$videoIds = array_map(fn($i) => $i['id']['videoId'], $searchData['items']);
|
||||
|
||||
// videos.list coûte 1 unité quota (vs 100 pour search)
|
||||
$detailData = callYouTubeApi('videos', [
|
||||
'part' => 'contentDetails',
|
||||
'id' => implode(',', $videoIds),
|
||||
]);
|
||||
|
||||
$durations = [];
|
||||
foreach ($detailData['items'] ?? [] as $item) {
|
||||
$durations[$item['id']] = $item['contentDetails']['duration'] ?? '';
|
||||
}
|
||||
|
||||
$shorts = [];
|
||||
$regular = [];
|
||||
|
||||
foreach ($searchData['items'] as $item) {
|
||||
$videoId = $item['id']['videoId'];
|
||||
$isShort = isset($durations[$videoId])
|
||||
? isYouTubeShort($durations[$videoId])
|
||||
: false;
|
||||
|
||||
$video = [
|
||||
'id' => $videoId,
|
||||
'title' => html_entity_decode($item['snippet']['title'], ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'thumbnail' => $item['snippet']['thumbnails']['high']['url']
|
||||
?? $item['snippet']['thumbnails']['default']['url'],
|
||||
'publishedAt' => $item['snippet']['publishedAt'],
|
||||
'url' => $isShort
|
||||
? 'https://www.youtube.com/shorts/' . $videoId
|
||||
: 'https://www.youtube.com/watch?v=' . $videoId,
|
||||
'isShort' => $isShort,
|
||||
];
|
||||
|
||||
if ($isShort && count($shorts) < $shortsTarget) {
|
||||
$shorts[] = $video;
|
||||
} elseif (!$isShort && count($regular) < $regularTarget) {
|
||||
$regular[] = $video;
|
||||
}
|
||||
|
||||
if (count($shorts) >= $shortsTarget && count($regular) >= $regularTarget) break;
|
||||
}
|
||||
|
||||
// Shorts en premier, puis vidéos normales
|
||||
$videos = array_merge($shorts, $regular);
|
||||
|
||||
if (!empty($videos)) {
|
||||
$ttl = defined('CACHE_DURATION') ? CACHE_DURATION : 900;
|
||||
$GLOBALS['simple_api_cache']->set($cacheKey, [], $videos, $ttl);
|
||||
return $videos;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priorité 2 : flux RSS public YouTube (sans clé API, nécessite YOUTUBE_CHANNEL_ID)
|
||||
return getYouTubeVideosFromRSS($count);
|
||||
}
|
||||
|
||||
function getYouTubeVideosFromRSS(int $count = 6): array {
|
||||
if (!defined('YOUTUBE_CHANNEL_ID') || empty(YOUTUBE_CHANNEL_ID)) return [];
|
||||
|
||||
$cacheKey = 'yt_rss_' . YOUTUBE_CHANNEL_ID . '_' . $count;
|
||||
$cached = $GLOBALS['simple_api_cache']->get($cacheKey, []);
|
||||
if ($cached !== null) return $cached;
|
||||
|
||||
$feedUrl = 'https://www.youtube.com/feeds/videos.xml?channel_id=' . urlencode(YOUTUBE_CHANNEL_ID);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $feedUrl,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_USERAGENT => 'KaubuntuRe/2.0',
|
||||
]);
|
||||
$xmlString = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if (!$xmlString || $httpCode !== 200) {
|
||||
error_log('YouTube RSS error: HTTP ' . $httpCode);
|
||||
return [];
|
||||
}
|
||||
|
||||
$xml = @simplexml_load_string($xmlString);
|
||||
if (!$xml) return [];
|
||||
|
||||
$videos = [];
|
||||
$ytNs = 'http://www.youtube.com/xml/schemas/2015';
|
||||
|
||||
foreach ($xml->entry as $entry) {
|
||||
if (count($videos) >= $count) break;
|
||||
$yt = $entry->children($ytNs);
|
||||
$videoId = (string) $yt->videoId;
|
||||
if (!$videoId) continue;
|
||||
|
||||
$videos[] = [
|
||||
'id' => $videoId,
|
||||
'title' => html_entity_decode((string) $entry->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'thumbnail' => "https://i.ytimg.com/vi/{$videoId}/hqdefault.jpg",
|
||||
'publishedAt' => (string) $entry->published,
|
||||
'url' => "https://www.youtube.com/watch?v={$videoId}",
|
||||
'isShort' => false,
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($videos)) {
|
||||
$ttl = defined('CACHE_DURATION') ? CACHE_DURATION : 3600;
|
||||
$GLOBALS['simple_api_cache']->set($cacheKey, [], $videos, $ttl);
|
||||
}
|
||||
|
||||
return $videos;
|
||||
}
|
||||
Reference in New Issue
Block a user