feat: improve SEO with podcast JSON-LD, canonical and meta descriptions
This commit is contained in:
+1
-1
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user