Twitterのレスポンスからタイムラインを作ってみる2

前回はこちら↓

これ自体は要するにレスポンス構造の解析(?)の成果物
1ミリも理解してなかったCSSをいじくり回して1ミリくらい理解した気になれたのでお勉強になりましたん。

で、下記が前回のものからの変更点

・読み込んだレスポンスをリストにする

・各種タイムラインへ汎用的に対応

・リプライ取得ができていなかった点を修正

・ツイートかどうかの判定を修正

・抽出結果をJSONに保存するように変更

・抽出時に改行コードを<br>に置換していたのを廃止

・上記に伴いHTML出力関数が改行の置換に対応

・メディアが動画だった場合にも対応

・CSSをいじくり回した

画像の表示させ方はTLで画像全体が見えるようにしたい
サイズの調整はしても形を整えた切り抜きはしない

偉大なる過去の遺産、過去タイムラインに近くしたい

これで出力したHTMLは単にサーバー上の画像を表示するだけ
TLを保存したい場合はブラウザで表示して完全保存を推奨。
これで画像はすべて元のサイズで保存される。

動画保存は完全保存したHTMLからコメントアウト部分に書いたサンプルコードで抽出してDL保存/リンクを修正行えるのでこれでこと足りると思うよ。

import os
import json
import requests

from urllib.parse import urlparse
from datetime import datetime, timezone, timedelta
from bs4 import BeautifulSoup # 整形には便利 入れてなければインストしてね

# extract_tweet_dataはパスを読み込む仕様に変更(各種TLに対応させるため)
"""
パス指定例
path = 'data.list.tweets_timeline.timeline.instructions' # List timeline
path = 'data.user.result.timeline_v2.timeline.instructions' # User timeline Favorites timeline
path = 'data.bookmark_timeline_v2.timeline.instructions' # Bookmark timeline
path = 'data.search_by_raw_query.bookmarks_search_timeline.timeline.instructions' # Bookmark search timeline
"""

