Auto Youtube Grid sur Webflow

Auto Youtube Grid sur Webflow

Cet outil extrait automatiquement et gratuitement les dernières vidéos de n'importe quelle chaîne YouTube et les affiche dans un format de grille professionnel. Il vous suffit de fournir un identifiant de chaîne YouTube et le module s'occupe du reste : récupération des données vidéo, des vignettes, des titres, des descriptions et des dates de publication. Les fonctionnalités incluent la mise en cache intelligente pour minimiser les appels d'API, la détection des robots pour éviter les demandes inutiles, les options de filtrage des courts métrages, le formatage des dates en plusieurs langues et une optimisation des vignettes de haute qualité. Parfait pour les présentations de portfolios, le marketing de contenu, les galeries vidéo et les intégrations dynamiques de YouTube sans codage complexe.
Temps de setup :
10min
Difficulté :
Facile
Partager ce contenu
Youtube Grid sur Webflow
1

Configuration API YouTube

Obtenir une clé API YouTube

Rendez-vous sur Google Cloud Console
Créez un nouveau projet
Activez YouTube Data API v3
Créez des identifiants → Clé API
Copiez votre clé API

Créer un compte Cloudflare

Rendez-vous sur Cloudflare
Créez un compte gratuit
Allez dans Workers & Pages
Cliquez sur Create application

2

Déployer le Worker

Ajouter le secret de la clé API

Ouvrez votre Worker dans Cloudflare
Allez dans Settings → Variables and Secrets
Cliquez sur Add → Secret
Nommez le secret : YOUTUBE_API_KEY
Collez votre clé API YouTube comme valeur
Enregistrez et redéployez le Worker

Créez un worker et collez ce code à l'intérieur :

