feat: improve SEO with podcast JSON-LD, canonical and meta descriptions

This commit is contained in:
2026-07-25 15:12:43 +04:00
parent 576a72a0e1
commit 521ad8c7f1
11 changed files with 246 additions and 59 deletions
+1 -1
View File
@@ -94,6 +94,6 @@
</div>
<div class="footer-copyright">
<?php echo LEGAL_COPYRIGHT; ?> <?php echo date('Y'); ?> - Licence libre <a href="<?php echo LEGAL_LICENSE_URL; ?>" target="_blank" rel="noopener noreferrer">GNU AGPL-V3</a>
<?php echo LEGAL_COPYRIGHT; ?> <?php echo date('Y'); ?> - Licence libre <a href="<?php echo LEGAL_LICENSE_URL; ?>" target="_blank" rel="noopener noreferrer"><?php echo LEGAL_LICENSE; ?></a>
</div>
</div>
+144 -1
View File
@@ -29,7 +29,7 @@ function generateWebSiteJsonLd() {
],
"publisher" => [
"@type" => "Organization",
"name" => "OKI",
"name" => ORGANIZATION_NAME,
"url" => $baseUrl,
"logo" => [
"@type" => "ImageObject",
@@ -69,6 +69,8 @@ function generateVideoObjectJsonLd($videoData, $video) {
? truncateText(strip_tags($video['description']), 300)
: "Regardez cette vidéo sur " . SITE_NAME,
"url" => $videoUrl,
"embedUrl" => PEERTUBE_URL . "/videos/embed/" . $video['id'],
"inLanguage" => "fr-FR",
"thumbnailUrl" => $thumbnailUrl,
"uploadDate" => formatDateISO8601($video['date']),
"duration" => $duration,
@@ -159,6 +161,147 @@ function generateVideoObjectJsonLd($videoData, $video) {
return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
}
/**
* Génère le JSON-LD pour un podcast (PodcastSeries + PodcastEpisode + AudioObject)
*
* @param array $episodes Épisodes formatés par getCastopodEpisodes()
* @return string JSON-LD pour le podcast, chaîne vide si aucun épisode
*/
function generatePodcastJsonLd($episodes) {
if (empty($episodes)) {
return '';
}
$baseUrl = getBaseUrl();
$first = $episodes[0];
// Informations de la série, déduites du flux RSS
$seriesName = !empty($first['podcastTitle']) ? $first['podcastTitle'] : SITE_NAME;
$seriesUrl = !empty($first['podcastLink']) ? $first['podcastLink'] : $baseUrl;
$seriesImage = !empty($first['image']) ? $first['image'] : $baseUrl . '/img/logo.png';
$webFeed = !empty($first['podcastSlug'])
? rtrim(CASTOPOD_URL, '/') . '/@' . $first['podcastSlug'] . '/feed'
: null;
$series = [
"@type" => "PodcastSeries",
"@id" => $seriesUrl . '#podcast',
"name" => $seriesName,
"url" => $seriesUrl,
"image" => $seriesImage,
"inLanguage" => "fr-FR",
"author" => [
"@type" => "Organization",
"name" => ORGANIZATION_NAME,
"url" => $baseUrl
],
"publisher" => [
"@type" => "Organization",
"name" => SITE_NAME,
"url" => $baseUrl,
"logo" => [
"@type" => "ImageObject",
"url" => $baseUrl . "/img/logo.png"
]
]
];
if ($webFeed) {
$series["webFeed"] = $webFeed;
}
$episodeItems = [];
foreach ($episodes as $episode) {
$item = [
"@type" => "PodcastEpisode",
"name" => $episode['title'],
"url" => $episode['link'],
"partOfSeries" => ["@id" => $seriesUrl . '#podcast']
];
if (!empty($episode['pubDate'])) {
$item["datePublished"] = formatDateISO8601($episode['pubDate']);
}
if (!empty($episode['image'])) {
$item["image"] = $episode['image'];
}
if (!empty($episode['description'])) {
$item["description"] = truncateText($episode['description'], 300);
}
$seconds = parseDurationToSeconds($episode['duration'] ?? '');
if ($seconds > 0) {
$item["timeRequired"] = formatDurationISO8601($seconds);
}
if (!empty($episode['audioUrl'])) {
$audio = [
"@type" => "AudioObject",
"contentUrl" => $episode['audioUrl']
];
$format = guessAudioEncodingFormat($episode['audioUrl']);
if ($format !== null) {
$audio["encodingFormat"] = $format;
}
$item["associatedMedia"] = $audio;
}
$episodeItems[] = $item;
}
$data = [
"@context" => "https://schema.org",
"@graph" => array_merge([$series], $episodeItems)
];
return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
}
/**
* Convertit une durée iTunes en secondes
* Formats acceptés : "3600", "90", "MM:SS", "HH:MM:SS"
*
* @param string $duration Durée brute issue du flux RSS
* @return int Durée en secondes (0 si non parsable)
*/
function parseDurationToSeconds($duration) {
$duration = trim((string) $duration);
if ($duration === '') {
return 0;
}
if (strpos($duration, ':') !== false) {
$parts = array_map('intval', explode(':', $duration));
$seconds = 0;
foreach ($parts as $part) {
$seconds = $seconds * 60 + $part;
}
return $seconds;
}
return max(0, (int) $duration);
}
/**
* Déduit le type MIME d'un fichier audio depuis son extension
*
* @param string $url URL du fichier audio
* @return string|null Type MIME (audio/mpeg, audio/mp4, audio/ogg) ou null
*/
function guessAudioEncodingFormat($url) {
$extension = strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION));
$map = [
'mp3' => 'audio/mpeg',
'm4a' => 'audio/mp4',
'mp4' => 'audio/mp4',
'ogg' => 'audio/ogg',
'opus' => 'audio/ogg',
'wav' => 'audio/wav'
];
return $map[$extension] ?? null;
}
/**
* Génère le JSON-LD pour les fils d'Ariane (BreadcrumbList)
*