# 以前掲載のコードはリプライツリーの取得ができていなかったので修正
# タイムライン情報を取得
def extract_tweet_data(response, path):
    tweet_data = []

    for page in response:
        instructions = page
        for part in path.split('.'):
            instructions = instructions[part]

        for instruction in instructions:
            if 'entries' in instruction:
                entries = instruction['entries']
                for entry in entries:
                    # 通常のツイート
                    if entry['entryId'].startswith('tweet-') and not entry['entryId'].startswith('promoted-tweet'):
                        try:
                            tweet = entry['content']['itemContent']['tweet_results']['result']
                            user = tweet['core']['user_results']['result']
                        except KeyError:
                            print("keyError_tweet")
                            tweet = entry['content']['itemContent']['tweet_results']['result']['tweet']
                            user = tweet['core']['user_results']['result']

                        legacy = tweet['legacy']
                        is_retweet = 'retweeted_status_result' in legacy
                        is_quoted = 'quoted_status_result' in tweet
                        # full_text = legacy['full_text'].replace('\n', '<br>')
                        full_text = legacy['full_text']
                        media = legacy.get('entities', {}).get('media', [])

                        retweeted_user_info = None
                        
                        if is_retweet:
                            try:
                                retweeted_status = legacy['retweeted_status_result']['result']
                                retweeted_user = retweeted_status['core']['user_results']['result']
                            except KeyError:  
                                print("keyError_retweet")
                                retweeted_status = legacy['retweeted_status_result']['result']['tweet']
                                retweeted_user = retweeted_status['core']['user_results']['result']                                
                                
                            retweeted_legacy = retweeted_status['legacy']
                            retweeted_user_info = {
                                'name': retweeted_user['legacy']['name'],
                                'screen_name': retweeted_user['legacy']['screen_name'],
                                'profile_image_url': retweeted_user['legacy']['profile_image_url_https'],
                            }
                            # full_text = retweeted_legacy['full_text'].replace('\n', '<br>')
                            full_text = retweeted_legacy['full_text']
                            media = retweeted_legacy['entities'].get('media', [])
                        
                        quoted_status = None
                        
                        if is_quoted:
                            try:
                                quoted_status = tweet['quoted_status_result']['result']
                                quoted_user = quoted_status['core']['user_results']['result']
                            except KeyError:
                                print("keyError_quoted")
                                quoted_status = tweet['quoted_status_result']['result']['tweet']
                                quoted_user = quoted_status['core']['user_results']['result']

                            quoted_legacy = quoted_status['legacy']
                            # quoted_text = quoted_legacy['full_text'].replace('\n', '<br>')
                            quoted_text = quoted_legacy['full_text']
                            quoted_media = quoted_legacy.get('entities', {}).get('media', [])

                            quoted_tweet_info = {
                                'id_str': quoted_legacy['id_str'],
                                'user_id_str': quoted_legacy['user_id_str'],
                                'name': quoted_user['legacy']['name'],
                                'screen_name': quoted_user['legacy']['screen_name'],
                                'created_at': datetime.strptime(quoted_legacy['created_at'], '%a %b %d %H:%M:%S %z %Y').astimezone(timezone(timedelta(hours=9))).strftime('%Y/%m/%d %H:%M'),
                                'full_text': quoted_text,
                                'profile_image_url': quoted_user['legacy']['profile_image_url_https'],
                                'media': quoted_media
                            }
                        else:
                            quoted_tweet_info = None
                            
                        tweet_info = {
                            'source': BeautifulSoup(tweet['source'], 'html.parser').get_text(),
                            'id_str': legacy['id_str'],
                            'user_id_str': legacy['user_id_str'],
                            'name': user['legacy']['name'],
                            'screen_name': user['legacy']['screen_name'],
                            'created_at': datetime.strptime(legacy['created_at'], '%a %b %d %H:%M:%S %z %Y').astimezone(timezone(timedelta(hours=9))).strftime('%Y/%m/%d %H:%M'),
                            'full_text': full_text,
                            'profile_image_url': user['legacy']['profile_image_url_https'],
                            'is_retweet': is_retweet,
                            'retweeted_user_info': retweeted_user_info,
                            'media': media,
                            'quoted_tweet_info': quoted_tweet_info,
                            'conversation_id': None
                        }
                        tweet_data.append(tweet_info)
                        
                    elif '-conversation-' in entry['entryId'] and not entry['entryId'].startswith('promoted-tweet'): # リプライツリー
                        for item in entry['content']["items"]:
                            if not item['entryId'].startswith('promoted-tweet'):
                                try:
                                    tweet = item['item']['itemContent']['tweet_results']['result']
                                    user = tweet['core']['user_results']['result']
                                except:
                                    tweet = item['item']['itemContent']['tweet_results']['result']['tweet']
                                    user = tweet['core']['user_results']['result']

                            legacy = tweet['legacy']
                            is_retweet = 'retweeted_status_result' in legacy
                            is_quoted = 'quoted_status_result' in tweet
                            # full_text = legacy['full_text'].replace('\n', '<br>')
                            full_text = legacy['full_text']
                            media = legacy.get('entities', {}).get('media', [])
                        
                            retweeted_user_info = None
                            
                            if is_retweet:
                                try:
                                    retweeted_status = legacy['retweeted_status_result']['result']
                                    retweeted_user = retweeted_status['core']['user_results']['result']
                                except KeyError:  
                                    print("keyError_retweet")
                                    retweeted_status = legacy['retweeted_status_result']['result']['tweet']
                                    retweeted_user = retweeted_status['core']['user_results']['result']                                
                                    
                                retweeted_legacy = retweeted_status['legacy']
                                retweeted_user_info = {
                                    'name': retweeted_user['legacy']['name'],
                                    'screen_name': retweeted_user['legacy']['screen_name'],
                                    'profile_image_url': retweeted_user['legacy']['profile_image_url_https'],
                                }
                                # full_text = retweeted_legacy['full_text'].replace('\n', '<br>')
                                full_text = retweeted_legacy['full_text']
                                media = retweeted_legacy['entities'].get('media', [])
                            
                            quoted_status = None
                            
                            if is_quoted:
                                try:
                                    quoted_status = tweet['quoted_status_result']['result']
                                    quoted_user = quoted_status['core']['user_results']['result']
                                except KeyError:
                                    print("keyError_quoted")
                                    quoted_status = tweet['quoted_status_result']['result']['tweet']
                                    quoted_user = quoted_status['core']['user_results']['result']

                                quoted_legacy = quoted_status['legacy']
                                # quoted_text = quoted_legacy['full_text'].replace('\n', '<br>')
                                quoted_text = quoted_legacy['full_text']
                                quoted_media = quoted_legacy.get('entities', {}).get('media', [])

                                quoted_tweet_info = {
                                    'id_str': quoted_legacy['id_str'],
                                    'user_id_str': quoted_legacy['user_id_str'],
                                    'name': quoted_user['legacy']['name'],
                                    'screen_name': quoted_user['legacy']['screen_name'],
                                    'created_at': datetime.strptime(quoted_legacy['created_at'], '%a %b %d %H:%M:%S %z %Y').astimezone(timezone(timedelta(hours=9))).strftime('%Y/%m/%d %H:%M'),
                                    'full_text': quoted_text,
                                    'profile_image_url': quoted_user['legacy']['profile_image_url_https'],
                                    'media': quoted_media
                                }
                            else:
                                quoted_tweet_info = None
                                
                            tweet_info = {
                                'source': BeautifulSoup(tweet['source'], 'html.parser').get_text(),
                                'id_str': legacy['id_str'],
                                'user_id_str': legacy['user_id_str'],
                                'name': user['legacy']['name'],
                                'screen_name': user['legacy']['screen_name'],
                                'created_at': datetime.strptime(legacy['created_at'], '%a %b %d %H:%M:%S %z %Y').astimezone(timezone(timedelta(hours=9))).strftime('%Y/%m/%d %H:%M'),
                                'full_text': full_text,
                                'profile_image_url': user['legacy']['profile_image_url_https'],
                                'is_retweet': is_retweet,
                                'retweeted_user_info': retweeted_user_info,
                                'media': media,
                                'quoted_tweet_info': quoted_tweet_info,
                                'conversation_id': entry['content']['metadata']['conversationMetadata']['allTweetIds'] if 'conversationMetadata' in entry['content']['metadata'] else None
                            }
                            tweet_data.append(tweet_info)
    return tweet_data

