X_dwndl.py

Un script Python que j’ai développé avec Grok pour télécharger automatiquement les images et vidéos attachées à un post sur X (anciennement Twitter). Ce outil est particulièrement utile si vous souhaitez archiver du contenu multimédia sans passer par des extensions de navigateur ou des sites tiers. Le script fonctionne sans nécessiter d’authentification ou de clé API. Il utilise l’API GraphQL publique de X pour extraire les données.

Pourquoi ce Script ?

X ne propose pas de moyen natif pour télécharger les médias des posts, surtout les vidéos en haute qualité. Ce script comble ce manque en :

  • Extrayant l’ID du post à partir d’une URL.
  • Récupérant un « guest token » pour accéder à l’API sans compte.
  • Analysant les données du post pour identifier les images et vidéos.
  • Téléchargeant les fichiers en qualité optimale (images en original, vidéos avec le bitrate le plus élevé).

Il est robuste aux changements mineurs d’API grâce à ses commentaires et ses prints de débogage. Notez que cela fonctionne uniquement pour les posts publics.

Fonctionnement du Script

Le script est structuré en plusieurs fonctions pour une meilleure lisibilité et maintenance :

  1. Extraction de l’ID du Post : À partir de l’URL fournie (ex. : https://x.com/utilisateur/status/123456789), il utilise une expression régulière pour isoler l’ID numérique du post.
  2. Récupération du Guest Token : Appel à un endpoint de X pour obtenir un token temporaire qui permet d’accéder à l’API sans login.
  3. Récupération des Données du Post : Utilisation de l’API GraphQL avec l’endpoint TweetResultByRestId. Le script envoie une requête GET avec des paramètres spécifiques (variables, features, fieldToggles) pour obtenir un JSON contenant les détails du post, y compris les médias.
  4. Analyse et Téléchargement des Médias :
    • Navigation dans le JSON pour trouver la section extended_entities/media.
    • Pour les images : Télécharge en ajoutant :orig à l’URL pour la qualité originale.
    • Pour les vidéos ou GIFs animés : Sélectionne la variante MP4 avec le bitrate le plus élevé.
    • Les fichiers sont sauvegardés localement avec un nom basé sur l’ID du post (ex. : 123456789_video_1.mp4).
  5. Gestion des Erreurs : Des messages clairs sont affichés en cas d’absence de médias, d’erreur API, ou d’URL invalide. Des prints à chaque étape aident au débogage.

Le script dépend de bibliothèques standards comme requests, json, re, sys et os. Pas besoin d’installer quoi que ce soit d’extra (sauf requests si ce n’est pas déjà fait via pip install requests).

Si l’API de X change (ce qui arrive souvent), vous pouvez déboguer en inspectant les prints ou en utilisant des outils comme Postman pour vérifier les endpoints.


from playwright.sync_api import sync_playwright  # Contrôle d'un navigateur via Playwright (mode synchrone)
import requests  # Pour télécharger les fichiers via HTTP
import json  # Pour manipuler des JSON (ici surtout utilisé implicitement)
import re  # Expressions régulières (extraction de l'ID du post)
import sys  # Accès aux arguments de la ligne de commande
import os  # Importé mais non utilisé — pourrait servir pour gérer les chemins/fichiers

def download_file(url, filename):
    """
    Télécharge un fichier depuis une URL et le sauvegarde localement.
    """
    # Message informatif au démarrage du téléchargement
    print(f"Téléchargement de {url} vers {filename}...")
    # On demande la ressource en mode streaming (pour ne pas charger tout en mémoire)
    response = requests.get(url, stream=True)
    if response.status_code == 200:
        # Ouvrir un fichier binaire en écriture et écrire par morceaux
        with open(filename, "wb") as f:
            for chunk in response.iter_content(chunk_size=8192):
                f.write(chunk)
        # Confirmation de succès
        print(f"{filename} téléchargé avec succès.")
    else:
        # Afficher l'erreur HTTP si le téléchargement échoue
        print(f"Échec du téléchargement: {response.status_code} - {response.text}")

def scrape_and_download_media(url, post_id):
    """
    Utilise Playwright pour charger la page du post, intercepter l'appel GraphQL contenant les données du tweet,
    analyser les médias et les télécharger.
    """
    # Étape d'information
    print("Étape 1: Lancement du navigateur Playwright...")
    # Utilisation de Playwright en contexte synchrone (fermeture automatique à la sortie du with)
    with sync_playwright() as pw:
        # Lancement d'un navigateur Chromium en mode headless (sans interface graphique)
        browser = pw.chromium.launch(headless=True)
        # Nouvelle page/onglet
        page = browser.new_page()
        # Liste pour stocker les réponses JSON complètes interceptées
        xhr_responses = []  # Liste pour stocker les réponses complètes (pas juste les objets response)

        # Fonction callback appelée à chaque réponse réseau
        def on_response(response):
            # On filtre les URLs contenant des indications d'appel GraphQL avec les détails du tweet
            if "TweetResultByRestId" in response.url or "TweetDetail" in response.url:
                print(f"Appel GraphQL intercepté: {response.url}")
                # Récupérer le JSON immédiatement pendant que le navigateur est ouvert
                try:
                    # Extraire le JSON de la réponse (attention : peut lever si le contenu n'est pas JSON)
                    data = response.json()
                    xhr_responses.append(data)
                    print("Données JSON récupérées avec succès pour cet appel.")
                except Exception as e:
                    # Gestion d'erreur si la conversion en JSON échoue
                    print(f"Erreur lors de la récupération du JSON pour {response.url}: {e}")

        # Enregistrer l'écouteur d'événements pour les réponses
        page.on("response", on_response)
        print(f"Étape 2: Navigation vers l'URL {url}...")
        # Charger l'URL du post
        page.goto(url)
        try:
            # Attendre la présence d'un sélecteur lié au tweet (timeout 60s)
            page.wait_for_selector("[data-testid='tweet']", timeout=60000)
            print("Page chargée avec succès.")
        except Exception as e:
            # Si la page ne charge pas correctement, fermer le navigateur et retourner
            print(f"Erreur lors du chargement de la page: {e}")
            browser.close()
            return

        # Traitement des données interceptées avant de fermer le navigateur
        print("Étape 3: Analyse des données interceptées...")
        found_media = False  # Indicateur pour savoir si on a trouvé et traité des médias
        # Parcourir toutes les réponses JSON sauvegardées
        for data in xhr_responses:
            try:
                # Navigation sûre dans la structure JSON pour atteindre le résultat du tweet
                tweet_result = data.get('data', {}).get('tweetResult', {}).get('result')
                if not tweet_result:
                    continue  # Passer si pas de tweetResult dans cet appel
                legacy = tweet_result.get('legacy')
                if not legacy:
                    continue
                # Vérifier si la clé extended_entities contient la liste des médias
                if 'extended_entities' in legacy and 'media' in legacy['extended_entities']:
                    medias = legacy['extended_entities']['media']
                    print(f"{len(medias)} média(s) trouvé(s) dans cet appel API.")
                    # Parcourir chaque média trouvé
                    for idx, media in enumerate(medias, start=1):
                        media_type = media['type']
                        if media_type == 'photo':
                            # Pour les images, demander la version originale (":orig")
                            url_media = media['media_url_https'] + ":orig"
                            filename = f"{post_id}_image_{idx}.jpg"
                            # Télécharger l'image
                            download_file(url_media, filename)
                        elif media_type in ['video', 'animated_gif']:
                            # Pour les vidéos/GIF animés, chercher les variantes vidéo (mp4)
                            variants = media['video_info']['variants']
                            # Filtrer les variantes au format mp4
                            mp4_variants = [v for v in variants if v['content_type'] == 'video/mp4']
                            if mp4_variants:
                                # Choisir la variante avec le bitrate le plus élevé (meilleure qualité)
                                best_variant = max(mp4_variants, key=lambda v: v.get('bitrate', 0))
                                url_media = best_variant['url']
                                filename = f"{post_id}_video_{idx}.mp4"
                                # Télécharger la vidéo choisie
                                download_file(url_media, filename)
                    found_media = True
                    # Sortir après avoir traité le premier appel valide contenant des médias
                    break  # Sortir après avoir traité le premier appel valide avec médias
            except KeyError as e:
                # Gestion d'erreur si une clé attendue est manquante dans la structure JSON
                print(f"Erreur lors de l'analyse des données pour un appel: {e}. Structure JSON inattendue.")
            except Exception as e:
                # Gestion d'erreur générale pendant l'analyse
                print(f"Erreur générale lors de l'analyse pour un appel: {e}")

        # Si aucun média n'a été trouvé dans les réponses interceptées, informer l'utilisateur
        if not found_media:
            print("Aucun média (images ou vidéos) trouvé dans les appels interceptés.")

        # Fermer le navigateur proprement
        browser.close()

if __name__ == "__main__":
    # Vérifier qu'un argument (l'URL du post) a été fourni
    if len(sys.argv) != 2:
        print("Usage: python script.py <URL_du_post>")
        sys.exit(1)
    
    url = sys.argv[1]
    print(f"URL fournie: {url}")
    
    # Extraction de l'ID du post depuis l'URL via une expression régulière
    match = re.search(r'/status/(\d+)', url)
    if not match:
        # Si l'URL ne correspond pas au format attendu, afficher une erreur et quitter
        print("URL invalide. Elle doit être de la forme https://x.com/utilisateur/status/ID")
        sys.exit(1)
    post_id = match.group(1)
    print(f"ID du post extrait: {post_id}")
    
    # Appel principal : lance le scraping et le téléchargement des médias
    scrape_and_download_media(url, post_id)

Utilisation du Script

  1. Installation des Dépendances : Assurez-vous d’avoir Python installé (version 3.6+). Installez requests si nécessaire : pip install requests.
  2. Exécution : Ouvrez un terminal et lancez le script avec l’URL du post comme argument : text
  3. Ce qui se Passe :
  4. Exemple de Sortie :
    • Pour un post avec une vidéo : Il télécharge 1973289315326136558_video_1.mp4.
    • En cas d’erreur : Vérifiez les prints pour identifier le problème (ex. : code HTTP 404 si l’API a changé).
  5. Précautions :
    • Respectez les termes d’utilisation de X : N’utilisez pas pour du scraping massif.
    • Si le script casse, mettez à jour l’endpoint ou les features en inspectant le réseau via les outils dev du navigateur.
    • Testez sur des posts publics pour éviter les problèmes de confidentialité.
  6. Si vous avez des questions ou des améliorations à suggérer, laissez un commentaire ci-dessous ! Ce script est open-source ; n’hésitez pas à le forker et l’adapter.
python x_dwndl.py https://x.com/LeJournalDuCoin/status/1973139324142051351
URL fournie: https://x.com/LeJournalDuCoin/status/1973139324142051351
ID du post extrait: 1973139324142051351
Étape 1: Lancement du navigateur Playwright...
Étape 2: Navigation vers l'URL https://x.com/LeJournalDuCoin/status/1973139324142051351...
Appel GraphQL intercepté: https://api.x.com/graphql/URPP6YZ5eDCjdVMSREn4gg/TweetResultByRestId?variables=%7B%22tweetId%22%3A%221973139324142051351%22%2C%22withCommunity%22%3Afalse%2C%22includePromotedContent%22%3Afalse%2C%22withVoice%22%3Afalse%7D&features=%7B%22creator_subscriptions_tweet_preview_api_enabled%22%3Atrue%2C%22premium_content_api_read_enabled%22%3Afalse%2C%22communities_web_enable_tweet_community_results_fetch%22%3Atrue%2C%22c9s_tweet_anatomy_moderator_badge_enabled%22%3Atrue%2C%22responsive_web_grok_analyze_button_fetch_trends_enabled%22%3Afalse%2C%22responsive_web_grok_analyze_post_followups_enabled%22%3Afalse%2C%22responsive_web_jetfuel_frame%22%3Atrue%2C%22responsive_web_grok_share_attachment_enabled%22%3Atrue%2C%22articles_preview_enabled%22%3Atrue%2C%22responsive_web_edit_tweet_api_enabled%22%3Atrue%2C%22graphql_is_translatable_rweb_tweet_is_translatable_enabled%22%3Atrue%2C%22view_counts_everywhere_api_enabled%22%3Atrue%2C%22longform_notetweets_consumption_enabled%22%3Atrue%2C%22responsive_web_twitter_article_tweet_consumption_enabled%22%3Atrue%2C%22tweet_awards_web_tipping_enabled%22%3Afalse%2C%22responsive_web_grok_show_grok_translated_post%22%3Afalse%2C%22responsive_web_grok_analysis_button_from_backend%22%3Atrue%2C%22creator_subscriptions_quote_tweet_preview_enabled%22%3Afalse%2C%22freedom_of_speech_not_reach_fetch_enabled%22%3Atrue%2C%22standardized_nudges_misinfo%22%3Atrue%2C%22tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled%22%3Atrue%2C%22longform_notetweets_rich_text_read_enabled%22%3Atrue%2C%22longform_notetweets_inline_media_enabled%22%3Atrue%2C%22payments_enabled%22%3Afalse%2C%22profile_label_improvements_pcf_label_in_post_enabled%22%3Atrue%2C%22rweb_tipjar_consumption_enabled%22%3Atrue%2C%22verified_phone_label_enabled%22%3Afalse%2C%22responsive_web_grok_image_annotation_enabled%22%3Atrue%2C%22responsive_web_grok_imagine_annotation_enabled%22%3Atrue%2C%22responsive_web_grok_community_note_auto_translation_is_enabled%22%3Afalse%2C%22responsive_web_graphql_skip_user_profile_image_extensions_enabled%22%3Afalse%2C%22responsive_web_graphql_timeline_navigation_enabled%22%3Atrue%2C%22responsive_web_enhance_cards_enabled%22%3Afalse%7D&fieldToggles=%7B%22withArticleRichContentState%22%3Atrue%2C%22withArticlePlainText%22%3Afalse%2C%22withGrokAnalyze%22%3Afalse%2C%22withDisallowedReplyControls%22%3Afalse%7D
Appel GraphQL intercepté: https://api.x.com/graphql/URPP6YZ5eDCjdVMSREn4gg/TweetResultByRestId?variables=%7B%22tweetId%22%3A%221973139324142051351%22%2C%22includePromotedContent%22%3Atrue%2C%22withBirdwatchNotes%22%3Atrue%2C%22withVoice%22%3Atrue%2C%22withCommunity%22%3Atrue%7D&features=%7B%22creator_subscriptions_tweet_preview_api_enabled%22%3Atrue%2C%22premium_content_api_read_enabled%22%3Afalse%2C%22communities_web_enable_tweet_community_results_fetch%22%3Atrue%2C%22c9s_tweet_anatomy_moderator_badge_enabled%22%3Atrue%2C%22responsive_web_grok_analyze_button_fetch_trends_enabled%22%3Afalse%2C%22responsive_web_grok_analyze_post_followups_enabled%22%3Afalse%2C%22responsive_web_jetfuel_frame%22%3Atrue%2C%22responsive_web_grok_share_attachment_enabled%22%3Atrue%2C%22articles_preview_enabled%22%3Atrue%2C%22responsive_web_edit_tweet_api_enabled%22%3Atrue%2C%22graphql_is_translatable_rweb_tweet_is_translatable_enabled%22%3Atrue%2C%22view_counts_everywhere_api_enabled%22%3Atrue%2C%22longform_notetweets_consumption_enabled%22%3Atrue%2C%22responsive_web_twitter_article_tweet_consumption_enabled%22%3Atrue%2C%22tweet_awards_web_tipping_enabled%22%3Afalse%2C%22responsive_web_grok_show_grok_translated_post%22%3Afalse%2C%22responsive_web_grok_analysis_button_from_backend%22%3Atrue%2C%22creator_subscriptions_quote_tweet_preview_enabled%22%3Afalse%2C%22freedom_of_speech_not_reach_fetch_enabled%22%3Atrue%2C%22standardized_nudges_misinfo%22%3Atrue%2C%22tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled%22%3Atrue%2C%22longform_notetweets_rich_text_read_enabled%22%3Atrue%2C%22longform_notetweets_inline_media_enabled%22%3Atrue%2C%22payments_enabled%22%3Afalse%2C%22profile_label_improvements_pcf_label_in_post_enabled%22%3Atrue%2C%22rweb_tipjar_consumption_enabled%22%3Atrue%2C%22verified_phone_label_enabled%22%3Afalse%2C%22responsive_web_grok_image_annotation_enabled%22%3Atrue%2C%22responsive_web_grok_imagine_annotation_enabled%22%3Atrue%2C%22responsive_web_grok_community_note_auto_translation_is_enabled%22%3Afalse%2C%22responsive_web_graphql_skip_user_profile_image_extensions_enabled%22%3Afalse%2C%22responsive_web_graphql_timeline_navigation_enabled%22%3Atrue%2C%22responsive_web_enhance_cards_enabled%22%3Afalse%7D&fieldToggles=%7B%22withArticleRichContentState%22%3Atrue%2C%22withArticlePlainText%22%3Afalse%7D
Données JSON récupérées avec succès pour cet appel.
Données JSON récupérées avec succès pour cet appel.
Page chargée avec succès.
Étape 3: Analyse des données interceptées...
1 média(s) trouvé(s) dans cet appel API.
Téléchargement de https://video.twimg.com/ext_tw_video/1973139250058031104/pu/vid/avc1/886x498/3zFWXfpjvgt0gWxE.mp4?tag=12 vers 1973139324142051351_video_1.mp4...
1973139324142051351_video_1.mp4 téléchargé avec succès.

~/python 6s
❯ ls 1973139324142051351_video_1.mp4 
.rw-r--r-- 6,5M stefan  1 oct 13:28  1973139324142051351_video_1.mp4

~/python
❯ 

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Thème : Superposition par Kaira. CopyLerft 2025