function decodeHTMLEntities(str) {
  if (typeof str !== 'string') return str
  return str
    .replace(/"/g, '"')
    .replace(/'/g, "'")
    .replace(/'/g, "'")
    .replace(/&/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&#(\d+);/g, (_, num) => String.fromCharCode(parseInt(num, 10)))
    .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
}

function decodeSnippetFields(data) {
  if (!data || !Array.isArray(data.items)) return data
  data.items = data.items.map(item => {
    if (!item.snippet) return item
    const s = item.snippet
    if (s.title) s.title = decodeHTMLEntities(s.title)
    if (s.description) s.description = decodeHTMLEntities(s.description)
    if (s.channelTitle) s.channelTitle = decodeHTMLEntities(s.channelTitle)
    return item
  })
  return data
}

export default {
  async fetch(request, env) {
    // Gérer les CORS
    if (request.method === 'OPTIONS') {
      return new Response(null, {
        headers: {
          'Access-Control-Allow-Origin': '*',
          'Access-Control-Allow-Methods': 'GET, OPTIONS',
          'Access-Control-Allow-Headers': 'Content-Type',
        }
      })
    }

    // Seulement GET autorisé
    if (request.method !== 'GET') {
      return new Response(JSON.stringify({ error: 'Method not allowed' }), {
        status: 405,
        headers: {
          'Content-Type': 'application/json',
          'Access-Control-Allow-Origin': '*'
        }
      })
    }

    try {
      const url = new URL(request.url)
      const channelId = url.searchParams.get('channelId')
      let maxResults = url.searchParams.get('maxResults') || '10'
      const allowShorts = url.searchParams.get('allowShorts') || 'false'

      // Validation channelId
      if (!channelId) {
        return new Response(JSON.stringify({ error: 'channelId parameter is required' }), {
          status: 400,
          headers: {
            'Content-Type': 'application/json',
            'Access-Control-Allow-Origin': '*'
          }
        })
      }

      // Validation format channelId (alphanumérique, tirets, underscores uniquement)
      if (!/^[a-zA-Z0-9_-]+$/.test(channelId)) {
        return new Response(JSON.stringify({ error: 'Invalid channelId format' }), {
          status: 400,
          headers: {
            'Content-Type': 'application/json',
            'Access-Control-Allow-Origin': '*'
          }
        })
      }

      // Validation et limitation maxResults
      maxResults = parseInt(maxResults, 10)
      if (isNaN(maxResults) || maxResults < 1) {
        maxResults = 10
      }
      // Limiter à 50 max (limite API YouTube)
      if (maxResults > 50) {
        maxResults = 50
      }
      maxResults = String(maxResults)

      // Validation allowShorts
      const safeAllowShorts = allowShorts === 'true'

      // Récupérer la clé API depuis les secrets Cloudflare
      const apiKey = env.YOUTUBE_API_KEY

      if (!apiKey) {
        return new Response(JSON.stringify({ error: 'API key not configured' }), {
          status: 500,
          headers: {
            'Content-Type': 'application/json',
            'Access-Control-Allow-Origin': '*'
          }
        })
      }

      // Cache Cloudflare 24h : retourner la réponse en cache si présente
      const cachedResponse = await caches.default.match(request)
      if (cachedResponse) {
        return cachedResponse
      }

      // Encoder les paramètres pour éviter l'injection
      const encodedChannelId = encodeURIComponent(channelId)
      const encodedMaxResults = encodeURIComponent(maxResults)

      // Timeout de 10 secondes pour les requêtes API
      const controller = new AbortController()
      const timeoutId = setTimeout(() => controller.abort(), 10000)

      try {
        if (safeAllowShorts) {
          const apiUrl = `https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=${encodedChannelId}&maxResults=${encodedMaxResults}&order=date&type=video&videoDuration=short&key=${apiKey}`
          const apiResponse = await fetch(apiUrl, { signal: controller.signal })

          clearTimeout(timeoutId)

          if (!apiResponse.ok) {
            throw new Error(`YouTube API error: ${apiResponse.status}`)
          }

          const data = await apiResponse.json()

          if (!data || typeof data !== 'object' || !Array.isArray(data.items)) {
            throw new Error('Invalid response format from YouTube API')
          }

          decodeSnippetFields(data)

          const response = new Response(JSON.stringify(data), {
            headers: {
              'Content-Type': 'application/json',
              'Access-Control-Allow-Origin': '*',
              'Cache-Control': 'public, max-age=86400'
            }
          })
          await caches.default.put(request, response.clone())
          return response
        } else {
          const [mediumResponse, longResponse] = await Promise.all([
            fetch(`https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=${encodedChannelId}&maxResults=${encodedMaxResults}&order=date&type=video&videoDuration=medium&key=${apiKey}`, { signal: controller.signal }),
            fetch(`https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=${encodedChannelId}&maxResults=${encodedMaxResults}&order=date&type=video&videoDuration=long&key=${apiKey}`, { signal: controller.signal })
          ])

          clearTimeout(timeoutId)

          if (!mediumResponse.ok || !longResponse.ok) {
            const failedResponse = !mediumResponse.ok ? mediumResponse : longResponse
            throw new Error(`YouTube API error: ${failedResponse.status}`)
          }

          const [mediumData, longData] = await Promise.all([
            mediumResponse.json(),
            longResponse.json()
          ])

          if (!mediumData || typeof mediumData !== 'object' || !Array.isArray(mediumData.items)) {
            throw new Error('Invalid response format from YouTube API (medium)')
          }
          if (!longData || typeof longData !== 'object' || !Array.isArray(longData.items)) {
            throw new Error('Invalid response format from YouTube API (long)')
          }

          const combinedItems = [...(mediumData.items || []), ...(longData.items || [])]
          combinedItems.sort((a, b) => {
            const dateA = new Date(a.snippet?.publishedAt || 0)
            const dateB = new Date(b.snippet?.publishedAt || 0)
            return dateB - dateA
          })

          const limitedItems = combinedItems.slice(0, parseInt(maxResults, 10))

          const outputData = { ...mediumData, items: limitedItems }
          decodeSnippetFields(outputData)

          const response = new Response(JSON.stringify(outputData), {
            headers: {
              'Content-Type': 'application/json',
              'Access-Control-Allow-Origin': '*',
              'Cache-Control': 'public, max-age=86400'
            }
          })
          await caches.default.put(request, response.clone())
          return response
        }
      } catch (fetchError) {
        clearTimeout(timeoutId)
        if (fetchError.name === 'AbortError') {
          throw new Error('Request timeout')
        }
        throw fetchError
      }

    } catch (error) {
      const errorMessage = error.message || 'Internal server error'

      return new Response(JSON.stringify({
        error: 'Internal server error',
        message: errorMessage.includes('API key') ? 'Configuration error' : 'An error occurred'
      }), {
        status: 500,
        headers: {
          'Content-Type': 'application/json',
          'Access-Control-Allow-Origin': '*'
        }
      })
    }
  }
}
NOTE
Ne mettez pas votre clé API dans le code du worker — elle est lue depuis le secret YOUTUBE_API_KEY. Après le déploiement, copiez l'URL de votre Worker.
3

Configuration Webflow

Ajoutez ceci dans le Head Code de votre page Webflow :

<!-- BeBranded Contents -->
<script async src="https://cdn.jsdelivr.net/npm/@bebranded/bb-contents@latest/bb-contents.js"></script>

<!-- Youtube Integration by bb-contents -->
<script>
window._bbContentsConfig = {
    youtubeEndpoint: 'YOUR CLOUDFLARE WORKER URL HERE'
};
</script>
NOTE
Remplacez YOUR CLOUDFLARE WORKER URL HERE par l'URL de votre Worker déployé.
Créez un div wrapper de grille dans Webflow.
Ajoutez bb-youtube-channel="UC_YOUR_CHANNEL_ID" sur le wrapper.
À l'intérieur, ajoutez un élément template avec bb-youtube-item.
Dans le template, marquez la miniature, le titre, la description, la date, etc.
Voir la section 4 pour tous les attributs disponibles.

Cloner le projet Webflow prêt à l'emploi (recommandé)

Le moyen le plus rapide de démarrer : clonez notre projet Made in Webflow.
La structure de la grille, les attributs et le style sont déjà configurés.
Clonez-le, collez l'URL de votre Worker dans le Head Code, mettez à jour votre ID de chaîne, et publiez.

NOTE
Made in Webflow (clone gratuit) :
https://webflow.com/made-in-webflow/website/bb-contents-youtube-grid

Après le clonage : ouvrez Project Settings → Custom Code → Head Code, collez le script ci-dessus et remplacez l'URL du Worker.
Vous pouvez aussi copier des sections individuelles dans votre propre projet.
4

Personnalisation du module

Comment fonctionnent les attributs

Ajoutez des attributs personnalisés sur les éléments Webflow (Paramètres de l'élément → Attributs personnalisés).
Les attributs de niveau grille vont sur le wrapper extérieur. Les attributs du template vont à l'intérieur de bb-youtube-item.
Le module récupère les vidéos via votre Worker Cloudflare et remplit automatiquement chaque template cloné.

NOTE
Astuce : voir la card de clonage en section 3 pour un exemple Webflow fonctionnel.

ID de chaîne (wrapper de grille)

bb-youtube-channel="UC_YOUR_CHANNEL_ID"
Ajouter cet attribut :
Nom
bb-youtube-channel
Valeur
UC_YOUR_CHANNEL_ID
NOTE
Sur le wrapper principal de la grille — pas à l'intérieur du template.
Valeur : un ou plusieurs ID de chaîne YouTube, séparés par des virgules.
Trouvez votre ID de chaîne dans YouTube Studio → Paramètres → Chaîne → Paramètres avancés.
Ne pas confondre avec bb-youtube-channel dans le template (celui-ci affiche le nom de la chaîne).

Nombre de vidéos dans la grille

bb-youtube-video-count="9"
Ajouter cet attribut :
Nom
bb-youtube-video-count
Valeur
9
NOTE
Combien de vidéos afficher dans la grille.
Par défaut : 10 si omis. Tout nombre positif fonctionne.
Le Worker limite les requêtes à 50 (limite de l'API YouTube).

Ignorer les premières vidéos

bb-youtube-skip="0"
Ajouter cet attribut :
Nom
bb-youtube-skip
Valeur
0
NOTE
Ignore les N vidéos les plus récentes avant l'affichage.
Exemple : bb-youtube-skip="1" masque la dernière publication et affiche les suivantes.
Utile pour épingler une vidéo mise en avant séparément ou exclure du contenu récent.

Autoriser les Youtube Shorts ?

bb-youtube-allow-shorts="true"
Ajouter cet attribut :
Nom
bb-youtube-allow-shorts
Valeur
true
NOTE
Valeurs : true ou false.
false (comportement par défaut) : uniquement les vidéos medium et long — les Shorts sont exclus.
true : inclut les YouTube Shorts dans la grille.

Langue du module

bb-youtube-language="fr"
Ajouter cet attribut :
Nom
bb-youtube-language
Valeur
fr
NOTE
Contrôle le formatage de la date relative (bb-youtube-date).
Langues supportées : Français (fr) — par défaut, Anglais (en).

Conteneur de grille (optionnel)

bb-youtube-container=" "
Ajouter cet attribut :
Nom
bb-youtube-container
Valeur
NOTE
Optionnel. Nécessaire uniquement si bb-youtube-item est imbriqué un niveau plus bas que le wrapper de grille.
Le module clone le template à l'intérieur de cet élément conteneur.
Laissez vide si bb-youtube-item est directement dans le wrapper de grille.

Template vidéo (requis)

bb-youtube-item=" "

Ajoutez bb-youtube-item sur l'élément qui sera dupliqué pour chaque vidéo.
Fonctionne sur un link block, un div ou tout autre wrapper.
Si le template est un link block, l'URL YouTube est définie automatiquement sur href.
Masquez ce template dans Webflow (display: none) — le module le clone pour chaque vidéo.

Ajouter cet attribut :
Nom
bb-youtube-item
Valeur
NOTE
À l'intérieur de bb-youtube-item, ajoutez les attributs ci-dessous. Voir la section 3 pour la structure Webflow complète.

Miniature vidéo

bb-youtube-thumbnail=" "
Ajouter cet attribut :
Nom
bb-youtube-thumbnail
Valeur
NOTE
À ajouter sur un élément Image à l'intérieur de bb-youtube-item.
Aucune valeur nécessaire — src et alt sont remplis automatiquement.
Utilise la meilleure qualité de miniature disponible (jusqu'à 1280×720).

Titre de la vidéo

bb-youtube-title=" "
Ajouter cet attribut :
Nom
bb-youtube-title
Valeur
NOTE
À ajouter sur un Text Block ou un Heading à l'intérieur de bb-youtube-item.
Affiche le titre de la vidéo depuis l'API YouTube.
Les entités HTML (&amp;, &#39;, etc.) sont décodées automatiquement.

Description de la vidéo

bb-youtube-description=" "
Ajouter cet attribut :
Nom
bb-youtube-description
Valeur
NOTE
À ajouter sur un Text Block à l'intérieur de bb-youtube-item.
Affiche la description de la vidéo depuis YouTube.
Les longues descriptions peuvent nécessiter du CSS (line-clamp, max-height) pour contrôler la mise en page.

Date de publication

bb-youtube-date=" "
Ajouter cet attribut :
Nom
bb-youtube-date
Valeur
NOTE
À ajouter sur un élément Text à l'intérieur de bb-youtube-item.
Affiche une date relative (ex. « il y a 3 jours »).
La langue suit bb-youtube-language.

Nom de la chaîne (dans le template)

bb-youtube-channel=" "
Ajouter cet attribut :
Nom
bb-youtube-channel
Valeur
NOTE
Uniquement à l'intérieur de bb-youtube-item — pas l'ID de chaîne.
À ajouter sur un élément Text pour afficher le nom de la chaîne YouTube.
Différent de bb-youtube-channel sur le wrapper de grille.
Toujours besoin d'aide ?