# HTMLを出力する関数
# CSS分からないから試行錯誤中のもの
def generate_html(tweet_data):
    html = """
    <html>
    <head>
        <style>
   /* 通常ツイートのスタイル */
            .tweet {
                border-bottom: 1px solid #ccc;
                margin-bottom: 5px;
                padding-bottom: 20px;
                position: relative;
            }
            .tweet .text { /* 会話とは共用しない */
                margin-top: 10px;
                word-wrap: break-word;
                max-width: 600px;
            }
            .tweet .media { /* 会話とは共用しない */
                margin-top: 10px;
                display: flex;
                flex-wrap: wrap;
                border: 1px solid #ccc; /* 画像表示領域の枠線 */
                padding: 3px; /* 枠の内側の余白? 5→3にしてみる */
                max-width: 600px; /* 画像表示領域の枠幅を固定 */
            }

        /* 会話ツイート開始のスタイル */
			/* 開始と中間は共通にして大丈夫?(3連ツイがあんまでてこないので要検証) */
            .conversation-tweet-start,
            .conversation-tweet-middle {
                margin-bottom: 10px;
                padding-bottom: 0px;
                position: relative;
            }
            /* 中間・終了との違いはmax-widthの設定 */
            .conversation-tweet-start .text {
                word-wrap: break-word;
                max-width: 600px;
                border-left: 2px solid #ccc;
                padding-left: 10px;
                margin-left: 20px;
            }
            /* 中間・終了は.media領域を動かす等している */
            .conversation-tweet-start .media {
                display: flex;
                flex-wrap: wrap;
                border: 1px solid #ccc; /* 画像表示領域の枠線 */
                padding: 3px; /* 枠の内側の余白? 5→3にしてみる */
                max-width: 600px; /* 画像表示領域の枠幅を固定 */
                border-left: 2px solid #ccc;
                margin-left: 20px;
                position: relative; /* 位置を相対的にする */
                left: 20px; /* .media 部分のみを右に移動 */
            }
            /* 中間・終了との違いはmax-widthの設定 */
            @media (max-width: 600px) {
                .tweet .media a,
                .conversation-tweet-start .media a {
                    flex: 0 0 100%; /* 画像が1枚の場合の幅 */
                    max-width: 100%;
                }
            }

        /* 会話ツイート終了のスタイル */
            /* 開始・中間との違いは横線&ボトム領域の余白*/
            .conversation-tweet-end {
                border-bottom: 1px solid #ccc;
                margin-bottom: 5px;
                padding-bottom: 20px;
                position: relative;
            }

        /* 共通のスタイル */
            .tweet img.profile,
            .conversation-tweet-start img.profile,
            .conversation-tweet-middle img.profile,
            .conversation-tweet-end img.profile{
                width: 50px;
                height: 50px;
                float: left;
                margin-right: 10px;
            }
            .tweet .user-info,
            .conversation-tweet-start .user-info,
            .conversation-tweet-middle .user-info,
            .conversation-tweet-end .user-info {
                display: flex;
                align-items: center;
                top: 0;
            }
            .tweet .user-info span,
            .conversation-tweet-start .user-info span,
            .conversation-tweet-middle .user-info span,
            .conversation-tweet-end .user-info span {
                margin-right: 5px;
            }
            .tweet .media a,
            .conversation-tweet-start .media a,
            .conversation-tweet-middle .media a,
            .conversation-tweet-end .media a {
                flex: 0 0 50%; /* 画像を2列に配置 */
                max-width: 50%;
                display: block;
                margin-bottom: 5px;
                text-align: center; /* 画像を中央揃え */
            }
            .tweet .media img,
            .tweet .media video,
            .conversation-tweet-start .media img,
            .conversation-tweet-start .media video,
            .conversation-tweet-middle .media img,
            .conversation-tweet-middle .media video,
            .conversation-tweet-end .media img,
            .conversation-tweet-end .media video {
                width: 100%; /* 画像と動画を枠内いっぱいに表示 */
                height: auto;
                border: 1px solid #ccc; /* 画像と動画の枠線 */
            }
            /* 引用ツイート内の画像サイズ調整 */
            .quoted-tweet .media img,
            .quoted-tweet .media video,
            .conversation-tweet-start .quoted-tweet .media img,
            .conversation-tweet-start .quoted-tweet .media video,
            .conversation-tweet-middle .quoted-tweet .media img,
            .conversation-tweet-middle .quoted-tweet .media video,
            .conversation-tweet-end .quoted-tweet .media img,
            .conversation-tweet-end .quoted-tweet .media video {
                max-width: 100%; /* 引用ツイート内の画像と動画が枠内に収まるように調整 */
                height: auto;
            }

        /* 通常と会話ツイート開始 共通のスタイル */
            .tweet .quoted-tweet,
            .conversation-tweet-start .quoted-tweet {
                border: 1px solid #ccc; /* 引用ツイートの枠線 */
                padding: 10px;
                margin-top: 10px;
                max-width: 500px; /* 引用ツイートの枠幅を固定 600→500*/
                word-wrap: break-word;
            }
            /* 1枚の場合の画像サイズ調整 */
            /* 中間・終了との違いはmax-widthの設定 */
            .tweet .media a:nth-child(1):only-child img,
            .conversation-tweet-start .media a:nth-child(1):only-child img {
                width: 600px; /* 1枚の画像の幅を600pxに設定 */
            }
            /* 2枚以上の場合の画像サイズ調整 */
            /* 中間・終了との違いはmax-widthの設定 */
            .tweet .media a img,
            .conversation-tweet-start .media a img {
                width: 300px; /* 2枚以上の画像の幅を300pxに設定 */
            }

        /* 会話ツイート中間・終了共通のスタイル */
        /* いずれも会話ツイート開始との違いはmax-widthの設定 */
            .conversation-tweet-middle .text,
            .conversation-tweet-end .text {
                max-width: 500px;
                word-wrap: break-word;
                border-left: 2px solid #ccc;
                padding-left: 10px;
                margin-left: 20px;
            }
            .conversation-tweet-middle .quoted-tweet,
            .conversation-tweet-end .quoted-tweet {
                border: 1px solid #ccc; /* 引用ツイートの枠線 */
                padding: 10px;
                margin-top: 10px;
                max-width: 450px; /* リプライの引用ツイートの枠幅を固定 450*/
                word-wrap: break-word;
            }
            .conversation-tweet-middle .media,
            .conversation-tweet-end .media {
                display: flex;
                flex-wrap: wrap;
                border: 1px solid #ccc; /* 画像表示領域の枠線 */
                padding: 3px; /* 枠の内側の余白? 5→3にしてみる */
                max-width: 450px; /* 画像表示領域の枠幅を固定 450 */
                border-left: 2px solid #ccc;
                margin-left: 20px;
                position: relative; /* 位置を相対的にする */
                left: 20px; /* .media 部分のみを右に移動 */
            }
            @media (max-width: 450px) {
                .conversation-tweet-middle .media a,
                .conversation-tweet-end .media a {
                    flex: 0 0 100%; /* 画像が1枚の場合の幅 */
                    max-width: 100%;
                }
            }
            /* 1枚の場合の画像サイズ調整 */
            .conversation-tweet-middle .media a:nth-child(1):only-child img,
            .conversation-tweet-end .media a:nth-child(1):only-child img {
                width: 450px; /* 1枚の画像の幅を450pxに設定 */
            }
            /* 2枚以上の場合の画像サイズ調整 */
            .conversation-tweet-middle .media a img,
            .conversation-tweet-end .media a img {
                width: 225px; /* 2枚以上の画像の幅を225pxに設定 */
            }

        /* 会話ツイート全体で共通のスタイル*/
            .conversation-tweet-start .media::before,
            .conversation-tweet-middle .media::before,
            .conversation-tweet-end .media::before {
                content: "";
                position: absolute;
                top: 0; /* 縦線をメディアの上部に合わせる */
                border-left: 2px solid #ccc;
                left: -22px; /* 縦線を左に移動 */
                height: 100%; /* 縦線の高さを調整 */
                width: 2px;
            }

        /* 更に表示部分 */
        .show-more-replies {
            word-wrap: break-word;
            max-width: 500px;
            border-left: 2px dashed #ccc;
            padding-left: 10px;
            margin-left: 20px;
        }
        </style>
    </head>
    <body>
    """
    if not tweet_data:
        html += "<div>No tweets found.</div>"
        html += "</body></html>"
        return html

    previous_conversation_id = None
    conversation = False
    conversation_tweet_count = 0

    for tweet in tweet_data:
        current_conversation_id = tweet.get('conversation_id')

        if current_conversation_id and current_conversation_id == previous_conversation_id:
            conversation = True
            id_length = len(tweet.get('conversation_id'))

            if conversation_tweet_count == 1: # カウント1=2ツイ目
                if id_length == 2: # ID数2の場合
                    html += f"<div class='conversation-tweet-end' data-user-id-str='{tweet['user_id_str']}' Tweet-Source-Label='{tweet['source']}'>"
                    conversation_tweet_count = 0 # ID数2の場合は2ツイ目でリセット(終端)
                    
                elif id_length == 3: # ID数3の場合
                    html += f"<div class='conversation-tweet-middle' data-user-id-str='{tweet['user_id_str']}' Tweet-Source-Label='{tweet['source']}'>"
                    conversation_tweet_count += 1 # ID数3の場合は3ツイ目があるため加算
                    
                elif id_length >= 4: # ID数4以上の場合
                    # メタデータに4以上のID格納例は多々あるがTL表示上は3が上限と思われ、items配列にも3ツイート分しかデータは格納されていない
                    html += f"<div class='show-more-replies'>"
                    html += f"<br/><a href='{conversation_start_url}' target='_blank'>返信をさらに表示</a><br/></div>"
                    html += f"<div class='conversation-tweet-middle' data-user-id-str='{tweet['user_id_str']}' Tweet-Source-Label='{tweet['source']}'>"
                    conversation_tweet_count += 1 # ID数4以上の場合は3ツイ目があるため加算
                    
            elif conversation_tweet_count == 2: # カウント2=3ツイ目
                html += f"<div class='conversation-tweet-end' data-user-id-str='{tweet['user_id_str']}' Tweet-Source-Label='{tweet['source']}'>"
                conversation_tweet_count = 0 # 3ツイ目の場合はリセット(終端)
                
        elif current_conversation_id and not current_conversation_id == previous_conversation_id: # 会話1ツイ目
            conversation = True
            conversation_start_url = f"https://x.com/{tweet['screen_name']}/status/{tweet['id_str']}"
            html += f"<div class='conversation-tweet-start' data-user-id-str='{tweet['user_id_str']}' Tweet-Source-Label='{tweet['source']}'>"
            conversation_tweet_count = 1 # 会話ツイは最低2ツイ以上構成のため
            
        else:
            html += f"<div class='tweet' data-user-id-str='{tweet['user_id_str']}' Tweet-Source-Label='{tweet['source']}'>" # 親属性

        previous_conversation_id = current_conversation_id # 前回のID(この時点では現在のID)を格納

        # リツイート情報
        if tweet['is_retweet']:
            retweeted_user_info = tweet['retweeted_user_info']
            retweeted_tweet_url = f"https://x.com/{retweeted_user_info['screen_name']}/status/{tweet['id_str']}"
            tweet_url = f"https://x.com/{tweet['screen_name']}/status/{tweet['id_str']}"
            html += f"<div><a href='{tweet_url}' target='_blank'>{tweet['name']}さんがリツイート</a></div>"
            if retweeted_user_info:
                html += f"<div class='user-info'>"
                html += f"<img src='{retweeted_user_info['profile_image_url']}' alt='プロフィール画像' class='profile'>"
                html += f"<span>{retweeted_user_info['name']}</span> <span>@{retweeted_user_info['screen_name']}</span>"
                html += f"<a href='{retweeted_tweet_url}' target='_blank'>{tweet['created_at']}</a>"
                html += "</div>"
        else:
            # プロフィール画像、ユーザー名、スクリーンネーム、作成日時
            html += "<div class='user-info'>"
            html += f"<img src='{tweet['profile_image_url']}' alt='プロフィール画像' class='profile'>"
            html += f"<span>{tweet['name']}</span> <span>@{tweet['screen_name']}</span>"
            tweet_url = f"https://x.com/{tweet['screen_name']}/status/{tweet['id_str']}"
            html += f"<a href='{tweet_url}' target='_blank'>{tweet['created_at']}</a>"
            html += "</div>"
        
        # 本文
        text = tweet['full_text'].replace('\n', '<br>')
        html += f"<div class='text'>{text}</div>"
        
        # メディア(画像および動画)
        if tweet['media']:
            html += "<div class='media'>"
            for media in tweet['media']:
                if 'video_info' in media:  # 動画の場合
                    video_url = None
                    highest_bitrate = 0
                    for variant in media['video_info']['variants']:
                        if variant['content_type'] == 'video/mp4' and variant['bitrate'] > highest_bitrate:
                            highest_bitrate = variant['bitrate']
                            video_url = variant['url']
                    if video_url:
                        html += f"<a href='{video_url}' target='_blank'><video src='{video_url}' controls></video></a>"
                else:  # 画像の場合
                    img_url = media['media_url_https']
                    html += f"<a href='{img_url}' target='_blank'><img src='{img_url}'></a>"
            html += "</div>"
        
        # 引用ツイートの処理
        if tweet['quoted_tweet_info']:
            quoted_tweet = tweet['quoted_tweet_info']
            quoted_tweet_url = f"https://x.com/{quoted_tweet['screen_name']}/status/{quoted_tweet['id_str']}"
            html += "<div class='quoted-tweet'>"
            html += "<div class='user-info'>"
            html += f"<img src='{quoted_tweet['profile_image_url']}' alt='プロフィール画像' class='profile'>"
            html += f"<span>{quoted_tweet['name']}</span> <span>@{quoted_tweet['screen_name']}</span>"
            html += f"<a href='{quoted_tweet_url}' target='_blank'>{quoted_tweet['created_at']}</a>"
            html += "</div>"
            text_with_br = quoted_tweet['full_text'].replace('\n', '<br>')
            html += f"<div class='text'>{text_with_br}</div>"
            if quoted_tweet['media']:
                html += "<div class='media'>"
                for media in quoted_tweet['media']:
                    if 'video_info' in media:  # 引用ツイート内の動画の場合
                        video_url = None
                        highest_bitrate = 0
                        for variant in media['video_info']['variants']:
                            if variant['content_type'] == 'video/mp4' and variant['bitrate'] > highest_bitrate:
                                highest_bitrate = variant['bitrate']
                                video_url = variant['url']
                        if video_url:
                            html += f"<a href='{video_url}' target='_blank'><video src='{video_url}' controls></video></a>"
                    else:  # 引用ツイート内の画像の場合
                        img_url = media['media_url_https']
                        html += f"<a href='{img_url}' target='_blank'><img src='{img_url}'></a>"
                html += "</div>" # mediaの終了タグ
            html += "</div>" # 引用ツイートの終了タグ

        html += "</div>" # .tweetの終了タグ
        
        if conversation:
            html += "</div>" # 会話ツイートを終了
            conversation = False

        previous_conversation_id = current_conversation_id

    html += "</body></html>"

    return html


def main():
    with open("List_respons.json", mode="r", encoding='utf-8') as f:
        buf1 = json.load(f)

    respons = [buf1] # 諸事情によりリストに変換

    path = 'data.list.tweets_timeline.timeline.instructions' # List timeline
    tweets = extract_tweet_data(respons, path)
    html_output = generate_html(tweets)

    with open('tweets.json', 'w', encoding='utf-8') as f:
        json.dump(tweets, f, ensure_ascii=False, indent=4)
    print(f"抽出結果をJSONに保存しました。")

    with open('List_tweets.html', 'w', encoding='utf-8') as file:
        file.write(html_output)
    print("HTMLファイル(List_tweets.html)に保存しました。")

if __name__ == '__main__':
    main()



"""
def sanitize_filename(filename):
    # 無効な文字を削除してファイル名を安全なものにする
    return filename.replace("?", "_").replace("&", "_").replace("=", "_").replace(":", "_")

def download_file(url, save_path):
    response = requests.get(url, stream=True)
    with open(save_path, 'wb') as file:
        for chunk in response.iter_content(chunk_size=8192):
            file.write(chunk)

def process_html(file_path, save_dir):
    with open(file_path, 'r', encoding='utf-8') as file:
        soup = BeautifulSoup(file, 'html.parser')

    # 動画タグの処理
    video_tags = soup.find_all('video')
    for video_tag in video_tags:
        video_url = video_tag.get('src')
        if video_url:
            # クエリパラメータを削除してファイル名を作成
            video_filename = os.path.basename(urlparse(video_url).path)
            video_filename = sanitize_filename(video_filename)
            local_video_path = os.path.join(save_dir, video_filename)

            # ダウンロードしていない場合のみダウンロード
            if not os.path.exists(local_video_path):
                print(f"Downloading {video_url} to {local_video_path}")
                download_file(video_url, local_video_path)
            
            # src属性をローカルパスに変更
            video_tag['src'] = os.path.join(os.path.basename(save_dir), video_filename)

    # リンクタグの処理
    a_tags = soup.find_all('a')
    for a_tag in a_tags:
        href = a_tag.get('href')
        if href:
            parsed_url = urlparse(href)
            if parsed_url.scheme in ['http', 'https']:
                filename = os.path.basename(parsed_url.path)
                filename = sanitize_filename(filename)
                local_file_path = os.path.join(save_dir, filename)
                
                # URLがローカルに存在する場合にリンクをローカルファイルに変更
                if os.path.exists(local_file_path):
                    a_tag['href'] = os.path.join(os.path.basename(save_dir), filename)

    # 修正されたHTMLを保存
    with open(file_path, 'w', encoding='utf-8') as file:
        file.write(str(soup))

main():
    html_file_path = '完全.html'  # ブラウザで「名前をつけて保存」したHTMLファイルのパス
    save_directory = '完全_files'  # ブラウザで保存されたファイルのディレクトリ(通常は "HTMLファイル名_files")

    process_html(html_file_path, save_directory)

if __name__ == "__main__":

"""

タイムラインは階層構造上部が違う以外はほぼ同じ仕様
階層上部をパス指定して取得しにいく仕様に変えたことで汎用的にいろんなタイムラインに対応できている、、、んじゃないかなあ(という希望)

ホームタイムラインの固定ツイートはたぶん抽出できない。
おすすめに流れてくるツイート左上にある固定ツイートだの誰々さんが何々だのにも対応はできない。正常に取得できるのかも不明(レスポンスの中身自体未確認なので)

ちなみに抽出したデータは下記のような構造で格納されるよ。

[
    {
        "source": "Twitter Web App",
        "id_str": "0000000000000000000",
        "user_id_str": "000000000",
        "name": "xxxx",
        "screen_name": "xxxxxxxxxxxx",
        "created_at": "2024/07/29 17:18",
        "full_text": "“xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
        "profile_image_url": "https://pbs.twimg.com/profile_images/xxxxxxxxxxxxxxxxxxx/xxxxxxxx_normal.jpg",
        "is_retweet": true,
        "retweeted_user_info": {
            "name": "xxxxxxx",
            "screen_name": "xxxxxxxxxxxxx",
            "profile_image_url": "https://pbs.twimg.com/profile_images/xxxxxxxxxxxxxxxxxxx/xxxxxxxx_normal.png"
        },
        "media": [],
        "quoted_tweet_info": null,
        "conversation_id": null
    },
    {
        "source": "Userlocal SocialInsight",
        "id_str": "xxxxxxxxxxxxxxxxxxx",
        "user_id_str": "xxxxxxxxx",
        "name": "xxxx",
        "screen_name": "xxxxxxxxxxxx",
        "created_at": "2024/07/29 17:00",
        "full_text": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
        "profile_image_url": "https://pbs.twimg.com/profile_images/xxxxxxxxxxxxxxxxxxx/xxxxxxxx_normal.jpg",
        "is_retweet": false,
        "retweeted_user_info": null,
        "media": [],
        "quoted_tweet_info": null,
        "conversation_id": null
    },
	{
        "source": "Twitter Web App",
        "id_str": "xxxxxxxxxxxxxxxxxxx",
        "user_id_str": "xxxxxxxx",
        "name": "xxxxxxxxx",
        "screen_name": "xxxxxxxxxxxx",
        "created_at": "2024/07/29 12:30",
        "full_text": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
        "profile_image_url": "https://pbs.twimg.com/profile_images/xxxxxxxxxxxxxxxxxxx/xxxxxxxx_normal.png",
        "is_retweet": false,
        "retweeted_user_info": null,
        "media": [],
        "quoted_tweet_info": null,
        "conversation_id": [
            "xxxxxxxxxxxxxxxxxxx",
            "xxxxxxxxxxxxxxxxxxx"
        ]
    }
]

TL構成に最低限必要そうなのはこれくらい。
mediaに関しては動画のビットレート判定とかもあるので丸ごと格納してる。

リツイートは文字通り「リ」ツイート。
つまりリツイートした人のツイート扱いだったりする。
だから「ツイートとしてのURL」が存在しているんだけど通常の利用ではこれを見るのが難しく、その対策としてHTMLに出力した際に「リツイートのツイートURL(ややこしい)」へのリンクを生成しているのでそのURLを把握しやすいかも?

今回はこんなかんじ。

この記事が気に入ったらサポートをしてみませんか?