マヨケーがポアされたため、現在はロシケーがメインとなっています。

「利用者:夜泣き/スクリプト」の版間の差分

提供:唐澤貴洋Wiki
ナビゲーションに移動 検索に移動
>Fet-Fe
(→‎コード: v4.0.0 最低限動くだけのものをとりとり)
(→‎コード: v4.4.2 WikiのURLをkrsw-wiki.inに変更)
 
(他の1人の利用者による、間の39版が非表示)
4行目: 4行目:
‎<syntaxhighlight lang="python3" line>
‎<syntaxhighlight lang="python3" line>
#!/usr/bin/env python3
#!/usr/bin/env python3
# © 2022 恒心教 (Koushinism)
# Released under the MIT license
# https://opensource.org/licenses/mit-license.php


"""Twitter自動収集スクリプト
"""Twitter自動収集スクリプト


ver4.0.0 2023/9/23恒心
ver4.4.2 2024/11/9恒心


当コードは恒心停止してしまった https://rentry.co/7298g の降臨ショーツイート自動収集スクリプトの復刻改善版です
当コードは恒心停止してしまった https://rentry.co/7298g の降臨ショーツイート自動収集スクリプトの復刻改善版です。
前開発者との出会いに感謝
前開発者との出会いに感謝。
本スクリプトは `archive.today <https://archive.today>`_ からWikiに未掲載のツイートを収集します。


Examples:
Examples:
  定数類は状況に応じて変えてください。
    定数類は状況に応じて変えてください。:class:`~UserProperties` で変更できます。
  ::
 
    ::
 
        $ python3 (ファイル名)
 
    オプションに ``--krsw`` とつけると自動モードになります。


     $ python3 (ファイル名)
     ::


  コマンドライン引数に ``krsw`` とつけると自動モードになります。
        $ python3 (ファイル名) --krsw
  ::


     $ python3 (ファイル名) krsw
     自動モードではユーザーは降臨ショー、クエリはなし、取得上限まで自動です。
    つまりユーザー入力が要りません。


  自動モードではユーザーは降臨ショー、クエリはなし、取得上限まで自動です。
    ``--no-browser`` オプションでTor Browserを使用しないモードに、
  つまりユーザー入力が要りません。
    ``--disable-script`` オプションでTor BrowserのJavaScriptを使用しないモードになります。
    ``--search-unarchived`` オプションでは、従来の通りNitterからツイートを収集するモードになります (廃止予定)。


Note:
Note:
  * Pythonのバージョンは3.10以上
    * Pythonのバージョンは3.12以上
  * 環境は玉葱前提です。
    * 環境は玉葱前提です。
  * Whonix-Workstation, MacOSで動作確認済
 
        * TailsやWhonixでない場合、Tor Browserを入れておくか、torコマンドでプロキシを立てておくことが必要です。
 
    * MacOSで動作確認済
 
        * MacOSの場合はTor Browserをダウンロードするかbrewでtorコマンドを導入してから実行する
        * Whonix-WorkstationなどLinuxやWindowsでの動作が未確認です。確認と修正をお願いします


     * MacOSの場合はbrewでtorコマンドを導入し、実行
     * PySocks, bs4, seleniumはインストールしないと標準で入ってません
    * requests, typing_extensionsも環境によっては入っていないかもしれない


  * PySocks, bs4, seleniumはインストールしないと標準で入ってません
    .. code-block:: bash
  * requestsも環境によっては入っていないかもしれない


    * $ pip install bs4 requests PySocks selenium
        $ python3 -m pip install bs4 requests PySocks selenium typing_extensions


  * pipも入っていなければ ``$ sudo apt install pip``
    * pipも入っていなければ ``$ sudo apt install pip``
  * `ffmpeg <https://ffmpeg.org>`_ が入っていると動画も自動取得しますが、無くても動きます
    * `ffmpeg <https://ffmpeg.org>`_ が入っていると動画も自動取得しますが、無くても動きます
  * バグ報告は `利用者・トーク:夜泣き#利用者:夜泣き/スクリプトについて <https://krsw-wiki.org/wiki/利用者・トーク:夜泣き#利用者:夜泣き/スクリプトについて>`_
    * バグ報告は `利用者・トーク:夜泣き#利用者:夜泣き/スクリプトについて \
        <https://krsw-wiki.in/wiki/利用者・トーク:夜泣き#利用者:夜泣き/スクリプトについて>`_
"""
"""


#インポート類
import json
import sys
import logging
import os
import os
import codecs
import platform
import random
import re
import re
import json
import shutil
import random
import signal
import subprocess
import sys
import warnings
from abc import ABCMeta, abstractmethod
from argparse import ArgumentParser, Namespace
from collections import deque
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime
from enum import Enum
from pathlib import Path
from time import sleep
from traceback import TracebackException
from types import FrameType, MappingProxyType, TracebackType
from typing import (Final, NamedTuple, NoReturn, Self, TextIO, assert_never,
                    final, override)
from urllib.parse import (ParseResult, parse_qs, quote, unquote, urljoin,
                          urlparse)
from zoneinfo import ZoneInfo
from zoneinfo import ZoneInfo
from time import sleep
from types import MappingProxyType
from typing import Final, NoReturn, Any, Self
from collections.abc import Callable
from urllib.parse import quote, unquote, urljoin
import warnings
import subprocess
import platform


import requests
import requests
import bs4
from bs4 import BeautifulSoup
from bs4 import BeautifulSoup
from selenium import webdriver
from bs4.element import NavigableString, ResultSet, Tag
from selenium.common.exceptions import NoSuchElementException, WebDriverException
from selenium.common.exceptions import (InvalidSwitchToTargetException,
from selenium.webdriver.firefox.options import Options
                                        WebDriverException)
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver import Firefox as FirefoxDriver
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By
from selenium.webdriver.common.by import By
from selenium.webdriver.firefox.options import Options as FirefoxOptions
from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.wait import WebDriverWait
from typing_extensions import deprecated
@dataclass(init=False, eq=False, frozen=True)
class UserProperties:
    """ユーザ設定。
    実行時に変更する可能性のある定数はここで定義する。
    """
    media_dir: Final[str] = 'tweet_media'
    """Final[str]: 画像などのツイートメディアをダウンロードするディレクトリ。"""
    filename: Final[str] = 'tweet.txt'
    """Final[str]: ツイートを保存するファイルの名前。"""
    log_file: Final[str] = 'twitter_archiver.log'
    """Final[str]: ログを保存するファイルの名前。"""
    @dataclass(init=False, eq=False, frozen=True)
    class NitterCrawler:
        """``--search-unarchived`` オプション有りの時に利用する設定値。"""
        limit_n_tweets: Final[int] = 100
        """Final[int]: 取得するツイート数の上限。"""
        report_interval: Final[int] = 5
        """Final[int]: 記録件数を報告するインターバル。"""
        nitter_instance: Final[str] = 'https://nitter.poast.org/'  # noqa: E501
        """Final[str]: Nitterのインスタンス。
        生きているのは https://github.com/zedeus/nitter/wiki/Instances で確認。
        Note:
            末尾にスラッシュ必須。
            インスタンスによっては画像の取得ができない。
            Tor用のインスタンスでないと動画の取得ができない。
        """
    @dataclass(init=False, eq=False, frozen=True)
    class ArchiveCrawler:
        """``--search-unarchived`` オプション無しの時に使用する設定値。"""
        url_list_filename: Final[str] = 'url_list.txt'
        """Final[str]: URLのリストをダンプするファイル名。"""


##おそらくはツイートの内容によってMarkupResemblesLocatorWarningが吐き出されることがあるので無効化
# ログ設定
warnings.simplefilter('ignore')
# basicConfigでレベルを設定するとモジュールのDEBUGログなども出力される
formatter: Final[logging.Formatter] = logging.Formatter(
    fmt='{asctime} [{levelname:.4}] : {message}', style='{')
logger: Final[logging.Logger] = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
# 標準エラー出力へのログ出力
stream_handler: Final[logging.StreamHandler[TextIO]] = logging.StreamHandler()
stream_handler.setLevel(logging.INFO)
stream_handler.setFormatter(formatter)
logger.addHandler(stream_handler)
# ファイルへのログ出力
file_handler: Final[logging.FileHandler] = (
    logging.FileHandler(UserProperties.log_file))
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)




class AccessError(Exception):
class AccessError(Exception):
  """RequestとSeleniumで共通のアクセスエラー。
    """RequestsとSeleniumで共通のアクセスエラー。"""
  """
    pass
  pass
 
 
class ReCaptchaRequiredError(Exception):
    """JavaScriptがオフの時にreCAPTCHAを要求された場合のエラー。"""
    pass




class AbstractAccessor:
class AbstractAccessor(metaclass=ABCMeta):
  """HTTPリクエストでWebサイトに接続するための基底クラス。
    """HTTPリクエストでWebサイトに接続するための基底クラス。"""
  """


  WAIT_TIME: Final[int] = 1
    WAIT_TIME: Final[int] = 1
  """Final[int]: HTTPリクエスト成功失敗関わらず待機時間。
    """Final[int]: HTTPリクエスト成功失敗関わらず待機時間(秒)。


  1秒待つだけで行儀がいいクローラーだそうなので既定では1秒。
    Note:
  しかし日本のポリホーモは1秒待っていても捕まえてくるので注意。
        1秒待つだけで行儀がいいクローラーだそうなので既定では1秒。
  https://ja.wikipedia.org/wiki/?curid=2187212
        しかし日本のポリホーモは1秒待っていても捕まえてくるので注意 [1]_。
  """


  WAIT_RANGE: Final[int] = 5
    References:
  """Final[int]: ランダムな時間待機するときの待機時間の幅。
        .. [1] 岡崎市立中央図書館事件. (2022, June 21). In Wikipedia.
  """
          https://ja.wikipedia.org/wiki/?curid=2187212&oldid=79121945
    """


  REQUEST_TIMEOUT: Final[int] = 30
    WAIT_RANGE: Final[int] = 5
  """Final[int]: HTTPリクエストのタイムアウト秒数。
    """Final[int]: ランダムな時間待機するときの待機時間の幅(秒)。"""
  """


  def _random_sleep(self) -> None:
    REQUEST_TIMEOUT: Final[int] = 30
     """ランダムな秒数スリープする。
     """Final[int]: HTTPリクエストのタイムアウト秒数。"""


     自動操縦だとWebサイトに見破られないため。
     @abstractmethod
     """
     def get(self, url: str) -> str:
    random.randrange(self.WAIT_TIME, self.WAIT_TIME + self.WAIT_RANGE)
        """URLにアクセスして、HTMLを取得する。


        Args:
            url (str): 接続するURL。


class RequestsAccessor(AbstractAccessor):
        Raises:
  """requestsモジュールでWebサイトに接続するためのクラス。
            AccessError: 通信に失敗した場合のエラー。
  """


  HEADERS: Final[dict[str, str]] = {
        Returns:
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0'
            str: レスポンスのHTML。
  }
        """
  """Final[dict[str, str]]: HTTPリクエスト時のヘッダ。
        ...
  """


  PROXIES_WITH_COMMAND: Final[dict[str, str]] = {
    @abstractmethod
     'http': 'socks5h://127.0.0.1:9050',
     def set_cookies(self, cookies: dict[str, str]) -> None:
    'https': 'socks5h://127.0.0.1:9050'
        """Cookieをセットする。
  }
  """Final[dict[str, str]]: torコマンドを起動しているときのHTTPプロキシの設定。
  """


  PROXIES_WITH_BROWSER: Final[dict[str, str]] = {
        Args:
    'http': 'socks5h://127.0.0.1:9150',
            cookies (dict[str, str]): Cookieのキーバリューペア。
    'https': 'socks5h://127.0.0.1:9150'
        """
  }
        ...
  """Final[dict[str, str]]: Tor Browserを起動しているときのHTTPプロキシの設定。
  """


  TOR_CHECK_URL: Final[str] = 'https://check.torproject.org/api/ip'
    @final
  """Final[str]: Tor経由で通信しているかチェックするサイトのURL。
    def _random_sleep(self) -> None:
  """
        """ランダムな秒数スリープする。


  def __init__(self):
        自動操縦だとWebサイトに見破られないため。
    """コンストラクタ。
        """
    """
        sleep(random.randrange(self.WAIT_TIME,
                              self.WAIT_TIME + self.WAIT_RANGE))


    self._proxies: dict[str, str] | None = None
    self._proxies = self._get_tor_proxies() ##Torに必要なプロキシをセット


  def _execute(self, url: Final[str], proxies: dict[str, str]) -> requests.models.Response:
class RequestsAccessor(AbstractAccessor):
     """引数のURLにrequestsモジュールでHTTP接続する。
     """RequestsモジュールでWebサイトに接続するためのクラス。"""


     Args:
     HEADERS: Final[dict[str, str]] = {
      url Final[str]: 接続するURL。
        'User-Agent':
      proxies dir[str, str]: 接続に利用するプロキシ。デフォルトでは :func:`~_get_tor_proxies` で設定した値を利用する。
            'Mozilla/5.0 (X11; Linux i686; rv:109.0) Gecko/20100101 Firefox/120.0'  # noqa: E501
    }
    """Final[dict[str, str]]: HTTPリクエスト時のヘッダ。"""


     Returns:
     PROXIES_WITH_BROWSER: Final[dict[str, str]] = {
      requests.models.Response: レスポンスのオブジェクト。
        'http': 'socks5h://127.0.0.1:9150',
        'https': 'socks5h://127.0.0.1:9150'
    }
    """Final[dict[str, str]]: Tor Browserを起動しているときのHTTPプロキシの設定。"""


     Raises:
     PROXIES_WITH_COMMAND: Final[dict[str, str]] = {
      requests.exceptions.ConnectionError: Torで接続ができないなど、接続のエラー。
        'http': 'socks5h://127.0.0.1:9050',
      AccessError: ステータスコードが200でない場合のエラー。
        'https': 'socks5h://127.0.0.1:9050'
    """
    }
    sleep(self.WAIT_TIME) ##DoS対策で待つ
     """Final[dict[str, str]]: torコマンドを起動しているときのHTTPプロキシの設定。"""
    try:
      res: requests.models.Response = requests.get(url, timeout=self.REQUEST_TIMEOUT, headers=self.HEADERS, allow_redirects=False, proxies=proxies if proxies is not None else self._proxies)
      res.raise_for_status() ##HTTPステータスコードが200番台以外でエラー発生
    except requests.exceptions.ConnectionError:
      raise
     except requests.exceptions.RequestException as e:
      # requestsモジュール固有の例外を共通の例外に変換
      raise AccessError(str(e)) from None
    return res


  def get(self, url: Final[str], proxies: dict[str, str]=None) -> str:
    TOR_CHECK_URL: Final[str] = 'https://check.torproject.org/api/ip'
    """引数のURLにrequestsモジュールでHTTP接続する。
    """Final[str]: Tor経由で通信しているかチェックするサイトのURL。"""


     Args:
     def __init__(self) -> None:
      url Final[str]: 接続するURL。
        # Torに必要なプロキシをセット
      proxies dir[str, str]: 接続に利用するプロキシ。デフォルトでは :func:`~_get_tor_proxies` で設定した値を利用する。
        self._cookies: dict[str, str] = {}
        self._proxies: dict[str, str] | None = self._choose_tor_proxies()


     Returns:
     def _execute(self, url: str) -> requests.models.Response:
      str: レスポンスのHTML。
        """引数のURLにRequestsモジュールでHTTP接続する。


    Raises:
        Args:
      requests.exceptions.ConnectionError: Torで接続ができないなど、接続のエラー。
            url (str): 接続するURL。
      AccessError: ステータスコードが200でない場合のエラー。
    """
    try:
      return self._execute(url, proxies).text
    except (requests.exceptions.ConnectionError, AccessError):
      raise


  def get_image(self, url: Final[str]) -> bytes | None:
        Raises:
    """引数のURLから画像のバイナリ列を取得する。
            requests.HTTPError: ステータスコードが200でない場合のエラー。


    Args:
        Returns:
      url Final[str]: 接続するURL
            requests.models.Response: レスポンスのオブジェクト。
        """
        sleep(self.WAIT_TIME)  # DoS対策で待つ
        res: Final[requests.models.Response] = requests.get(
            url,
            timeout=self.REQUEST_TIMEOUT,
            headers=self.HEADERS,
            allow_redirects=False,
            proxies=self._proxies,
            cookies=self._cookies)
        res.raise_for_status()  # HTTPステータスコードが200番台以外でエラー発生
        return res


     Returns:
     @override
      bytes | None: 画像のバイナリ。画像でなければNone。
    def get(self, url: str) -> str:
        try:
            return self._execute(url).text
        except (requests.HTTPError, requests.ConnectionError) as e:
            raise AccessError from e


     Raises:
     @override
      requests.exceptions.ConnectionError: Torで接続ができないなど、接続のエラー。
    def set_cookies(self, cookies: dict[str, str]) -> None:
      AccessError: ステータスコードが200でない場合のエラー。
        self._cookies.update(cookies)
    """
    try:
      res: requests.models.Response = self._execute(url, self._proxies)
    except (requests.exceptions.ConnectionError, AccessError):
      raise


     if 'image' in res.headers['content-type']:
     def get_image(self, url: str) -> bytes | None:
      return res.content
        """引数のURLから画像のバイナリ列を取得する。
    else:
      return None


  def _get_tor_proxies(self) -> dict[str, str] | None | NoReturn:
        Args:
    """Torを使うのに必要なプロキシ情報を返す。
            url (str): 接続するURL。


    プロキシなしで接続できればNone、Tor Browserのプロキシで接続できるなら :const:`~PROXIES_WITH_BROWSER`、torコマンドのプロキシで接続できるなら :const:`~PROXIES_WITH_COMMAND`。
        Raises:
    いずれでもアクセスできなければ異常終了する。
            AccessError: アクセスに失敗した場合のエラー。


    Returns:
        Returns:
      dict[str, str] | None | NoReturn: プロキシ情報。
            bytes | None: 画像のバイナリ。画像でなければ `None`。
        """
        try:
            res: Final[requests.models.Response] = self._execute(url)
        except (requests.HTTPError, requests.ConnectionError) as e:
            raise AccessError from e


    Raises:
        if 'image' in res.headers['content-type']:
      RuntimeError: :const:`~TOR_CHECK_URL` にアクセスできない場合のエラー。
            return res.content
    """
        else:
    print('Torのチェック中ですを')
            return None
    # プロキシなしでTorにアクセスできるかどうか
    res: str = self.get(self.TOR_CHECK_URL, proxies=None)
    is_tor: bool = json.loads(res)['IsTor']
    if is_tor:
      print('Tor connection OK')
      return None


     # Tor BrowserのプロキシでTorにアクセスできるかどうか
     def _choose_tor_proxies(self) -> dict[str, str] | None:
    res = self.get(self.TOR_CHECK_URL, proxies=self.PROXIES_WITH_BROWSER)
        """Torを使うのに必要なプロキシ情報を返す。
    is_tor = json.loads(res)['IsTor']
    if is_tor:
      print('Tor connection OK')
      return self.PROXIES_WITH_BROWSER


    # torコマンドのプロキシでTorにアクセスできるかどうか
        プロキシなしで接続できれば `None`、
    try:
        Tor Browserのプロキシで接続できるなら :const:`~PROXIES_WITH_BROWSER`、
      res = self.get(self.TOR_CHECK_URL, proxies=self.PROXIES_WITH_COMMAND)
         torコマンドのプロキシで接続できるなら :const:`~PROXIES_WITH_COMMAND`。
      is_tor = json.loads(res)['IsTor']
         いずれでもアクセスできなければ異常終了する。
      if is_tor:
         print('Tor proxy OK')
        return self.PROXIES_WITH_COMMAND
      else:
         raise RuntimeError('サイトにTorのIPでアクセスできていないなりを')
    except requests.exceptions.ConnectionError as e:
      print(e, file=sys.stderr)
      print('通信がTorのSOCKS proxyを経由していないなりを', file=sys.stderr)
      exit(1)


  @property
        Raises:
  def proxies(self) -> dict[str, str] | None:
            AccessError: :const:`~TOR_CHECK_URL` にアクセスできない場合のエラー。
    """オブジェクトのプロキシ設定を返す。
    """
    return self._proxies


        Returns:
            dict[str, str] | None: プロキシ情報。
        """
        logger.info('Torのチェック中ですを')
        # プロキシなしでTorにアクセスできるかどうか
        self._proxies = None
        res: str = self._execute(self.TOR_CHECK_URL).text
        is_tor: bool = json.loads(res)['IsTor']
        if is_tor:
            logger.info('Tor connection OK')
            return None


class SeleniumAccessor(AbstractAccessor):
        # Tor BrowserのプロキシでTorにアクセスできるかどうか
  """SeleniumでWebサイトに接続するためのクラス。
        try:
  """
            self._proxies = self.PROXIES_WITH_BROWSER
            res = self._execute(self.TOR_CHECK_URL).text
            is_tor = json.loads(res)['IsTor']
            if is_tor:
                logger.info('Tor browser connection OK')
                return self.PROXIES_WITH_BROWSER
        except requests.exceptions.ConnectionError:
            pass


  TOR_BROWSER_PATHS: MappingProxyType[str, str] = MappingProxyType({
        # torコマンドのプロキシでTorにアクセスできるかどうか
      "Windows": "",
        try:
      "Darwin": "/Applications/Tor Browser.app/Contents/MacOS/firefox",
            self._proxies = self.PROXIES_WITH_COMMAND
      "Linux": ""
            res = self._execute(self.TOR_CHECK_URL).text
  })
            is_tor = json.loads(res)['IsTor']
  """MappingProxyType[str, str]: OSごとのTor Browserのパス。
            if is_tor:
  """
                logger.info('Tor proxy OK')
                return self.PROXIES_WITH_COMMAND
            else:
                raise AccessError('サイトにTorのIPでアクセスできていないなりを')
        except requests.exceptions.ConnectionError:
            logger.critical('通信がTorのSOCKS proxyを経由していないなりを', exc_info=True)
            sys.exit(1)


  WAIT_TIME_FOR_INIT: Final[int] = 15
    @property
  """Final[int]: 最初のTor接続時の待機時間。
    def proxies(self) -> dict[str, str] | None:
  """
        """dict[str, str] | None: プロキシ設定。"""
        return self._proxies


  def __init__(self, enable_javascript: bool):
    """コンストラクタ。


     Tor Browserを自動操縦するためのSeleniumドライバを初期化する。
class SeleniumAccessor(AbstractAccessor):
     """SeleniumでWebサイトに接続するためのクラス。


     Args:
     Args:
      enable_javascript bool: JavaScriptを有効にするかどうか。reCAPTCHA対策に必要。
        enable_javascript (bool): JavaScriptを有効にするかどうか。reCAPTCHA対策に必要。
     """
     """


     options: Options = Options()
     TOR_BROWSER_PATHS: Final[MappingProxyType[str, str]] = MappingProxyType({
     options.binary_location = self.TOR_BROWSER_PATHS[platform.system()]
        'Windows': r'C:\Program Files\Tor Browser\Browser\firefox.exe',
        'Darwin': '/Applications/Tor Browser.app/Contents/MacOS/firefox',
        'Linux': '/usr/bin/torbrowser'
    })
     """Final[MappingProxyType[str, str]]: OSごとのTor Browserのパス。"""
 
    WEB_DRIVER_WAIT_TIME: Final[int] = 15
    """Final[int]: 最初のTor接続時の待機時間(秒)。"""


     if enable_javascript:
     WAIT_TIME_FOR_RECAPTCHA: Final[int] = 10_000
      print("reCAPTCHA対策のためJavaScriptをonにしますを")
    """Final[int]: reCAPTCHAのための待機時間(秒)。"""


     options.preferences.update({
     def __init__(self, enable_javascript: bool) -> None:
      "javascript.enabled": enable_javascript,
        self._options: Final[FirefoxOptions] = FirefoxOptions()
      "intl.accept_languages": "en-US, en",
        self._options.binary_location = (
      "intl.locale.requested": "US",
            self.TOR_BROWSER_PATHS[platform.system()]
      "font.language.group": "x-western",
        )
      "dom.webdriver.enabled": False # 自動操縦と見破られないための設定
        self._options.add_argument('--user-data-dir=selenium')  # pyright: ignore [reportUnknownMemberType] # noqa: E501
    })
 
        if enable_javascript:
            logger.warning('reCAPTCHA対策のためJavaScriptをonにしますを')


    self.driver: webdriver.Firefox = webdriver.Firefox(options=options)
        self._options.set_preference('javascript.enabled', enable_javascript)
    sleep(1)
        self._options.set_preference('intl.accept_languages', 'en-US, en')
    wait_init: WebDriverWait = WebDriverWait(self.driver, self.WAIT_TIME_FOR_INIT)
        self._options.set_preference('intl.locale.requested', 'US')
    wait_init.until(EC.element_to_be_clickable((By.ID, "connectButton")))
        self._options.set_preference('font.language.group', 'x-western')
    self.driver.find_element(By.ID, "connectButton").click()
        # 自動操縦と見破られないための設定
    wait_init.until(EC.url_contains("about:blank")) # Torの接続が完了するまで待つ
        self._options.set_preference('dom.webdriver.enabled', False)
        self._refresh_browser()


     self.wait: WebDriverWait = WebDriverWait(self.driver, self.REQUEST_TIMEOUT)
     def quit(self) -> None:
        """Seleniumドライバを終了する。"""
        if hasattr(self, '_driver'):
            logger.debug('ブラウザ終了')
            self._driver.quit()


  def quit(self) -> None:
    def _refresh_browser(self) -> None:
    """Seleniumドライバを終了する。
        """ブラウザを起動する。"""
    """
        try:
    if self.driver:
            logger.debug('ブラウザ起動')
      self.driver.quit()
            self._driver: FirefoxDriver = FirefoxDriver(
                options=self._options)
            sleep(1)
            web_driver_wait: Final[WebDriverWait[FirefoxDriver]] = (
                WebDriverWait(self._driver, self.WEB_DRIVER_WAIT_TIME))
            web_driver_wait.until(
                ec.element_to_be_clickable((By.ID, 'connectButton'))
            )
            self._driver.find_element(By.ID, 'connectButton').click()
            # Torの接続が完了するまで待つ
            web_driver_wait.until(ec.url_contains('about:blank'))
        except BaseException:
            self.quit()
            raise


  def _check_recaptcha(self) -> None:
    def _check_recaptcha(self, url: str) -> None:
    """reCAPTCHAが表示されているかどうか判定して、入力を待機する。
        """reCAPTCHAが表示されているかどうか判定して、入力を待機する。
    """
    try:
      self.driver.find_element(By.CSS_SELECTOR, 'script[src^="https://www.google.com/recaptcha/api.js"]') # 要素がない時に例外を吐く
      print("reCAPTCHAを解いてね(笑)、それはできるよね。")
      print("botバレしたらNew Tor circuit for this siteを選択するナリよ")
      WebDriverWait(self.driver, 10000).until(EC.staleness_of(self.driver.find_element(By.CSS_SELECTOR, 'script[src^="https://www.google.com/recaptcha/api.js"]')))
      sleep(self.WAIT_TIME) ##DoS対策で待つ
    except NoSuchElementException:
      # reCAPTCHAの要素がなければそのまま
      pass


  def get(self, url: Final[str]) -> str:
        botであることが検知された場合、自動でブラウザを再起動する。
    """引数のURLにSeleniumでHTTP接続する。


    Args:
        Args:
      url Final[str]: 接続するURL。
            url (str): アクセスしようとしているURL。\
                reCAPTCHAが要求されると `current_url` が変わることがあるので必要。


    Returns:
        Raises:
      str: レスポンスのHTML。
            ReCaptchaRequiredError: JavaScriptがオフの状態でreCAPTCHAが要求された場合のエラー。
    """
        """
    self._random_sleep() ##DoS対策で待つ
        iframe_selector: Final[str] = (
    try:
            'iframe[title="recaptcha challenge expires in two minutes"]')
      self.driver.get(url)
      self._check_recaptcha()
    except WebDriverException as e:
      # Selenium固有の例外を共通の例外に変換
      raise AccessError(str(e)) from None
    return self.driver.page_source


        if len(self._driver.find_elements(By.ID, 'g-recaptcha')) > 0:
            if self._options.preferences.get('javascript.enabled'):  # pyright: ignore[reportUnknownMemberType] # noqa: E501
                logger.warning(f'{url} でreCAPTCHAが要求されたナリ')
                print('reCAPTCHAを解いてね(笑)、それはできるよね。\a\a\a')
                print('botバレしたら自動でブラウザが再起動するナリよ')
                print('Tips: カーソルを迷ったように動かすとか、人間らしく振る舞うのがコツナリ')
                WebDriverWait(self._driver, self.WEB_DRIVER_WAIT_TIME).until(
                    ec.presence_of_element_located(
                        (By.CSS_SELECTOR, iframe_selector)
                    )
                )


class AccessorHandler:
                self._driver.switch_to.frame(  # reCAPTCHAのフレームに遷移する
  """WebサイトからHTMLを取得するためのクラス。
                    self._driver.find_element(By.CSS_SELECTOR, iframe_selector)
                )
                try:
                    WebDriverWait(
                        self._driver, self.WAIT_TIME_FOR_RECAPTCHA
                    ).until(
                        ec.visibility_of_element_located(
                            # bot検知された場合に現れるクラス
                            (By.CLASS_NAME, 'rc-doscaptcha-header')
                        )
                    )
                except InvalidSwitchToTargetException:
                    # reCAPTCHAのフレームがなくなっていた場合
                    logger.info('reCAPTCHAが解かれましたを')
                    self._driver.switch_to.default_content()
                    self._random_sleep()  # DoS対策で待つ
                else:
                    # waitを普通に抜けた場合
                    logger.warning('botバレしたなりを')
                    # 一回ブラウザを落として起動し直す
                    self.quit()
                    self._refresh_browser()
                    self.get(url)
            else:
                raise ReCaptchaRequiredError(
                    f'JavaScriptがオフの状態でreCAPTCHAが要求されました。 URL: {url}')


  RequestsとSeleniumのどちらかを選択して使用することができ、その違いを隠蔽する。
    @override
  """
    def get(self, url: str) -> str:
        self._random_sleep()  # DoS対策で待つ
        try:
            self._driver.get(url)
            WebDriverWait(self._driver, self.WEB_DRIVER_WAIT_TIME).until_not(
                ec.any_of(
                    ec.title_is('Redirecting'),
                    ec.title_is('Verifying your browser | Nitter')
                )
            )  # Nitterのリダイレクトを検知する
            self._check_recaptcha(url)
        except WebDriverException as e:
            # Selenium固有の例外を共通の例外に変換
            raise AccessError from e
        sleep(5)
        return self._driver.page_source


  LIMIT_N_REQUESTS: Final[int] = 5
    @override
  """Final[int]: HTTPリクエスト失敗時の再試行回数。
    def set_cookies(self, cookies: dict[str, str]) -> None:
  """
        for name, value in cookies.items():
            self._driver.add_cookie(  # pyright: ignore [reportUnknownMemberType] # noqa: E501
                {'name': name, 'value': value})
        self._driver.refresh()


  WAIT_TIME_FOR_ERROR: Final[int] = 4
  """Final[int]: HTTPリクエスト失敗時にさらに追加する待機時間。
  """


  def __init__(self, use_browser: bool=True, enable_javascript=True):
class AccessorHandler:
     """コンストラクタ。
     """WebサイトからHTMLを取得するためのクラス。


     Requestのみを利用するか、Seleniumも利用するか引数で選択して初期化する。
     RequestsとSeleniumのどちらかを選択して使用することができ、その違いを隠蔽する。


     Args:
     Args:
      use_browser bool: TrueならSeleniumを利用する。FalseならRequestsのみでアクセスする。
        use_browser (bool): `True` ならSeleniumを利用する。`False` ならRequestsのみでアクセスする。
      enable_javascript bool: SeleniumでJavaScriptを利用する場合はTrue。
        enable_javascript (bool): SeleniumでJavaScriptを利用する場合は `True`。
     """
     """
    self.selenium_accessor: SeleniumAccessor | None = SeleniumAccessor(enable_javascript) if use_browser else None
    self.requests_accessor: RequestsAccessor = RequestsAccessor()


  def __enter__(self) -> Self:
    LIMIT_N_REQUESTS: Final[int] = 5
     """withブロックの開始時に実行する。
    """Final[int]: HTTPリクエスト失敗時の再試行回数。"""
    """
 
     return self
    WAIT_TIME_FOR_ERROR: Final[int] = 4
    """Final[int]: HTTPリクエスト失敗時にさらに追加する待機時間。"""
 
    def __init__(self, use_browser: bool, enable_javascript: bool) -> None:
        self._selenium_accessor: Final[SeleniumAccessor | None] = (
            SeleniumAccessor(enable_javascript) if use_browser else None
        )
        self._requests_accessor: Final[RequestsAccessor] = RequestsAccessor()
        self._last_url = ''  # 最後にアクセスしたURL
 
    def __enter__(self) -> Self:
        return self
 
    def __exit__(self,
                exc_type: type[BaseException] | None,
                exc_value: BaseException | None,
                traceback: TracebackType | None) -> None:
        if self._selenium_accessor is not None:
            self._selenium_accessor.quit()
 
     def request_once(self, url: str) -> str:
        """引数のURLにHTTP接続する。
 
        Args:
            url (str): 接続するURL。
 
        Returns:
            str: レスポンスのテキスト。
 
        Raises:
            AccessError: アクセスエラー。
 
        Note:
            失敗かどうかは呼出側で要判定。
        """
        try:
            if self._selenium_accessor is not None:
                return self._selenium_accessor.get(url)
            else:
                return self._requests_accessor.get(url)
        except AccessError:
            raise
 
     def _request_with_callable[T](
        self,
        url: str,
        request_callable: Callable[[str], T]
    ) -> T | None:
        """`request_callable` の実行を :const:`~LIMIT_N_REQUESTS` で定義された回数まで試す。
 
        成功すると結果を返す。
        接続失敗が何度も起きると `None` を返す。
 
        Args:
            url (str): 接続するURL
            request_callable (Callable[[str], T]): 1回リクエストを行うメソッド。


  def __exit__(self, *args) -> None:
        Returns:
    """withブロックの終了時に実行する。
            T | None: レスポンス。接続失敗が何度も起きると `None` を返す。
    """
        """
    if self.selenium_accessor is not None:
        assert url, 'URLが空っぽでふ'
      self.selenium_accessor.quit()
        logger.debug('Requesting ' + unquote(url))
        self._last_url = url


  def request_once(self, url: Final[str]) -> str:
        for i in range(self.LIMIT_N_REQUESTS):
    """引数のURLにHTTP接続する。
            try:
                res: T = request_callable(url)
            except AccessError:
                logger.warning(
                    url + 'への通信失敗ナリ  '
                    f'{i + 1}/{self.LIMIT_N_REQUESTS}回')
                logger.debug('エラーログ', exc_info=True)
                if i < self.LIMIT_N_REQUESTS:
                    sleep(self.WAIT_TIME_FOR_ERROR) # 失敗時は長めに待つ
            else:
                return res
        return None


     Args:
     def request(self, url: str) -> str | None:
      url Final[str]: 接続するURL。
        """HTTP接続を :const:`~LIMIT_N_REQUESTS` で定義された回数まで試す。


    Returns:
        成功すると結果を返す。
      str: レスポンスのテキスト。
        接続失敗が何度も起きると `None` を返す。


    Raises:
        Args:
      AccessError: アクセスエラー。
            url (str): 接続するURL。


    Note:
        Returns:
      失敗かどうかは呼出側で要判定。
            str | None: レスポンスのテキスト。接続失敗が何度も起きると `None` を返す。
    """
    try:
      if self.selenium_accessor is not None:
        return self.selenium_accessor.get(url)
      else:
        return self.requests_accessor.get(url)
    except AccessError:
      raise


  def _request_with_callable(self, url: Final[str], request_callable: Callable[[str], Any]) -> Any | None:
        Note:
    """request_callableの実行を :const:`~LIMIT_N_REQUESTS` で定義された回数まで試す。
            失敗かどうかは呼出側で要判定
        """
        return self._request_with_callable(url, self.request_once)


     成功すると結果を返す。
     def request_with_requests_module(self, url: str) -> str | None:
    接続失敗が何度も起きるとNoneを返す。
        """RequestsモジュールでのHTTP接続を :const:`~LIMIT_N_REQUESTS` で定義された回数まで試す。


    Args:
        成功すると結果を返す。
      url Final[str]: 接続するURL
        接続失敗が何度も起きると `None` を返す。
      request_callable Callable[[str], Any]: 1回リクエストを行うメソッド。


    Returns:
        Args:
      Any | None: レスポンス。接続失敗が何度も起きるとNoneを返す。
            url (str): 接続するURL。


    Note:
         Returns:
      失敗かどうかは呼出側で要判定
            str | None: レスポンスのテキスト。接続失敗が何度も起きると `None` を返す。
    """
    for i in range(self.LIMIT_N_REQUESTS):
      try:
         res: Any = request_callable(url)
      except AccessError as e:
        print(url + 'への通信失敗ナリ  ' + f"{i}/{self.LIMIT_N_REQUESTS}回")
        sleep(self.WAIT_TIME_FOR_ERROR) ##失敗時は長めに待つ
      else:
        return res
    return None


  def request(self, url: Final[str]) -> str | None:
        Note:
    """HTTP接続を :const:`~LIMIT_N_REQUESTS` で定義された回数まで試す。
            失敗かどうかは呼出側で要判定
        """
        return self._request_with_callable(url, self._requests_accessor.get)


     成功すると結果を返す。
     def request_image(self, url: str) -> bytes | None:
    接続失敗が何度も起きるとNoneを返す。
        """Requestsモジュールで画像ファイルを取得する。


    Args:
        成功すると結果を返す。
      url Final[str]: 接続するURL
        接続失敗が何度も起きると `None` を返す。


    Returns:
        Args:
      str | None: レスポンスのテキスト。接続失敗が何度も起きるとNoneを返す。
            url (str): 接続するURL。


    Note:
        Returns:
      失敗かどうかは呼出側で要判定
            bytes | None: レスポンスのバイト列。接続失敗が何度も起きると `None` を返します。
    """
    return self._request_with_callable(url, self.request_once)


  def request_with_requests_module(self, url: Final[str]) -> str | None:
        Note:
    """requestsモジュールでのHTTP接続を :const:`~LIMIT_N_REQUESTS` で定義された回数まで試す。
            失敗かどうかは呼出側で要判定
        """
        return self._request_with_callable(
            url,
            self._requests_accessor.get_image)


     成功すると結果を返す。
     def set_cookies(self, cookies: dict[str, str]) -> None:
    接続失敗が何度も起きるとNoneを返す。
        """Cookieをセットする。


    Args:
        Args:
      url Final[str]: 接続するURL
            cookies (dict[str, str]): Cookieのキーバリューペア。
        """
        self._requests_accessor.set_cookies(cookies)
        if self._selenium_accessor is not None:
            self._selenium_accessor.set_cookies(cookies)


     Returns:
     @property
      str | None: レスポンスのテキスト。接続失敗が何度も起きるとNoneを返す。
    def last_url(self) -> str:
        """str: 最後にアクセスしたURL。"""
        return self._last_url


     Note:
     @property
      失敗かどうかは呼出側で要判定
    def proxies(self) -> dict[str, str] | None:
    """
        """dict[str, str] | None: RequestsAccessorオブジェクトのプロキシ設定。"""
    return self._request_with_callable(url, self.requests_accessor.get)
        return self._requests_accessor.proxies


  def request_image(self, url: Final[str]) -> bytes | None:
    """requestsモジュールで画像ファイルを取得します。


    成功すると結果を返します。
class TableBuilder:
     接続失敗が何度も起きるとNoneを返します。
     """Wikiの表を組み立てるためのクラス。


     Args:
     Args:
      url Final[str]: 接続するURL
        date (datetime | None, optional): 記録するツイートの最新日付。デフォルトは今日の日付。


     Returns:
     Attributes:
      bytes | None: レスポンスのバイト列。接続失敗が何度も起きるとNoneを返します。
        _tables (list[str]): ツイートのリストを日毎にまとめたもの。\
            一番最後の要素が `_date` に対応し、最初の要素が最近の日付となる。
        _count (int): 表に追加したツイートの件数。
        _date (datetime): 現在収集中のツイートの日付。
     """
     """
     return self._request_with_callable(url, self.requests_accessor.get_image)
     FILENAME: Final[str] = UserProperties.filename
    """Final[str]: ツイートを保存するファイルの名前。"""
 
    def __init__(self, date: datetime | None = None) -> None:
        self._tables: Final[list[str]] = ['']
        self._count: int = 0  # 記録数
        self._date: datetime = date or datetime.today()
 
    @property
    def count(self) -> int:
        """int: 表に追加したツイートの件数。"""
        return self._count
 
    def append(self, callinshow_template: str, text: str) -> None:
        """ツイートを表に追加する。
 
        Args:
            callinshow_template (str): ツイートのURLをCallinshowLinkテンプレートに入れたもの。
            text (str): ツイートの本文。
        """
        self._tables[-1] = (
            '!' + callinshow_template + '\n|-\n|\n'
            + text
            + '\n|-\n'
            + self._tables[-1])
        self._count += 1


  @property
    def dump_file(self) -> None:
  def proxies(self) -> dict[str, str] | None:
        """Wikiテーブルをファイル出力する。"""
    return self.requests_accessor.proxies
        self._next_day()
        Path(self.FILENAME).write_text(
            '\n'.join(reversed(self._tables)), 'utf-8'
        )
        logger.info('テキストファイル手に入ったやで〜')


    def next_day_if_necessary(self, date: datetime) -> None:
        """引数dateがインスタンスの持っている日付より前の場合、日付更新処理をする。


class TwitterArchiver:
        Args:
  """ツイートをWikiの形式にダンプするクラス。
            date (datetime): 次のツイートの日付。
        """
        if (date.year != self._date.year or date.month != self._date.month
                or date.day != self._date.day):
            self._next_day(date)


  Nitterからツイートを取得し、Wikiの形式にダンプする。
    def _next_day(self, date: datetime | None = None) -> None:
  削除されたツイートや編集前のツイートは取得できない。
        """Wikiテーブルに日付の見出しを付与し、日付を更新する。
  """


  NITTER_INSTANCE: Final[str] = 'https://nitter.net/'
        記録済みのツイートが1つもない場合は日付の更新だけをする。
  """Final[str]: Nitterのインスタンス。


  生きているのは https://github.com/zedeus/nitter/wiki/Instances で確認。
        Args:
            date (datetime | None, optional): 次に記録するツイートの日付。
        """
        if self._tables[-1]:
            self._tables[-1] = self._convert_to_text_table(self._tables[-1])
            if platform.system() == 'Windows':
                self._tables[-1] = self._date.strftime(
                    '\n=== %#m月%#d日 ===\n') + self._tables[-1]
                logger.info(self._date.strftime('%#m月%#d日のツイートを取得完了ですを'))
            else:
                self._tables[-1] = self._date.strftime(
                    '\n=== %-m月%-d日 ===\n') + self._tables[-1]
                logger.info(self._date.strftime('%-m月%-d日のツイートを取得完了ですを'))
        if date is not None:
            self._date = date
            if self._tables[-1]:
                self._tables.append('')


  Note:
    def _convert_to_text_table(self, text: str) -> str:
    末尾にスラッシュ必須。
        """Wikiでテーブル表示にするためのヘッダとフッタをつける。


  Todo:
        Args:
    Tor専用のインスタンスが使えるようになったら変更する。
            text (str): ヘッダとフッタがないWikiテーブル。
  """


  ARCHIVE_TODAY: Final[str] = 'http://archiveiya74codqgiixo33q62qlrqtkgmcitqx5u2oeqnmn5bpcbiyd.onion/'
        Returns:
  """Final[str]: archive.todayの魚拓のonionドメイン。
            str: テーブル表示用のヘッダとフッタがついたWikiテーブル。
        """
        return '{|class="wikitable" style="text-align: left;"\n' + text + '|}'


  ページにアクセスして魚拓があるか調べるのにはonion版のarchive.todayを使用。
    @classmethod
  Note:
    def escape_wiki_reserved_words(cls, text: str) -> str:
    末尾にスラッシュ必須。
        """MediaWikiの文法と衝突する文字を無効化する。
  """


  ARCHIVE_TODAY_STANDARD: Final[str] = 'https://archive.vn/'
        Args:
  """Final[str]: archive.todayの魚拓のクリアネットドメイン。
            text (str): ツイートの文字列。


  記事にはクリアネット用のarchive.todayリンクを貼る。
        Returns:
            str: MediaWikiの文法と衝突する文字がエスケープされたツイートの文字列。
        """
        def escape_nolink_urls(text: str) -> str:
            """Archiveテンプレートの中にないURLがWikiでaタグに変換されないよう無効化する。


  Note:
            Args:
    末尾にスラッシュ必須。
                text (str): ツイートの文字列。
  """


  TWITTER_URL: Final[str] = 'https://twitter.com/'
            Returns:
  """Final[str]: TwitterのURL。
                str: Archiveテンプレートの中にないURLがnowikiタグでエスケープされた文字列。
            """
            is_in_archive_template: bool = False
            i: int = 0
            while i < len(text):
                if is_in_archive_template:
                    if text[i:i + len('}}')] == '}}':
                        is_in_archive_template = False
                        i += len('}}')
                else:
                    if (text[i:i + len('{{Archive|')] == '{{Archive|'
                            or text[i:i + len('{{Archive|')] == '{{archive|'):
                        is_in_archive_template = True
                        i += len('{{Archive|')
                    elif text[i:i + len('https://')] == 'https://':
                        text = (text[:i]
                                + '<nowiki>https://</nowiki>'
                                + text[i + len('https://'):])
                        i += len('<nowiki>https://</nowiki>')
                    elif text[i:i + len('http://')] == 'http://':
                        text = (text[:i]
                                + '<nowiki>http://</nowiki>'
                                + text[i + len('http://'):])
                        i += len('<nowiki>http://</nowiki>')
                i += 1
            return text


  Note:
        if not hasattr(cls, '_escape_callables'):
    末尾にスラッシュ必須。
            # 初回呼び出しの時だけ正規表現をコンパイルする
  """
            head_space_pattern: Final[re.Pattern[str]] = re.compile(
                r'^ ', re.MULTILINE)
            head_marks_pattern: Final[re.Pattern[str]] = re.compile(
                r'^([\*#:;])', re.MULTILINE)
            bar_pattern: Final[re.Pattern[str]] = re.compile(
                r'^----', re.MULTILINE)


  CALLINSHOW: Final[str] = 'CallinShow'
            cls._escape_callables: tuple[Callable[[str], str], ...] = (
  """Final[str]: 降臨ショーのユーザーネーム。
                lambda t: t.replace('\n', '<br>\n'),
  """
                lambda t: head_space_pattern.sub('&nbsp;', t),
                lambda t: head_marks_pattern.sub(r'<nowiki>\1</nowiki>', t),
                lambda t: bar_pattern.sub('<nowiki>----</nowiki>', t),
                lambda t: escape_nolink_urls(t),
            )


  LIMIT_N_TWEETS: Final[int] = 100
        escaped_text: str = text
  """Final[int]: 取得するツイート数の上限。
        for escape_callable in cls._escape_callables:
  """
            escaped_text = escape_callable(escaped_text)
        return escaped_text


  REPORT_INTERVAL: Final[int] = 5
    @staticmethod
  """Final[int]: 記録件数を報告するインターバル。
    def archive_url(
  """
        url: str,
        archived_url: str,
        text: str | None = None
    ) -> str:
        """URLをArchiveテンプレートでラップする。


  TWEETS_OR_REPLIES: Final[str] = 'with_replies'
        Args:
  """Final[str]: NitterのURLのドメインとユーザーネーム部分の接続部品。
            url (str): ラップするURL。
  """
            archive_url (str): ラップするURLの魚拓のURL。
            text (str | None, optional): ArchiveテンプレートでURLの代わりに表示する文字列。


  NITTER_ERROR_TITLE: Final[str] = 'Error|nitter'
        Returns:
  """Final[str]: Nitterでユーザーがいなかったとき返ってくるページのタイトル。
            str: ArchiveタグでラップしたURL。
        """
        if text is None:
            return '{{Archive|1=' + unquote(url) + '|2=' + archived_url + '}}'
        else:
            return '{{Archive|1=' + unquote(url) + '|2=' + archived_url \
                + '|3=' + text + '}}'


  万が一仕様変更で変わったとき用。
    @staticmethod
  """
    def callinshowlink_url(url: str, archived_url: str) -> str:
        """URLをCallinShowLinkテンプレートでラップする。


  NO_ARCHIVE: Final[str] = 'No results'
        Args:
  """Final[str]: archive.todayで魚拓がなかったときのレスポンス。
            url (str): ラップするURL。
            archive_url (str): ラップするURLの魚拓のURL。


  万が一仕様変更で変わったとき用。
        Returns:
  """
            str: CallinShowLinkタグでラップしたURL。
        """
        return '{{CallinShowLink|1=' + url + '|2=' + archived_url + '}}'


  NEWEST: Final[str] = 'Load newest'
  """Final[str]: Nitterの前ページ読み込み部分の名前。


  万が一仕様変更で変わったとき用。
class FfmpegStatus(Enum):
  """
    """ffmpegでの動画保存ステータス。"""
    MP4 = 1
    """mp4の取得に成功したときのステータス。"""
    TS = 2
    """tsの取得までは成功したが、mp4への変換に失敗したときのステータス。"""
    FAILED = 3
    """m3u8からtsの取得に失敗したときのステータス。"""


  MEDIA_DIR: Final[str] = 'tweet_media'
  """Final[str]: 画像などのツイートメディアをダウンロードするディレクトリ。
  """


  def __init__(self, krsw: bool=False):
class TwitterArchiver:
    """コンストラクタ
     """ツイートをWikiの形式にダンプするクラス。
     """
    self._txt_data: list[str] = []
    self._limit_count: int = 0 ##記録数


  def _set_queries(self, accessor: AccessorHandler, krsw: bool):
    Nitterからツイートを取得し、Wikiの形式にダンプする。
     """検索条件を設定する。
     削除されたツイートや編集前のツイートは取得できない。


     :class:`~TwitterArchiver` のメンバをセットし、ツイートを収集するアカウントと
     Important:
    検索クエリ、終わりにするツイートを入力させる。
        Nitterはサービスを終了しており、いずれすべてのインスタンスが使えなくなる [2]_。
        本クラスを継承した :class:`~ArchiveCrawler` はarchive.todayからツイートを収集するため引き続き利用可能。


     Args:
     References:
      accessor AccessorHandler: アクセスハンドラ
        .. [2] Nitter is over - It's been a fun ride. (2024, February 15).
      krsw bool: Trueの場合、名前が自動で :const:`~CALLINSHOW` になり、クエリと終わりにするツイートが自動で無しになる。
            https://nitter.cz
     """
     """


     ##ユーザー名取得
     NITTER_INSTANCE: Final[str] = UserProperties.NitterCrawler.nitter_instance
    if krsw:
     """Final[str]: Nitterのインスタンス。
      print('名前は自動的に' + self.CALLINSHOW + 'にナリます')
      self.name: Final[str] = self.CALLINSHOW
     else:
      self.name: Final[str] = self._get_name(accessor)


     ##検索クエリとページ取得
     生きているのは https://github.com/zedeus/nitter/wiki/Instances で確認。
    self.query_strs: list[str] = []
    if krsw:
      print('クエリは自動的になしにナリます')
    else:
      self._get_query()
    self._page: Final[str] | None = accessor.request(urljoin(self.NITTER_INSTANCE, self.name + '/' + self.TWEETS_OR_REPLIES))
    if self._page is None:
      self._fail()


     ##終わりにするツイート取得
     Note:
    if krsw:
        末尾にスラッシュ必須。
      print('終わりにするツイートは自動的になしにナリます')
      self._stop: Final[str] = ''
    else:
      self._stop: Final[str] = self._stop_word()


    ##日付取得
        インスタンスによっては画像の取得ができない。
    self._date: datetime = self._tweet_date(BeautifulSoup(self._page, 'html.parser').find(class_='timeline-item'))
        Tor用のインスタンスでないと動画の取得ができない。
    self._txt_data.append('')
     """
     print()


  def _check_slash(self) -> None | NoReturn:
    ARCHIVE_TODAY: Final[str] = 'http://archiveiya74codqgiixo33q62qlrqtkgmcitqx5u2oeqnmn5bpcbiyd.onion/'  # noqa: E501
     """URLの最後にスラッシュが付いていなければエラーを出します。
     """Final[str]: archive.todayの魚拓のonionドメイン。


     Returns:
     ページにアクセスして魚拓があるか調べるのにはonion版のarchive.todayを使用。
      None | NoReturn: すべてのURLが正しければNone。失敗したら例外を出す。


     Raises:
     Note:
      RuntimeError: URLの最後にスラッシュがついていない場合に出る。
        末尾にスラッシュ必須。
     """
     """
    if self.NITTER_INSTANCE[-1] != '/':
      raise RuntimeError('NITTER_INSTANCEの末尾には/が必須です')
    if self.ARCHIVE_TODAY[-1] != '/':
      raise RuntimeError('ARCHIVE_TODAYの末尾には/が必須です')
    if self.ARCHIVE_TODAY_STANDARD[-1] != '/':
      raise RuntimeError('ARCHIVE_TODAY_STANDARDの末尾には/が必須です')
    if self.TWITTER_URL[-1] != '/':
      raise RuntimeError('TWITTER_URLの末尾には/が必須です')


  def _check_nitter_instance(self, accessor: AccessorHandler) -> None | NoReturn:
    ARCHIVE_TODAY_STANDARD: Final[str] = 'https://archive.vn/'
     """Nitterのインスタンスが生きているかチェックする。
     """Final[str]: archive.todayの魚拓のクリアネットドメイン。


     死んでいたらそこで終了。
     Wiki上の記事にはクリアネット用のarchive.todayリンクを貼る。
    接続を一回しか試さない :func:`~_request_once` を使っているのは、激重インスタンスが指定されたとき試行回数増やして偶然成功してそのまま実行されるのを躱すため。


     Args:
     Note:
      accessor AccessorHandler: アクセスハンドラ
        末尾にスラッシュ必須。
 
    Returns:
      None | NoReturn: NitterにアクセスできればNone。できなければ終了。
     """
     """
    print("Nitterのインスタンスチェック中ですを")
    try:
      accessor.request_once(self.NITTER_INSTANCE)
    except AccessError as e: ##エラー発生時は終了
      print(e, file=sys.stderr)
      print('インスタンスが死んでますを', file=sys.stderr)
      exit(1)
    print("Nitter OK")


  def _check_archive_instance(self, accessor: AccessorHandler) -> None | NoReturn:
    TWITTER_URL: Final[str] = 'https://x.com/'
     """archive.todayのTor用インスタンスが生きているかチェックする。
     """Final[str]: TwitterのURL。


     Args:
     Note:
      accessor AccessorHandler: アクセスハンドラ
        末尾にスラッシュ必須。
    """


     Returns:
     TWITTER_MEDIA_URL: Final[str] = 'https://pbs.twimg.com/media/'
      None | NoReturn: archive.todayのTorインスタンスにアクセスできればNone。できなければ終了。
    """Final[str]: TwitterのメディアのURL。
     """
     """
    print("archive.todayのTorインスタンスチェック中ですを")
    try:
      accessor.request_once(self.ARCHIVE_TODAY)
    except AccessError as e: ##エラー発生時は終了
      print(e, file=sys.stderr)
      print('インスタンスが死んでますを', file=sys.stderr)
      exit(1)
    print("archive.today OK")


  def _invidious_instances(self, accessor: AccessorHandler) -> tuple[str] | NoReturn:
    INVIDIOUS_INSTANCES_URL: Final[str] = \
     """Invidiousのインスタンスのタプルを取得する。
        'https://api.invidious.io/instances.json'
     """Final[str]: Invidiousのインスタンスのリストを取得するAPIのURL。"""


     Args:
     INVIDIOUS_INSTANCES_TUPLE: Final[tuple[str, ...]] = (
      accessor AccessorHandler: アクセスハンドラ
        'piped.kavin.rocks',
        'piped.video',
        'invidious.poast.org'
    )
    """Final[tuple[str, ...]]: よく使われるInvidiousインスタンスのリスト。


     Returns:
     :const:`~INVIDIOUS_INSTANCES_URL` にアクセスしてもインスタンスが取得できないことがあるため、
      tuple[str] | NoReturn: Invidiousのインスタンスのタプル。Invidiousのインスタンスが死んでいれば終了。
    それによってURLが置換できないことを防ぐ。
     """
     """
    print("Invidiousのインスタンスリストを取得中ですを")
    invidious_json: Final[str] | None = accessor.request_with_requests_module('https://api.invidious.io/instances.json')
    if invidious_json is None:
      print("Invidiousが死んでますを")
      exit(1)
    instance_list: list[str] = []
    for instance_info in json.loads(invidious_json):
      instance_list.append(instance_info[0])


     # よく使われているものはチェック
     CALLINSHOW: Final[str] = 'CallinShow'
     if 'piped.kavin.rocks' not in instance_list:
     """Final[str]: 降臨ショーのユーザーネーム。"""
      instance_list.append('piped.kavin.rocks')
 
     if 'piped.video' not in instance_list:
     LIMIT_N_TWEETS: Final[int] = UserProperties.NitterCrawler.limit_n_tweets
      instance_list.append('piped.video')
     """Final[int]: 取得するツイート数の上限。"""
     return tuple(instance_list)


  def _get_name(self, accessor: AccessorHandler) -> str | NoReturn:
    REPORT_INTERVAL: Final[int] = UserProperties.NitterCrawler.report_interval
     """ツイート収集するユーザー名を標準入力から取得する。
     """Final[int]: 記録件数を報告するインターバル。"""


     何も入力しないと :const:`~CALLINSHOW` を指定する。
     TWEETS_OR_REPLIES: Final[str] = 'with_replies'
    """Final[str]: NitterのURLのドメインとユーザーネーム部分の接続部品。"""


     Args:
     NITTER_ERROR_TITLE: Final[str] = 'Error|nitter'
      accessor AccessorHandler: アクセスハンドラ
    """Final[str]: Nitterでユーザーがいなかったとき返ってくるページのタイトル。


     Returns:
     万が一仕様変更で変わったとき用。
      str | NoReturn: ユーザ名。ユーザページの取得に失敗したら終了。
     """
     """
    while True:
      print('アカウント名を入れなければない。空白だと自動的に' + self.CALLINSHOW + 'になりますを')
      account_str: str = input() ##ユーザー入力受付
      ##空欄で降臨ショー
      if account_str == '':
        return self.CALLINSHOW
      else:
        res: Final[str]| None = accessor.request(urljoin(self.NITTER_INSTANCE, account_str)) ##リクエストして結果取得
        if res is None : ##リクエスト失敗判定
          self._fail()
        soup: BeautifulSoup = BeautifulSoup(res, 'html.parser') ##beautifulsoupでレスポンス解析
        if soup.title == self.NITTER_ERROR_TITLE: ##タイトルがエラーでないか判定
          print(account_str + "は実在の人物ではありませんでした") ##エラー時ループに戻る
        else:
          print("最終的に出会ったのが@" + account_str + "だった。")
          return account_str ##成功時アカウント名返す


  def _get_query(self) -> None:
    NO_ARCHIVE: Final[str] = 'No results'
     """検索クエリを標準入力から取得する。
     """Final[str]: archive.todayで魚拓がなかったときのレスポンス。


     取得したクエリは ``self.query_strs`` に加えられる。
     万が一仕様変更で変わったとき用。
     """
     """
    print("検索クエリを入れるナリ。複数ある時は一つの塊ごとに入力するナリ。空欄を入れると検索開始ナリ。")
    print("例:「無能」→改行→「脱糞」→改行→「弟殺し」→改行→空欄で改行")
    query_input: str = input() ##ユーザー入力受付
    ##空欄が押されるまでユーザー入力受付
    while query_input != '':
      self.query_strs.append(query_input)
      query_input = input()
    print("クエリのピースが埋まっていく。")


  def _fail(self) -> NoReturn:
    NEWEST: Final[str] = 'Load newest'
     """接続失敗時処理。
     """Final[str]: Nitterの前ページ読み込み部分の名前。


     取得に成功した分だけファイルにダンプし、プログラムを終了する。
     万が一仕様変更で変わったとき用。
     """
     """
    print("接続失敗しすぎで強制終了ナリ")
    if len(self._txt_data) > 0: ##取得成功したデータがあれば発行
      print("取得成功した分だけ発行しますを")
      self._make_txt()
    else:
      exit(1) ##終了


  def _convert_to_text_table(self, text: str) -> str:
    MEDIA_DIR: Final[str] = UserProperties.media_dir
    """``self._txt_data[0]`` にwikiでテーブル表示にするためのヘッダとフッタをつける。
    """Final[str]: 画像などのツイートメディアをダウンロードするディレクトリ。"""
 
    def __init__(self) -> None:
        self._check_constants()  # スラッシュが抜けてないかチェック
        self._has_ffmpeg: Final[bool] = self._check_ffmpeg()  # ffmpegがあるかチェック
 
        self._img_ext_pattern: Final[re.Pattern[str]] = re.compile(
            r'%2F([^%]*\.(?:jpg|jpeg|png|gif))')
        self._url_fragment_pattern: Final[re.Pattern[str]] = re.compile(
            r'#[^#]*$')
        self._url_query_pattern: Final[re.Pattern[str]] = re.compile(r'\?.*$')
 
    def _set_queries(
        self, accessor: AccessorHandler, krsw: str | None
    ) -> bool:
        """検索条件を設定する。
 
        :class:`~TwitterArchiver` のメンバをセットし、ツイートを収集するアカウントと
        検索クエリ、終わりにするツイートを入力させる。
 
        Args:
            accessor (AccessorHandler): アクセスハンドラ
            krsw (str | None): `None` でない場合、名前が :const:`~CALLINSHOW` になり、\
                クエリと終わりにするツイートが無しになる。\
                更に空文字でもない場合、この引数が終わりにするツイートになる。
 
        Returns:
            bool: 処理成功時は `True`。
        """
 
        # ユーザー名取得
        if krsw is not None:
            logger.info('名前は自動的に' + self.CALLINSHOW + 'にナリます')
            self._name: str = self.CALLINSHOW
        else:
            name_optional: str | None = self._input_name(accessor)
            if name_optional is not None:
                self._name: str = name_optional
            else:
                return False
 
        # 検索クエリとページ取得
        self._query_strs: list[str] = []
        if krsw is not None:
            logger.info('クエリは自動的になしにナリます')
        else:
            self._input_query()
        page_optional: str | None = accessor.request(
            urljoin(self.NITTER_INSTANCE, self._name + '/'
                    + self.TWEETS_OR_REPLIES))
        if page_optional is None:
            self._on_fail(accessor)
            return False
        self._nitter_page: str = page_optional
 
        # Nitter用のCookieをセット
        accessor.set_cookies({
            'infiniteScroll': '',
            'proxyVideos': 'on',
            'replaceReddit': '',
            'replaceYouTube': '',
        })


    Args:
        # 終わりにするツイート取得
      text str: ヘッダとフッタがないWikiテーブル。
        if krsw == '':
            logger.info('終わりにするツイートは自動的になしにナリます')
            self._stop: str = ''
        elif krsw is not None:
            logger.info(f'終わりにするツイートは自動的に"{krsw}"にナリます')
            self._stop: str = krsw
        else:
            self._stop: str = self._input_stop_word()


    Returns:
        logger.info(
      str: テーブル表示用のヘッダとフッタがついたWikiテーブル。
            'ユーザー名: @' + self._name
    """
            + ', クエリ: ["' + '", "'.join(self._query_strs)
    return '{|class="wikitable" style="text-align: left;"\n' + text + '|}'
            + '"], 終わりにする文言: "' + self._stop
            + '"で検索しまふ'
        )


  def _make_txt(self) -> NoReturn:
        # 日付取得
    """Wikiテーブルをファイル出力し、プログラムを終了する。
        tweet_link: Final[Tag | NavigableString | None] = BeautifulSoup(
    """
            self._nitter_page, 'html.parser'
    self._next_day()
        ).find(class_='tweet-link')
    result_txt: Final[str] = '\n'.join(self._txt_data) ##リストを合体
        assert isinstance(tweet_link, Tag)
    ##ファイル出力
        href: Final[str | list[str] | None] = tweet_link.get('href')
    with codecs.open('tweet.txt', 'w', 'utf-8') as f:
        assert isinstance(href, str)
      f.write(result_txt)
        date: Final[datetime] = self._tweet_date(
    print("テキストファイル手に入ったやで〜")
            href.split('#')[0]) # フラグメント識別子を除去
    exit(0) ##終了


  def _stop_word(self) -> str:
        self._table_builder: TableBuilder = TableBuilder(date)
    """ツイートの記録を中断するための文をユーザに入力させる。
        return True


     Returns:
     def _check_constants(self) -> None:
      str: ツイートの記録を中断するための文。ツイート内容の一文がその文と一致したら記録を中断する。
        """定数のバリデーションを行う。
    """
    print(f"ここにツイートの本文を入れる!すると・・・・なんとそれを含むツイートで記録を中断する!(これは本当の仕様です。)ちなみに、空欄だと{self.LIMIT_N_TWEETS}件まで終了しない。")
    end_str: Final[str] = input() ##ユーザー入力受付
    return end_str


  def _download_media(self, media_name: Final[str], accessor: AccessorHandler) -> bool:
        Returns:
    """ツイートの画像をダウンロードし、:const:`~MEDIA_DIR` に保存する。
            None: すべての対象定数が正しければ `None`。失敗したら例外を出す。


    Args:
        Raises:
      media_name Final[str]: 画像ファイル名。Nitter上のimgタグのsrc属性では、``/pic/media%2F`` に後続する。
            AssertionError: バリデーションに違反した場合に出る。
      accessor AccessorHandler: アクセスハンドラ
        """
        assert self.NITTER_INSTANCE[-1] == '/', 'NITTER_INSTANCEの末尾をには/が必須です'
        assert self.ARCHIVE_TODAY[-1] == '/', 'ARCHIVE_TODAYの末尾をには/が必須です'
        assert self.ARCHIVE_TODAY_STANDARD[-1] == '/', \
            'ARCHIVE_TODAY_STANDARDの末尾をには/が必須です'
        assert self.TWITTER_URL[-1] == '/', 'TWITTER_URLの末尾をには/が必須です'


     Returns:
     def _check_ffmpeg(self) -> bool:
      bool: 保存に成功したかどうか。
        """ffmpegがインストールされているかチェックする。
    """
    os.makedirs(self.MEDIA_DIR, exist_ok=True)
    url: Final[str] = urljoin('https://pbs.twimg.com/media/', media_name)
    image_bytes: Final[bytes | None] = accessor.request_image(url)
    if image_bytes is not None:
      with open(os.path.join(self.MEDIA_DIR, media_name), "wb") as f:
        f.write(image_bytes)
      return True
    else:
      return False


  def _tweet_date(self, tweet: bs4.element.Tag) -> datetime:
        Returns:
    """ツイートの時刻を取得する。
            bool: ffmpegがインストールされているか。
        """
        return shutil.which('ffmpeg') is not None


     Args:
     def _check_nitter_instance(self, accessor: AccessorHandler) -> None:
      tweet bs4.element.Tag: ツイート内容である ``.timeline-item`` タグを表すbeautifulsoup4タグ。
        """Nitterのインスタンスが生きているかチェックする。


    Returns:
        死んでいたらそこで終了。
      datetime: ツイートの時刻。
        接続を一回しか試さない :func:`~_request_once` を使っているのは、
    """
        激重インスタンスが指定されたとき試行回数増やして偶然成功してそのまま実行されるのを躱すため。
    date_str: str = tweet.find(class_='tweet-date').a['title']
    date: datetime = datetime.strptime(date_str, '%b %d, %Y · %I:%M %p %Z').replace(tzinfo=ZoneInfo('UTC')).astimezone(ZoneInfo('Asia/Tokyo'))
    return date


  #self._dateの日付のツイートがなくなったときの処理
        Args:
  def _next_day(self, date: datetime|None = None) -> None:
            accessor (AccessorHandler): アクセスハンドラ。
    """1日分のツイートをテーブル形式に変換し、その日のツイートを記録し終わったことを通知して、``self._txt_data`` の0番目に空文字列を追加する。


    Args:
        Returns:
      date datetime|None:
            None: Nitterにアクセスできれば `None`。できなければ終了。
        記録した日付の前日の日付。Noneでなければ、``self._date`` をその値に更新する。
        """
    """
         logger.info('Nitterのインスタンスチェック中ですを')
    if self._txt_data[0]: # 空でなければ出力
         try:
      self._txt_data[0] = self._convert_to_text_table(self._txt_data[0])
            accessor.request_once(self.NITTER_INSTANCE)
      if os.name == 'nt': # Windows
        except AccessError:
         self._txt_data[0] = self._date.strftime('\n=== %#m月%#d日 ===\n') + self._txt_data[0]
            logger.critical('インスタンスが死んでますを', exc_info=True)
         print(self._date.strftime('%#m月%#d日のツイートを取得完了ですを'))
            sys.exit(1)
      else: # Mac or Linux
        logger.info('Nitter OK')
        self._txt_data[0] = self._date.strftime('\n=== %-m月%-d日 ===\n') + self._txt_data[0]
        print(self._date.strftime('%-m月%-d日のツイートを取得完了ですを'))
    if date is not None:
      self._txt_data.insert(0, '')
      self._date = date


  def _get_tweet_media(self, tweet: bs4.element.Tag, accessor: AccessorHandler) -> str:
    def _check_archive_instance(self, accessor: AccessorHandler) -> None:
    """ツイートの画像や動画を取得する。
        """archive.todayのTor用インスタンスが生きているかチェックする。


    Args:
        Args:
      tweet bs4.element.Tag: ツイート内容である ``.timeline-item`` タグを表すbeautifulsoup4タグ。
            accessor (AccessorHandler): アクセスハンドラ。
      accessor AccessorHandler: アクセスハンドラ


    Returns:
        Returns:
      str: Wiki記法でのファイルへのリンクの文字列。
            None: archive.todayのTorインスタンスにアクセスできれば `None`。できなければ終了。
    """
        """
    tweet_media: bs4.element.Tag | None = tweet.select_one('.tweet-body > .attachments') # 引用リツイート内のメディアを選択しないように.tweet-body直下の.attachmentsを選択
        logger.info('archive.todayのTorインスタンスチェック中ですを')
    media_txt: str = ''
    if tweet_media is not None:
      media_list: list[str] = []
      # ツイートの画像の取得
      for image_a in tweet_media.select('.attachment.image a'):
         try:
         try:
          media_name: str = [group for group in re.search(r'%2F([^%]*\.jpg)|%2F([^%]*\.jpeg)|%2F([^%]*\.png)|%2F([^%]*\.gif)', image_a.get('href')).groups() if group][0]
            accessor.request_once(self.ARCHIVE_TODAY)
          media_list.append(f"[[ファイル:{media_name}|240px]]")
        except AccessError:  # エラー発生時は終了
          if self._download_media(media_name, accessor):
            logger.critical('インスタンスが死んでますを', exc_info=True)
            print(os.path.join(self.MEDIA_DIR, media_name) + ' をアップロードしなければない。')
            sys.exit(1)
          else:
        logger.info('archive.today OK')
            print(urljoin('https://pbs.twimg.com/media/', media_name) + ' をアップロードしなければない。')
 
        except AttributeError as e:
    def _invidious_instances(
          tweet_url: str = urljoin(self.TWITTER_URL, re.sub('#[^#]*$', '', tweet.find(class_='tweet-link').get('href'))) ##ツイートのURL作成
        self, accessor: AccessorHandler
          print(f"{tweet_url}の画像が取得できませんでしたを 当職無能")
    ) -> tuple[str, ...]:
          media_list.append(f"[[ファイル:(画像の取得ができませんでした)|240px]]")
        """Invidiousのインスタンスのタプルを取得する。
      # ツイートの動画の取得。ffmpegが入っていなければ手動で取得すること
 
      for i, video_container in enumerate(tweet_media.select('.attachment.video-container')):
        Args:
        tweet_url: str = urljoin(self.TWITTER_URL, re.sub('#[^#]*$', '', tweet.find(class_='tweet-link').get('href'))) ##ツイートのURL作成
            accessor (AccessorHandler): アクセスハンドラ。
        video = video_container.select_one('video')
 
        if video is None:
        Returns:
          print(f"{tweet_url}の動画が取得できませんでしたを 当職無能")
            tuple[str, ...]: Invidiousのインスタンスのタプル。インスタンスが死んでいれば終了。
          media_list.append(f"[[ファイル:(動画の取得ができませんでした)|240px]]")
        """
          continue
        logger.info('Invidiousのインスタンスリストを取得中ですを')
        invidious_json: Final[str | None] = (
            accessor.request_with_requests_module(self.INVIDIOUS_INSTANCES_URL)
        )
        if invidious_json is None:
            logger.critical('Invidiousが死んでますを')
            sys.exit(1)
        instance_list: Final[list[str]] = []
        for instance_info in json.loads(invidious_json):
            instance_list.append(instance_info[0])
 
        # よく使われているものはチェック
        for invidious_api in self.INVIDIOUS_INSTANCES_TUPLE:
            if invidious_api not in instance_list:
                instance_list.append(invidious_api)
        logger.debug('Invidiousのインスタンス: [' + ', '.join(instance_list) + ']')
        return tuple(instance_list)
 
    def _input_name(self, accessor: AccessorHandler) -> str | None:
        """ツイート収集するユーザー名を標準入力から取得する。
 
        何も入力しないと :const:`~CALLINSHOW` を指定する。
 
        Args:
            accessor (AccessorHandler): アクセスハンドラ。
 
        Returns:
            str | None: ユーザ名。ユーザページの取得に失敗したら `None`。
        """
        while True:
            print(
                'アカウント名を入れなければない。空白だと自動的に'
                + self.CALLINSHOW
                + 'になりますを')
            print('> ', end='')
            account_str: str = input()
            # 空欄で降臨ショー
            if account_str == '':
                return self.CALLINSHOW
            else:
                res: str | None = accessor.request(
                    urljoin(self.NITTER_INSTANCE, account_str))
                if res is None:  # リクエスト失敗判定
                    self._on_fail(accessor)
                    return None
                soup: BeautifulSoup = BeautifulSoup(res, 'html.parser')
                if soup.title == self.NITTER_ERROR_TITLE:
                    print(account_str + 'は実在の人物ではありませんでした')
                else:
                    print('最終的に出会ったのが@' + account_str + 'だった。')
                    logger.info('@' + account_str + 'をクロールしまふ')
                    return account_str
 
    def _input_query(self) -> None:
        """検索クエリを標準入力から取得する。"""
        print('検索クエリを入れるナリ。複数ある時は一つの塊ごとに入力するナリ。空欄を入れると検索開始ナリ。')
        print('例:「無能」→改行→「脱糞」→改行→「弟殺し」→改行→空欄で改行')
        print('> ', end='')
        query_input: str = input()
        # 空欄が押されるまでユーザー入力受付
        while query_input != '':
            self._query_strs.append(query_input)
            print('> ', end='')
            query_input = input()
        print('クエリのピースが埋まっていく。')
 
    def _on_fail(self, accessor: AccessorHandler) -> None:
        """接続失敗時処理。
 
        取得に成功した分だけファイルにダンプする。
        ログにトレースバックも表示する。
 
        Args:
            accessor (AccessorHandler): アクセスハンドラ。
        """
        logger.critical('接続失敗時処理をしておりまふ', stack_info=True)
        logger.critical('最後にアクセスしたURL: ' + accessor.last_url)
        print('接続失敗しすぎで強制終了ナリ')
        if self._table_builder.count > 0:  # 取得成功したデータがあれば発行
            print('取得成功した分だけ発行しますを')
            self._table_builder.dump_file()
 
    def _input_stop_word(self) -> str:
        """ツイートの記録を中断するための文をユーザに入力させる。
 
        Returns:
            str: ツイートの記録を中断するための文。ツイート内容の一文がその文と一致したら記録を中断する。
        """
        print(
            'ここにツイートの本文を入れる!すると・・・・なんとそれを含むツイートで記録を中断する!(これは本当の仕様です。)'
            f'ちなみに、空欄だと{self.LIMIT_N_TWEETS}件まで終了しない。')
        print('> ', end='')
        end_str: Final[str] = input()
        return end_str
 
    def _download_media(
        self,
        media_url: str,
        media_name: str,
        accessor: AccessorHandler
    ) -> bool:
        """ツイートの画像をダウンロードし、:const:`~MEDIA_DIR` に保存する。
 
        Args:
            media_url (str): 画像のURL。
            media_name (str): 画像ファイル名。Nitter上のimgタグのsrc属性では、\
                ``/pic/media%2F`` に後続する。
            accessor (AccessorHandler): アクセスハンドラ。
 
        Returns:
            bool: 保存に成功したかどうか。
        """
        os.makedirs(self.MEDIA_DIR, exist_ok=True)
        image_bytes: Final[bytes | None] = accessor.request_image(media_url)
        if image_bytes is not None:
            Path(self.MEDIA_DIR, media_name).write_bytes(image_bytes)
            return True
        else:
            return False
 
    def _download_m3u8(
        self,
        media_url: str,
        ts_filename: str | None,
        mp4_filename: str,
        proxies: dict[str, str] | None
    ) -> FfmpegStatus:
        """ffmpegで動画をダウンロードし、:const:`~MEDIA_DIR` に保存する。
 
        Args:
            media_url (str): 動画のm3u8/mp4ファイルのURL。
            ts_filename (str | None): m3u8から取得したtsファイルのパス。\
                `None` の場合はtsファイルをダウンロードせず、直接mp4をダウンロードする。
            mp4_filename (str): 取得したmp4ファイルのパス。
            proxies (dict[str, str] | None): ffmpegでの通信に用いるプロキシ設定。
 
        Returns:
            FfmpegStatus: ffmpegでの保存ステータス。
        """
        os.makedirs(self.MEDIA_DIR, exist_ok=True)
        if ts_filename is not None:
            ts_returncode: Final[int] = subprocess.run(
                [
                    'ffmpeg', '-y',
                    '-http_proxy', 'proxies["http"]',
                    '-i', media_url,
                    '-c', 'copy', ts_filename
                ] if proxies is not None else [
                    'ffmpeg', '-y',
                    '-i', media_url,
                    '-c', 'copy', ts_filename
                ],
                stdout=subprocess.DEVNULL).returncode
 
            # 取得成功したらtsをmp4に変換
            if ts_returncode == 0:
                ts2mp4_returncode: Final[int] = subprocess.run(
                    [
                        'ffmpeg', '-y', '-i', ts_filename,
                        '-acodec', 'copy', '-vcodec', 'copy', mp4_filename
                    ],
                    stdout=subprocess.DEVNULL
                ).returncode
                if ts2mp4_returncode == 0:
                    return FfmpegStatus.MP4
                else:
                    return FfmpegStatus.TS
            else:
                return FfmpegStatus.FAILED
 
        else:
            returncode: Final[int] = subprocess.run(
                [
                    'ffmpeg', '-y',
                    '-http_proxy', 'proxies["http"]',
                    '-i', media_url,
                    '-c', 'copy', mp4_filename
                ] if proxies is not None else [
                    'ffmpeg', '-y',
                    '-i', media_url,
                    '-c', 'copy', mp4_filename
                ],
                stdout=subprocess.DEVNULL).returncode
            if returncode == 0:
                return FfmpegStatus.MP4
            else:
                return FfmpegStatus.FAILED
 
    def _tweet_date(self, url: str) -> datetime:
        """ツイートの時刻を取得する。
 
        URLのIDから、Snowflakeの手順を逆算してタイムスタンプを計算している。
        https://github.com/twitter-archive/snowflake/blob/b3f6a3c6ca8e1b6847baa6ff42bf72201e2c2231/src/main/scala/com/twitter/service/snowflake/IdWorker.scala#L91
 
        Args:
            url (str): ツイートのURL。
 
        Returns:
            datetime: ツイートの時刻。
        """
        id: int = int(url.split('/')[-1])
        timestamp: float = ((id >> 22) + 1_288_834_974_657) / 1000.
        return datetime.fromtimestamp(timestamp, tz=ZoneInfo('Asia/Tokyo'))
 
    def _fetch_tweet_media(
        self,
        tweet: Tag,
        tweet_url: str,
        accessor: AccessorHandler
    ) -> str:
        """ツイートの画像や動画を取得する。
 
        Args:
            tweet (Tag): ツイート内容である ``.timeline-item`` タグを表すbeautifulsoup4タグ。
            tweet_url (str): ツイートのURL。
            accessor (AccessorHandler): アクセスハンドラ。
 
        Returns:
            str: Wiki記法でのファイルへのリンクの文字列。
        """
        # 引用リツイート内のメディアを選択しないように.tweet-body直下の.attachmentsを選択
        tweet_media: Final[Tag | None] = tweet.select_one(
            '.tweet-body > .attachments')
        media_txt: str = ''
        if tweet_media is not None:
            media_list: Final[list[str]] = []
            # ツイートの画像の取得
            for image_a in tweet_media.select('.attachment.image a'):
                try:
                    href: str | list[str] | None = image_a.get('href')
                    assert isinstance(href, str)
                    img_matched: re.Match[str] | None = (
                        self._img_ext_pattern.search(href))
                    assert img_matched is not None
                    media_name: str = img_matched.group(1)
                    media_list.append(f'[[ファイル:{media_name}|240px]]')
                    if self._download_media(
                            urljoin(self.TWITTER_MEDIA_URL, media_name),
                            media_name,
                            accessor):
                        logger.info(
                            os.path.join(self.MEDIA_DIR, media_name)
                            + ' をアップロードしなければない。')
                    else:
                        logger.info(
                            urljoin(self.TWITTER_MEDIA_URL, media_name)
                            + ' をアップロードしなければない。')
                except AttributeError:
                    logger.error(f'{tweet_url}の画像が取得できませんでしたを 当職無能')
                    media_list.append('[[ファイル:(画像の取得ができませんでした)|240px]]')
 
            # ツイートの動画の取得。ffmpegが入っていなければ手動で取得すること
            for i, video_container in enumerate(
                    tweet_media.select('.attachment.video-container')):
                if not self._has_ffmpeg:
                    logger.warning(f'ffmpegがないため{tweet_url}の動画が取得できませんでしたを')
                    media_list.append('[[ファイル:(動画の取得ができませんでした)|240px]]')
                    continue
 
                # videoタグがない場合は取得できない
                video: Tag | None = video_container.select_one('video')
                if video is None:
                    logger.error(f'{tweet_url}の動画が取得できませんでしたを 当職無能')
                    media_list.append('[[ファイル:(動画の取得ができませんでした)|240px]]')
                    continue
 
                # videoタグのdata-url属性またはvideoタグ直下のsourceタグからURLが取得できる
                data_url: str | list[str] | None = video.get('data-url')
                source_tag: Tag | None = video.select_one('source')
                src_url: str | list[str] | None = \
                    source_tag.get('src') if source_tag is not None else None
                video_url: str | list[str] | None = data_url or src_url
                assert isinstance(video_url, str)
                tweet_id: str = tweet_url.split('/')[-1]
                if data_url is not None:
                    # data-url属性からURLを取得した場合
                    video_matched: re.Match[str] | None = re.search(
                        r'[^/]+$', video_url)
                    assert video_matched is not None
                    media_path: str = unquote(video_matched.group())
                    media_url: str = urljoin(self.NITTER_INSTANCE, media_path)
                    ts_filename: str | None = (
                        f'{os.path.join(self.MEDIA_DIR, tweet_id)}_{i}.ts'
                    )
                else:
                    # sourceタグからURLを取得した場合
                    media_url: str = video_url
                    ts_filename: str | None = None
                mp4_filename: str = (
                    f'{os.path.join(self.MEDIA_DIR, tweet_id)}_{i}.mp4'
                )
 
                match self._download_m3u8(
                        media_url,
                        ts_filename,
                        mp4_filename,
                        accessor.proxies):
                    case FfmpegStatus.MP4:
                        logger.info(f'{mp4_filename}をアップロードしなければない。')
                        media_list.append(f'[[ファイル:{tweet_id}_{i}.mp4|240px]]')
                    case FfmpegStatus.TS:
                        logger.info(f'{ts_filename}.tsをmp4に変換してアップロードしなければない。')
                        media_list.append(f'[[ファイル:{tweet_id}_{i}.mp4|240px]]')
                    case FfmpegStatus.FAILED:
                        logger.error(f'{tweet_url}の動画が取得できませんでしたを 当職無能')
                        media_list.append('[[ファイル:(動画の取得ができませんでした)|240px]]')
                    case _ as unreachable:  # pyright: ignore[reportUnnecessaryComparison] # noqa: E501
                        assert_never(unreachable)
 
            media_txt = ' '.join(media_list)
        return media_txt
 
    def _get_tweet_quote(self, tweet: Tag, accessor: AccessorHandler) -> str:
        """引用リツイートの引用元へのリンクを取得する。
 
        Args:
            tweet (Tag): ツイート内容である ``.timeline-item`` タグを表すbeautifulsoup4タグ。
            accessor (AccessorHandler): アクセスハンドラ。
 
        Returns:
            str: Archiveテンプレートでラップされた引用元ツイートへのリンク。
        """
        tweet_quote: Final[Tag | None] = tweet.select_one(
            '.tweet-body > .quote.quote-big')  # 引用リツイートを選択
        quote_txt: str = ''
        if tweet_quote is not None:
            quote_link: Final[Tag | None] = (
                tweet_quote.select_one('.quote-link'))
            assert quote_link is not None
            link_href: Final[str | list[str] | None] = quote_link.get('href')
            assert isinstance(link_href, str)
            link: str = self._url_fragment_pattern.sub('', link_href)
            link = urljoin(self.TWITTER_URL, link)
            quote_txt = self._archive_url(link, accessor)
        tweet_quote_unavailable: Final[Tag | None] = tweet.select_one(
            '.tweet-body > .quote.unavailable')  # 引用リツイートを選択
        if tweet_quote_unavailable is not None:
            quote_txt = '(引用元が削除されました)'
        return quote_txt
 
    def _get_tweet_poll(self, tweet: Tag) -> str:
        """ツイートの投票結果を取得する。
 
        Args:
            tweet (Tag): ツイート内容である ``.timeline-item`` タグを表すbeautifulsoup4タグ。
 
        Returns:
            str: Wiki形式に書き直した投票結果。
        """
        tweet_poll: Final[Tag | None] = tweet.select_one('.tweet-body > .poll')
        poll_txt: str = ''
        if tweet_poll is not None:
            poll_meters: Final[ResultSet[Tag]] = tweet_poll.select(
                '.poll-meter')
            poll_info: Final[Tag | None] = tweet_poll.select_one('.poll-info')
            assert poll_info is not None
            for poll_meter in poll_meters:
                poll_choice_value: Tag | None = poll_meter.select_one(
                    '.poll-choice-value')
                assert poll_choice_value is not None
                ratio: str = poll_choice_value.text
                poll_choice_option: Tag | None = poll_meter.select_one(
                    '.poll-choice-option')
                assert poll_choice_option is not None
                if 'leader' in poll_meter['class']:
                    poll_txt += ('<br>\n'
                                '&nbsp; <span style="display: inline-block; '
                                'width: 30em; background: linear-gradient('
                                'to right, '
                                f'rgba(29, 155, 240, 0.58) 0 {ratio}, '
                                f'transparent {ratio} 100%); '
                                'font-weight: bold;">') \
                        + ratio + ' ' + poll_choice_option.text + '</span>'
                else:
                    poll_txt += ('<br>\n'
                                '&nbsp; <span style="display: inline-block; '
                                'width: 30em; background: linear-gradient('
                                'to right, '
                                f'rgb(207, 217, 222) 0 {ratio}, '
                                f'transparent {ratio} 100%);">') \
                        + ratio + ' ' + poll_choice_option.text + '</span>'
            poll_txt += '<br>\n&nbsp; <span style="font-size: small;">' \
                + poll_info.text + '</span>'
        return poll_txt
 
    def _get_timeline_items(self, soup: Tag) -> list[Tag]:
        """タイムラインのツイートを取得。
 
        基本的に投稿時刻の降順に取得し、リプライツリーは最後のツイートの時刻を基準として降順にひとまとまりにする。
 
        Args:
            soup (Tag): Nitterのページを表すbeautifulsoup4タグ。
 
        Returns:
            list[Tag]: ツイートのアイテムである ``.timeline-item`` タグを表すTagオブジェクトのリスト。
        """
        timeline_item_list: Final[list[Tag]] = []
        for item_or_list in soup.select(
                '.timeline > .timeline-item, .timeline > .thread-line'):
            if 'unavailable' in item_or_list.attrs['class']:
                continue
            elif 'thread-line' in item_or_list.attrs['class']:
                # そのままtimeline-itemクラスをfind_allするとツイートの順番が逆転するので、順番通りに取得するよう処理
                for item in reversed(item_or_list.select('.timeline-item')):
                    timeline_item_list.append(item)
            else:
                timeline_item_list.append(item_or_list)
        return timeline_item_list
 
    def _archive_soup(self, tag: Tag, accessor: AccessorHandler) -> None:
        """ツイート内のaタグをテンプレートArchiveの文字列に変化させる。
 
        NitterリンクをYouTubeへのリンクに、bibliogramへのリンクをInstagramへのリンクに修正する。
 
        Args:
            tag (Tag): ツイート内容の本文である ``.media-body`` タグを表すbeautifulsoup4タグ。
            accessor (AccessorHandler): アクセスハンドラ。
        """
        urls_in_tweet: Final[ResultSet[Tag]] = tag.find_all('a')
        for url in urls_in_tweet:
            href: str | list[str] | None = url.get('href')
            assert isinstance(href, str)
 
            if href.startswith('https://') or href.startswith('http://'):
                # 先頭にhttpが付いていない物はハッシュタグの検索ページへのリンクなので処理しない
                if href.startswith('https' + self.NITTER_INSTANCE[4:]):
                    # Nitter上のTwitterへのリンクを直す
                    url_link: str = href.replace(
                        'https' + self.NITTER_INSTANCE[4:], self.TWITTER_URL)
                    url_link = self._url_query_pattern.sub('', url_link)
                    url.replace_with(self._archive_url(url_link, accessor))
                elif href.startswith('https://nitter.kavin.rocks/'):
                    # Nitter上のTwitterへのリンクを直す
                    url_link: str = href.replace(
                        'https://nitter.kavin.rocks/', self.TWITTER_URL)
                    url_link = self._url_query_pattern.sub('', url_link)
                    url.replace_with(self._archive_url(url_link, accessor))
                elif (hasattr(self, '_invidious_pattern')
                        and self._invidious_pattern.search(href)):
                    # Nitter上のYouTubeへのリンクをInvidiousのものから直す
                    invidious_href: str | list[str] | None = (
                        self._invidious_pattern.sub(
                            'youtube.com' if (
                                re.match(r'https://[^/]+/[^/]+/', href)
                                or re.search(r'/@[^/]*$', href)
                            ) else 'youtu.be',
                            href))
                    url.replace_with(self._archive_url(
                        invidious_href, accessor))
                elif href.startswith('https://bibliogram.art/'):
                    # Nitter上のInstagramへのリンクをBibliogramのものから直す
                    # Bibliogramは中止されたようなのでそのうちリンクが変わるかも
                    url_link: str = href.replace(
                        'https://bibliogram.art/',
                        'https://www.instagram.com/')
                    url.replace_with(self._archive_url(url_link, accessor))
                else:
                    url.replace_with(self._archive_url(href, accessor))
            elif url.text.startswith('@'):
                url_link: str = urljoin(self.TWITTER_URL, href)
                url_text: str = url.text
                url.replace_with(
                    self._archive_url(
                        url_link, accessor, url_text))
 
    def _archive_url(
        self,
        url: str,
        accessor: AccessorHandler,
        text: str | None = None
    ) -> str:
        """URLをArchiveテンプレートでラップする。
 
        フラグメント識別子がURLに含まれていたら、Archive側のURLにも反映させる。
 
        Args:
            url (str): ラップするURL。
            accessor (AccessorHandler): アクセスハンドラ。
            text (str | None, optional): ArchiveテンプレートでURLの代わりに表示する文字列。
 
        Returns:
            str: ArchiveタグでラップしたURL。
        """
        if '#' in url:  # フラグメント識別子の処理
            main_url, fragment = url.split('#', maxsplit=1)
            return TableBuilder.archive_url(
                url, self._archive(main_url, accessor) + '#' + fragment, text)
        else:
            return TableBuilder.archive_url(
                url, self._archive(url, accessor), text)
 
    def _callinshowlink_url(self, url: str, accessor: AccessorHandler) -> str:
        """URLをCallinShowLinkテンプレートでラップする。
 
        Args:
            url (str): ラップするURL。
            accessor (AccessorHandler): アクセスハンドラ。
 
        Returns:
            str: CallinShowLinkタグでラップしたURL。
        """
        return TableBuilder.callinshowlink_url(
            url, self._archive(url, accessor))
 
    def _archive(self, url: str, accessor: AccessorHandler) -> str:
        """URLから対応するarchive.todayのURLを返す。
 
        取得できれば魚拓ページのURLを返す。
        魚拓がなければその旨の警告を表示し、https://archive.ph/https%3A%2F%2Fxxxxxxxx の形で返される。
        アクセスに失敗すればその旨の警告を表示し、https://archive.ph/https%3A%2F%2Fxxxxxxxx の形で返される。
 
        Args:
            url (str): 魚拓を取得するURL。
            accessor (AccessorHandler): アクセスハンドラ。


         if subprocess.run(['which', 'ffmpeg'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode != 0:
         Returns:
          print(f"ffmpegがないため{tweet_url}の動画が取得できませんでしたを")
            str: 魚拓のURL。
          media_list.append(f"[[ファイル:(動画の取得ができませんでした)|240px]]")
        """
         else: # ffmpegがある場合
         archive_url: str = urljoin(
          media_url: str = unquote(re.search(r'[^\/]+$', video.get('data-url')).group(0))
            self.ARCHIVE_TODAY_STANDARD,
          tweet_id: str = tweet_url.split('/')[-1]
            quote(unquote(url), safe='&=+?%'))
          # 動画のダウンロード
        res: Final[str | None] = accessor.request(urljoin(
          if accessor.proxies is not None:
            self.ARCHIVE_TODAY, quote(unquote(url), safe='&=+?%')))
              returncode: int = subprocess.run(["ffmpeg", "-y", "-http_proxy", "accessor.proxies['http']", "-i", urljoin(self.NITTER_INSTANCE, media_url), "-c", "copy", f"{os.path.join(self.MEDIA_DIR, tweet_id)}_{i}.ts"], stdout=subprocess.DEVNULL).returncode
        if res is None: # 魚拓接続失敗時処理
          else:
            # https://archive.ph/https%3A%2F%2Fxxxxxxxxの形で返される
              returncode: int = subprocess.run(["ffmpeg", "-y", "-i", urljoin(self.NITTER_INSTANCE, media_url), "-c", "copy", f"{os.path.join(self.MEDIA_DIR, tweet_id)}_{i}.ts"], stdout=subprocess.DEVNULL).returncode
            logger.error(archive_url + 'にアクセス失敗ナリ。出力されるテキストにはそのまま記載されるナリ。')
          # 取得成功したらtsをmp4に変換
        else:
          if returncode == 0:
            soup: Final[BeautifulSoup] = BeautifulSoup(res, 'html.parser')
            ts2mp4_returncode: int = subprocess.run(["ffmpeg", "-y", "-i", f"{os.path.join(self.MEDIA_DIR, tweet_id)}_{i}.ts", "-acodec", "copy", "-vcodec", "copy", f"{os.path.join(self.MEDIA_DIR, tweet_id)}_{i}.mp4"], stdout=subprocess.DEVNULL).returncode
            content: Final[Tag | NavigableString | None] = soup.find(
            if ts2mp4_returncode == 0:
                id='CONTENT') # archive.todayの魚拓一覧ページの中身だけ取得
              print(f"{os.path.join(self.MEDIA_DIR, tweet_id)}_{i}.mp4をアップロードしなければない。")
            if (content is None or content.get_text()[:len(self.NO_ARCHIVE)]
                    == self.NO_ARCHIVE): # 魚拓があるかないか判定
                logger.warning(url + 'の魚拓がない。これはいけない。')
             else:
             else:
              print(f"{os.path.join(self.MEDIA_DIR, tweet_id)}_{i}.tsをmp4に変換してアップロードしなければない。")
                assert isinstance(content, Tag)
             media_list.append(f"[[ファイル:{tweet_id}_{i}.mp4|240px]]")
                content_a: Final[Tag | NavigableString | None] = (
          else:
                    content.select_one('.TEXT-BLOCK > a'))  # 最新の魚拓を取得
             print(f"{tweet_url}の動画が取得できませんでしたを 当職無能")
                assert isinstance(content_a, Tag)
             media_list.append(f"[[ファイル:(動画の取得ができませんでした)|240px]]")
                href: Final[str | list[str] | None] = content_a.get('href')
      media_txt = ' '.join(media_list)
                assert isinstance(href, str)
     return media_txt
                archive_url = href.replace(
                    self.ARCHIVE_TODAY, self.ARCHIVE_TODAY_STANDARD)
        return archive_url
 
    def _get_tweet(self, accessor: AccessorHandler) -> bool:
        """ページからツイート本文を ``TableBuilder`` インスタンスに収めていく。
 
        ツイートの記録を中断するための文を発見するか、記録したツイート数が :const:`~LIMIT_N_TWEETS` 件に達したら
        `False` を返す。
 
        Args:
            accessor (AccessorHandler): アクセスハンドラ。
 
        Returns:
            bool: 終わりにするツイートを発見するか、記録件数が上限に達したら `False`。
        """
        soup: Final[BeautifulSoup] = BeautifulSoup(
            self._nitter_page, 'html.parser')
        tweets: Final[list[Tag]] = self._get_timeline_items(soup)
        for tweet in tweets:
            tweet_a: Tag | None = tweet.a
            assert tweet_a is not None
            if tweet_a.text == self.NEWEST:
                # Load Newestのボタンは処理しない
                continue
            if tweet.find(class_='retweet-header') is not None:
                # retweet-headerはリツイートを示すので入っていれば処理しない
                continue
            if tweet.find(class_='pinned') is not None:
                # pinnedは固定ツイートを示すので入っていれば処理しない
                continue
            if len(self._query_strs) > 0:
                # クエリが指定されている場合、一つでも含まないツイートは処理しない
                not_match: bool = False
                for query_str in self._query_strs:
                    if query_str not in tweet.text:
                        not_match = True
                        break
                if not_match:
                    continue
 
            tweet_link: Tag | NavigableString | None = tweet.find(
                class_='tweet-link')
            assert isinstance(tweet_link, Tag)
            href: str | list[str] | None = tweet_link.get('href')
            assert isinstance(href, str)
            tweet_url: str = urljoin(
                self.TWITTER_URL,
                self._url_fragment_pattern.sub('', href))
 
            # 日付の更新処理
            date: datetime = self._tweet_date(tweet_url)
            self._table_builder.next_day_if_necessary(date)
 
            tweet_callinshow_template: str = self._callinshowlink_url(
                tweet_url, accessor)
            tweet_content: Tag | NavigableString | None = tweet.find(
                class_='tweet-content media-body')
            assert isinstance(tweet_content, Tag)
            self._archive_soup(tweet_content, accessor)
            media_txt: str = self._fetch_tweet_media(
                tweet, tweet_url, accessor)
            quote_txt: str = self._get_tweet_quote(tweet, accessor)
            poll_txt: str = self._get_tweet_poll(tweet)
            self._table_builder.append(
                tweet_callinshow_template, '<br>\n'.join(
                    filter(
                        None,
                        [
                            TableBuilder.escape_wiki_reserved_words(
                                tweet_content.get_text()),
                            quote_txt, media_txt, poll_txt
                        ])))
 
            if self._table_builder.count % self.REPORT_INTERVAL == 0:
                logger.info(
                    f'ツイートを{self._table_builder.count}件も記録したンゴwwwwwwwwwww')
            if self._stop != '' and self._stop in tweet_content.get_text():
                logger.info('目的ツイート発見でもう尾張屋根')
                self._table_builder.dump_file()
                return False
             if self._table_builder.count >= self.LIMIT_N_TWEETS:
                logger.info(f'{self.LIMIT_N_TWEETS}件も記録している。もうやめにしませんか。')
                self._table_builder.dump_file()
                return False
        return True
 
    def _go_to_new_page(self, accessor: AccessorHandler) -> bool:
        """Nitterで次のページに移動する。
 
        次のページが無ければ `False` を返す。
 
        Args:
            accessor (AccessorHandler): アクセスハンドラ。
 
        Returns:
            bool: 次のページを取得できれば `True`。
        """
        soup: Final[BeautifulSoup] = BeautifulSoup(self._nitter_page,
                                                  'html.parser')
        show_mores: Final[ResultSet[Tag]] = soup.find_all(class_='show-more')
        assert len(show_mores) > 0
 
        new_url: str = ''
        for show_more in show_mores:  # show-moreに次ページへのリンクか前ページへのリンクがある
            show_more_a: Tag | None = show_more.a
            assert show_more_a is not None
            href: str | list[str] | None = show_more_a.get('href')
            assert isinstance(href, str)
            new_url = urljoin(
                self.NITTER_INSTANCE,
                self._name
                + '/'
                + self.TWEETS_OR_REPLIES
                + href)  # 直下のaタグのhrefの中身取ってURL頭部分と合体
 
        res: Final[str | None] = accessor.request(new_url)
        if res is None:
            self._on_fail(accessor)
            return False
        new_page_soup: Final[BeautifulSoup] = BeautifulSoup(res, 'html.parser')
        if new_page_soup.find(class_='timeline-end') is None:
            # ツイートの終端ではtimeline-endだけのページになるので判定
            logger.info(new_url + 'に移動しますを')
            self._nitter_page = res  # まだ残りツイートがあるのでページを返して再度ツイート本文収集
            return True
        else:
             logger.info('急に残りツイートが無くなったな終了するか')
            self._table_builder.dump_file()
            return False
 
    def _signal_handler(
        self, signum: int, frame: FrameType | None
    ) -> NoReturn:
        """ユーザがCtrl + Cでプログラムを止めたときのシグナルハンドラ。
 
        Args:
            signum (int): シグナル番号。想定されるのはSIGINTのみ。
             frame (FrameType | None): 現在のスタックフレーム。
        """
        logger.info('ユーザがプログラムを中止したなりを')
        self._table_builder.dump_file()
        sys.exit()
 
    @deprecated('\033[31m'
                'Nitterはサービスを終了しており、いずれすべてのインスタンスが使えなくなるナリ。'
                '--search-unarchived (-u)オプションを外してarchive.todayから収集するモードを'
                '使ってね(笑)、それはできるよね。'
                'See also: https://nitter.cz'
                '\033[0m')
    def execute(
        self,
        krsw: str | None = None,
        use_browser: bool = True,
        enable_javascript: bool = True
    ) -> None:
        """通信が必要な部分のロジック。
 
        Args:
            krsw (str | None, optional): `None` でない場合、名前が自動で \
                :const:`~CALLINSHOW` になり、クエリと終わりにするツイートが自動で無しになる。\
                更に空文字でもない場合、この引数が終わりにするツイートになる。
            use_browser (bool, optional): `True` ならSeleniumを利用する。\
                `False` ならRequestsのみでアクセスする。
            enable_javascript (bool, optional): SeleniumでJavaScriptを利用する場合は \
                `True`。
 
        .. deprecated:: 4.2.0
            Nitterはサービスを終了しており、いずれすべてのインスタンスが使えなくなる [2]_。
            :class:`~ArchiveCrawler` はarchive.todayからツイートを収集するため引き続き利用可能。
        """
 
        # Seleniumドライバーを必ず終了するため、with文を利用する。
        with AccessorHandler(use_browser, enable_javascript) as accessor:
            # 実行前のチェック
            self._check_nitter_instance(accessor)
            self._check_archive_instance(accessor)
            # Invidiousのインスタンスリストの正規表現パターンを取得
            invidious_url_tuple: Final[tuple[str, ...]] = (
                self._invidious_instances(accessor)
            )
            self._invidious_pattern: re.Pattern[str] = re.compile(
                '|'.join(invidious_url_tuple))
 
            # 検索クエリの設定
            if not self._set_queries(accessor, krsw):
                sys.exit(1)
 
            # ツイートを取得し終えるまでループ
            signal.signal(signal.SIGINT, self._signal_handler)
            try:
                while True:
                    if not self._get_tweet(accessor):
                        break
                    if not self._go_to_new_page(accessor):
                        break
            except BaseException:
                logger.critical('予想外のエラーナリ。ここまでの成果をダンプして終了するナリ。')
                self._table_builder.dump_file()
                raise
 
 
class UrlTuple(NamedTuple):
    """URLとその魚拓のURLのペア。"""
    url: str
    """URL。"""
    archive_url: str
    """魚拓のURL。"""
 
 
class ArchiveCrawler(TwitterArchiver):
     """archive.todayに記録された尊師のツイートのうち、Wiki未掲載のものを収集する。


  def _get_tweet_quote(self, tweet: bs4.element.Tag, accessor: AccessorHandler) -> str:
    Warning:
    """引用リツイートの引用元へのリンクを取得する。
        このモードではURLリストの他にツイート本文も整形して取得するナリが、
        機能のテストが不十分であることと、TwitterのHTML構造はしばしば変更されることから、
        整形後の出力が正しいとは限らないナリ


    Args:
        機能を過信せず、自分の目で確かめてね(笑)、それはできるよね。
      tweet bs4.element.Tag: ツイート内容である ``.timeline-item`` タグを表すbeautifulsoup4タグ。
      accessor AccessorHandler: アクセスハンドラ


     Returns:
     Todo:
      str: Archiveテンプレートでラップされた引用元ツイートへのリンク。
        * ちゃんとテストする。
     """
     """
     tweet_quote: Final[bs4.element.Tag | None] = tweet.select_one('.tweet-body > .quote.quote-big') # 引用リツイートを選択
     WIKI_URL: Final[str] = 'https://krsw-wiki.in'
     quote_txt: str = ''
     """Final[str]: WikiのURL。"""
     if tweet_quote is not None:
 
      link: str = tweet_quote.select_one('.quote-link').get('href')
    TEMPLATE_URL: Final[str] = WIKI_URL + '/wiki/テンプレート:降臨ショー恒心ログ'
      link = re.sub('#.*$', '', link)
     """Final[str]: テンプレート:降臨ショー恒心ログのURL。"""
      link = urljoin(self.TWITTER_URL, link)
 
      quote_txt = self._archive_url(link, accessor)
    URL_LIST_FILENAME: Final[str] = \
    tweet_quote_unavailable: Final[bs4.element.Tag | None] = tweet.select_one('.tweet-body > .quote.unavailable') # 引用リツイートを選択
        UserProperties.ArchiveCrawler.url_list_filename
    if tweet_quote_unavailable is not None:
    """Final[str]: URLのリストをダンプするファイル名。"""
      quote_txt = '(引用元が削除されました)'
 
    return quote_txt
    @override
    def _set_queries(
        self, accessor: AccessorHandler, krsw: str | None
    ) -> bool:
        """検索条件を設定する。
 
        :class:`~TwitterArchiver` のメンバをセットし、ツイートを収集するアカウントを入力させ、
        収集するツイートのうち最古のツイートIDを指定させる。


  def _get_tweet_poll(self, tweet: bs4.element.Tag) -> str:
        Args:
    """ツイートの投票結果を取得する。
            accessor (AccessorHandler): アクセスハンドラ
            krsw (str | None): `None` でない場合、名前が :const:`~CALLINSHOW` になる。


    Args:
        Returns:
      tweet bs4.element.Tag: ツイート内容である ``.timeline-item`` タグを表すbeautifulsoup4タグ。
            bool: 処理成功時は `True`
        """


    Returns:
        # ユーザー名取得
      str: Wiki形式に書き直した投票結果。
        if krsw is not None:
    """
            logger.info('名前は自動的に' + self.CALLINSHOW + 'にナリます')
    tweet_poll: Final[bs4.element.Tag | None] = tweet.select_one('.tweet-body > .poll')
            self._name: str = self.CALLINSHOW
    poll_txt: str = ''
    if tweet_poll is not None:
      poll_meters: Final[bs4.element.ResultSet] = tweet_poll.select('.poll-meter')
      for poll_meter in poll_meters:
        ratio: str = poll_meter.select_one('.poll-choice-value').text
        if 'leader' in poll_meter['class']:
          poll_txt += f'<br>\n&nbsp; <span style="display: inline-block; width: 30em; background: linear-gradient(to right, rgba(29, 155, 240, 0.58) 0 {ratio}, transparent {ratio} 100%); font-weight: bold;">' + ratio + ' ' + poll_meter.select_one('.poll-choice-option').text + '</span>'
         else:
         else:
          poll_txt += f'<br>\n&nbsp; <span style="display: inline-block; width: 30em; background: linear-gradient(to right, rgb(207, 217, 222) 0 {ratio}, transparent {ratio} 100%);">' + ratio + ' ' + poll_meter.select_one('.poll-choice-option').text + '</span>'
            name_optional: str | None = self._input_name(accessor)
      poll_txt += '<br>\n&nbsp; <span style="font-size: small;">' + tweet_poll.select_one('.poll-info').text + '</span>'
            if name_optional is not None:
    return poll_txt
                self._name: str = name_optional
            else:
                return False
        # 探索対象のうち最古のツイートID取得
        oldest_id: str = self._input_oldest_id()
        self._oldest_id_queue: deque[int] = deque(map(int, oldest_id)) if \
            len(oldest_id) > 0 else deque([0])
        self._latest_id_digit: int = self._oldest_id_queue.popleft()
        self._oldest_id_queue.append(0) # 空になると予想外の動作を起こしかねないため
        self._oldest_url: str = self.TWITTER_URL + self._name + '/status/' \
            + oldest_id
        logger.info(
            'ユーザー名: @' + self._name
            + ', 最古のURL: ' + self._oldest_url + '*' + 'で検索しまふ'
        )
        self._twitter_url_pattern: re.Pattern[str] = re.compile(
            '^' + self.TWITTER_URL + self._name + r'/status/\d+')
        self._archive_rt_pattern: re.Pattern[str] = re.compile(
            r'on (?:Twitter|X): "RT @\w+:.+"(?:$| / Twitter$| / X$)')
 
        self._url_list_on_wiki: list[str] = []
        self._url_list: list[UrlTuple] = []
        return True
 
    def _input_oldest_id(self) -> str:
        """探索対象のうち、最古のツイートのIDを入力させる。
 
        正しい形式のツイートIDまたは空白が入力されるまで繰り返す。
 
        Returns:
            str: ツイートIDまたは空文字列。
        """
        while True:
            print('ツイートを新しい順に取得するので、収集をストップするツイートIDを入力して下さいナリ')
            print('空白だと全ツイートを収集しますを')
            print('ツイートIDとは、`' + self.TWITTER_URL + self.CALLINSHOW
                  + '/status/`の後ろの数字でふ')
            print('> ', end='')
            oldest_id: str = input()
            if re.match(r'^\d*$', oldest_id) is not None:
                return oldest_id
            else:
                print('整数か空白を入力して下さいナリ')


  def _get_timeline_items(self, soup: BeautifulSoup) -> list[bs4.element.Tag]:
    def _get_tweet_urls_from_wiki(self, accessor: AccessorHandler) -> None:
    """タイムラインのツイートを取得。
        """Wikiに未掲載のツイートのURLリストを取得する。


    基本的に投稿時刻の降順に取得し、リプライツリーは最後のツイートの時刻を基準として降順にひとまとまりにする。
        Args:
            accessor (AccessorHandler): アクセスハンドラ。
        """
        def assert_get(tag: Tag, key: str) -> str:
            """BeautifulSoupのタグから属性値を取得する。


    Args:
            Args:
      soup BeautifulSoup: Nitterのページを表すBeautifulSoupオブジェクト。
                tag (Tag): BeautifulSoupのタグ。
                key (str): 属性キー。


    Returns:
            Returns:
      list[bs4.element.Tag]: ツイートのアイテムである ``.timeline-item`` タグを表すbs4.element.Tagオブジェクトのリスト。
                str: タグの属性値。
    """
            """
    timeline_item_list: list[bs4.element.Tag] = []
            result: Final[str | list[str] | None] = tag.get(key)
    for item_or_list in soup.select('.timeline > .timeline-item, .timeline > .thread-line'):
            assert isinstance(result, str)
      if 'unavailable' in item_or_list.attrs['class']:
            return result
        continue
      elif 'thread-line' in item_or_list.attrs['class']:
        # そのままtimeline-itemクラスをfind_allするとツイートの順番が逆転するので、順番通りに取得するよう処理
        for item in reversed(item_or_list.select('.timeline-item')):
          timeline_item_list.append(item)
      else:
        timeline_item_list.append(item_or_list)
    return timeline_item_list


  def _get_tweet(self, accessor: AccessorHandler) -> None | NoReturn:
        template_page: Final[str | None] = (
    """ページからツイート本文を ``self._txt_data`` に収めていく。
            accessor.request_with_requests_module(self.TEMPLATE_URL))
        assert template_page is not None
        template_soup: Final[BeautifulSoup] = BeautifulSoup(
            template_page,
            'html.parser')


    ツイートの記録を中断するための文を発見するか、記録したツイート数が :const:`~LIMIT_N_TWEETS` 件に達したら終了する。
        urls: Final[list[str]] = list(map(
            lambda x: self.WIKI_URL + assert_get(x, 'href'),
            template_soup.select('.wikitable > tbody > tr > td a')))
        for url in urls:
            logger.info(f'{unquote(url)} で収集済みツイートを探索中でふ')
            page: str | None = accessor.request_with_requests_module(url)
            assert page is not None
            soup: BeautifulSoup = BeautifulSoup(page, 'html.parser')
            url_as = soup.select('tr > th a')
            assert len(url_as) > 0
            for url_a in url_as:
                href: str | list[str] | None = url_a.get('href')
                assert isinstance(href, str)
                if href.startswith(self.TWITTER_URL + self._name):
                    self._url_list_on_wiki.append(href)


     Args:
     def _append_tweet_urls(self, soup: Tag) -> None:
      accessor AccessorHandler: アクセスハンドラ
         """ツイートのURLを保存する。
    """
    soup: Final[BeautifulSoup] = BeautifulSoup(self._page, 'html.parser') ##beautifulsoupでレスポンス解析
    tweets: Final[list[bs4.element.Tag]] = self._get_timeline_items(soup) ##一ツイートのブロックごとにリストで取得
    for tweet in tweets: ##一ツイート毎に処理
      if tweet.a.text == self.NEWEST: ##Load Newestのボタンは処理しない
        continue
      if tweet.find(class_='retweet-header') is not None: ##retweet-headerはリツイートを示すので入っていれば処理しない
         continue
      if tweet.find(class_='pinned') is not None: ##pinnedは固定ツイートを示すので入っていれば処理しない
        continue
      if len(self.query_strs) > 0: # クエリが指定されている場合、一つでも含まないツイートは処理しない、未テスト
        not_match: bool = False
        for query_str in query_strs:
          if query_str not in tweet.text:
            not_match = True
            break
        if not_match:
          continue


      tweet_url: str = urljoin(self.TWITTER_URL, re.sub('#[^#]*$', '', tweet.find(class_='tweet-link').get('href'))) ##ツイートのURL作成
         リツイートはここで除外する。
      date: datetime = self._tweet_date(tweet)
      if date.year != self._date.year or date.month != self._date.month or date.day != self._date.day:
         self._next_day(date)
      archived_tweet_url: str = self._callinshowlink_url(tweet_url, accessor) ##ツイートURLをテンプレートCallinShowlinkに変化
      tweet_content: bs4.element.Tag = tweet.find(class_='tweet-content media-body') ##ツイートの中身だけ取り出す
      self._archive_soup(tweet_content, accessor) ##ツイートの中身のリンクをテンプレートArchiveに変化
      media_txt: str = self._get_tweet_media(tweet, accessor) ##ツイートに画像などのメディアを追加
      quote_txt: str = self._get_tweet_quote(tweet, accessor) ##引用リツイートの場合、元ツイートを追加
      poll_txt: str = self._get_tweet_poll(tweet) ##投票の取得
      self._txt_data[0] = '!' + archived_tweet_url + '\n|-\n|\n' \
        + '<br>\n'.join(filter(None, [
          self._escape_wiki_reserved_words(tweet_content.get_text()),
          quote_txt,
          media_txt,
          poll_txt
        ])) \
        + '\n|-\n' \
        + self._txt_data[0] ##wikiの文法に変化
      self._limit_count += 1 ##記録回数をカウント
      if self._limit_count % self.REPORT_INTERVAL == 0:
        print(f"ツイートを{self._limit_count}件も記録したンゴwwwwwwwwwww")
      if self._stop != '' and self._stop in tweet_content.get_text(): ##目的ツイートか判定
        print("目的ツイート発見でもう尾張屋根")
        self._make_txt()
      if self._limit_count >= self.LIMIT_N_TWEETS: ##上限達成か判定
        print(f"{self.LIMIT_N_TWEETS}件も記録している。もうやめにしませんか。")
        self._make_txt()


  def _escape_wiki_reserved_words(self, text: str) -> str:
        Args:
    """MediaWikiの文法と衝突する文字を無効化する。
            soup (Tag): archive.todayでのURL検索結果のページのオブジェクト。
        """
        tweets: Final[ResultSet[Tag]] = soup.select(
            '#CONTENT > div > .TEXT-BLOCK')
        for tweet in tweets:
            # リダイレクトがあればすべてのリンクを、ないなら目的のURLだけが取得できる
            urls: list[str] = list(map(
                lambda a: a.text, tweet.select('a')[1:]))
            # 別のユーザへのリダイレクトがあるものは除く
            if len(set(map(lambda url: urlparse(url).path, urls))) != 1:
                logger.debug('Found an external redirect ' + str(urls))
                continue


    Args:
            url_matched: Final[re.Match[str]] | None = next(
      text str: ツイートの文字列。
                filter(
                    lambda x: x is not None,
                    map(lambda url: self._twitter_url_pattern.match(url), urls)
                ), None
            )  # 最初にマッチしたURLを返す
            if url_matched is not None:
                if url_matched.string < self._oldest_url:
                    logger.debug(url_matched.string + 'は最古の探索対象よりも古いのでポア')
                    continue
                a_first_child: Tag | None = tweet.select_one('a:first-child')
                assert a_first_child is not None
                archive_url: str | list[str] | None = a_first_child.get('href')
                assert isinstance(archive_url, str)
                if url_matched[0] not in self._url_list_on_wiki:
                    # ツイートのURLが未取得のものならばURLを保存する
                    text_tag: Tag | None = tweet.select_one('a')
                    assert text_tag is not None
                    if (self._archive_rt_pattern.search(text_tag.text) is not
                            None):
                        logger.debug(url_matched[0] + 'はリツイートなのでポア')
                        continue
                    self._url_list.append(
                        UrlTuple(url_matched[0], archive_url))
                    self._url_list_on_wiki.append(url_matched[0])
        self._url_list.sort(reverse=True, key=lambda x: x.url)  # 降順


     Returns:
     def _fetch_next_page(
      str: MediaWikiの文法と衝突する文字がエスケープされたツイートの文字列。
        self, soup: Tag, accessor: AccessorHandler
    """
     ) -> str | None:
     def escape_nolink_urls(text: str) -> str:
        """archive.todayの検索結果のページをpaginateする。
      """Archiveテンプレートの中にないURLがWikiでaタグに変換されないよう無効化する。


      Args:
        Args:
        text str: ツイートの文字列。
            soup (Tag): archive.todayでのURL検索結果のページのオブジェクト。
            accessor (AccessorHandler): アクセスハンドラ。


      Returns:
        Returns:
        str: Archiveテンプレートの中にないURLがnowikiタグでエスケープされた文字列。
            str | None: 次のページがあればそのHTML。
      """
        """
      is_in_archive_template: bool = False
        next_a: Final[Tag | None] = soup.select_one('#next')
      i: int = 0
         if next_a is not None:
      while i < len(text):
            link: Final[str | list[str] | None] = next_a.get('href')
         if is_in_archive_template:
            assert isinstance(link, str)
          if text[i:i+2] == '}}':
            page: Final[str | None] = accessor.request(link)
             is_in_archive_template = False
             assert page is not None
             i += 2
             return page
         else:
         else:
          if text[i:i+10] == '{{Archive|' or text[i:i+10] == '{{archive|':
            return
             is_in_archive_template = True
 
             i += 10
    def _get_tweet_loop(self, soup: Tag, accessor: AccessorHandler) -> None:
          elif text[i:i+8] == 'https://':
        """archive.todayの検索結果に対して、paginateしながら未記載のツイートURLを記録する。
            text = text[:i] + '<nowiki>https://</nowiki>' + text[i+8:]
 
             i += 25
        Args:
          elif text[i:i+7] == 'http://':
            soup (Tag): archive.todayでのURL検索結果のページのオブジェクト。
             text = text[:i] + '<nowiki>http://</nowiki>' + text[i+7:]
             accessor (AccessorHandler): アクセスハンドラ。
             i += 24
        """
        i += 1
        has_next: bool = True
      return text
        while has_next:
            self._append_tweet_urls(soup)
             next_page: str | None = self._fetch_next_page(soup, accessor)
            if next_page is not None:
                soup = BeautifulSoup(next_page, 'html.parser')
            else:
                has_next = False
 
    def _next_url(
        self,
        accessor: AccessorHandler,
        tweet_url_prefix: str,
        incremented_num: int,
        incremented: bool = False
    ) -> None:
        """ツイートのURLを、数字部分をインクリメントしながら探索する。
 
        `https://twitter.com/CallinShow/status/` に続く数字部分について、
        `tweet_url_prefix` で始まるものを、その次の桁を `incremented_num` から9までインクリメントして探索する。
 
        Args:
            accessor (AccessorHandler): アクセスハンドラ。
             tweet_url_prefix (str): ツイートURLの数字部分のうち、インクリメントする桁以前の部分。
            incremented_num (int): ツイートURLのうちインクリメントする桁の現在の数字。
            incremented (bool, optional): `incremented_num` がインクリメント済みの場合True。
 
        Examples:
             `https://twitter.com/CallinShow/status/1707` で始まるURLから最新まですべて探索する場合
             ::
 
                self._next_url(accessor, '1707', 0)


    text = text.replace('\n', '<br>\n')
            `https://twitter.com/CallinShow/status/165` で始まるURLから最新まですべて探索する場合
    text = re.sub(r'^ ', '&nbsp;', text, flags=re.MULTILINE)
            ::
    text = re.sub(r'^([\*#:;])', r'<nowiki>\1</nowiki>', text, flags=re.MULTILINE)
    text = re.sub(r'^----', '<nowiki>----</nowiki>', text, flags=re.MULTILINE)
    text = escape_nolink_urls(text)
    return text


  def _archive_soup(self, tag: bs4.element.Tag, accessor: AccessorHandler) -> None:
              self._next_url(accessor, '16', 5)
    """ツイート内のaタグをテンプレートArchiveの文字列に変化させる。
        """
        assert 0 <= incremented_num <= 9, \
            f'incremented_numが{incremented_num}でふ'
        logger.info(self.TWITTER_URL + self._name + '/status/'
                    + tweet_url_prefix + str(incremented_num) + '*を探索中')


    NitterリンクをYouTubeへのリンクに、bibliogramへのリンクをInstagramへのリンクに修正する。
        page: Final[str | None] = accessor.request(
            self.ARCHIVE_TODAY
            + self.TWITTER_URL
            + self._name
            + '/status/'
            + tweet_url_prefix
            + str(incremented_num) + '*')
        assert page is not None
        soup: Final[BeautifulSoup] = BeautifulSoup(page, 'html.parser')
 
        pager: Final[Tag | None] = soup.select_one('#pager')
        if pager is not None:  # 検索結果が複数ページ
            page_num_matched: Final[re.Match[str] | None] = re.search(
                r'of (\d+) urls', pager.text)
            assert page_num_matched is not None
            page_num: Final[int] = int(page_num_matched[1])
            if page_num > 100:  # ツイート数が100を超えると途中でreCAPTCHAが入るので、もっと細かく検索
                # ユーザが最初に"1512"と指定した時、tweet_url_prefix="151"ならincremented_numを2から開始したいが、
                # tweet_url_prefix="152"や"160"などならincremented_numを0から開始したい
                if incremented:
                    self._next_url(accessor,
                                  tweet_url_prefix + str(incremented_num), 0,
                                  True)
                else:
                    self._latest_id_digit = self._oldest_id_queue.popleft()
                    self._oldest_id_queue.append(0)  # 空になると予想外の動作を起こしかねないため
                    self._next_url(accessor,
                                  tweet_url_prefix + str(incremented_num),
                                  self._latest_id_digit)
            else:
                logger.debug(
                    self.TWITTER_URL + self._name + '/status/'
                    + tweet_url_prefix + str(incremented_num) + '*からURLを収集しまふ')
                self._get_tweet_loop(soup, accessor)
        else:  # 検索結果が1ページだけ
            if soup.select_one('.TEXT-BLOCK'):  # 検索結果が存在する場合
                logger.debug(
                    self.TWITTER_URL + self._name + '/status/'
                    + tweet_url_prefix + str(incremented_num) + '*からURLを収集しまふ')
                self._get_tweet_loop(soup, accessor)


    Args:
        # 次のurlを探索
      tag bs4.element.Tag: ツイート内容の本文である ``.media-body`` タグを表すbeautifulsoup4タグ。
         if incremented_num == 9:
      accessor AccessorHandler: アクセスハンドラ
             return
    """
    urls_in_tweet: Final[bs4.element.ResultSet] = tag.find_all('a')
    for url in urls_in_tweet:
      if url.get('href').startswith('https://') or url.get('href').startswith('http://'): ##先頭にhttpが付いていない物はハッシュタグの検索ページへのリンクなので処理しない
         if url.get('href').startswith('https' + self.NITTER_INSTANCE[4:]):
          #Nitter上のTwitterへのリンクを直す
          url_link: str = url.get('href').replace('https' + self.NITTER_INSTANCE[4:], self.TWITTER_URL)
          url_link = re.sub('\?.*$', '', url_link)
          url.replace_with(self._archive_url(url_link, accessor)) ##テンプレートArchiveに変化
        elif url.get('href').startswith('https://nitter.kavin.rocks/'):
          #Nitter上のTwitterへのリンクを直す
          url_link: str = url.get('href').replace('https://nitter.kavin.rocks/', self.TWITTER_URL)
          url_link = re.sub('\?.*$', '', url_link)
          url.replace_with(self._archive_url(url_link, accessor)) ##テンプレートArchiveに変化
        elif self._invidious_pattern.search(url.get('href')):
          #Nitter上のYouTubeへのリンクをInvidiousのものから直す
          url_link: str = url.get('href')
          if re.match('https://[^/]+/[^/]+/', url_link) or re.search('/@[^/]*$', url_link):
            url_link = self._invidious_pattern.sub('youtube.com', url_link)
          else:
             url_link = self._invidious_pattern.sub('youtu.be', url_link)
          url.replace_with(self._archive_url(url_link, accessor)) ##テンプレートArchiveに変化
        elif url.get('href').startswith('https://bibliogram.art/'):
          # Nitter上のInstagramへのリンクをBibliogramのものから直す
          # Bibliogramは中止されたようなのでそのうちリンクが変わるかも
          url_link: str = url.get('href').replace('https://bibliogram.art/', 'https://www.instagram.com/')
          url.replace_with(self._archive_url(url_link, accessor)) ##テンプレートArchiveに変化
         else:
         else:
          url.replace_with(self._archive_url(url.get('href'), accessor)) ##テンプレートArchiveに変化
            self._next_url(accessor,
      elif url.text.startswith('@'):
                          tweet_url_prefix,
          url_link: str = urljoin(self.TWITTER_URL, url.get('href'))
                          incremented_num + 1,
          url_text: str = url.text
                          True)
          url.replace_with(self._archive_url(url_link, accessor, url_text)) ##テンプレートArchiveに変化
 
    def _parse_images(
        self, soup: Tag, accessor: AccessorHandler
    ) -> tuple[str, ...]:
        """ツイートの魚拓から画像をダウンロードし、ファイル名のタプルを返す。
 
        引用リツイート内の画像や、外部リンクの画像は除く。
 
        Args:
            soup (Tag): ツイートの魚拓のタグ。
            accessor (AccessorHandler): アクセスハンドラ。
 
        Returns:
            tuple[str, ...]: ファイル名のタプル。
        """
        image_list: list[str] = []
 
        image_tags: ResultSet[Tag] = soup.select(
            'img[alt="Image"]:not(div[role="link"] img[alt="Image"])')
        for image_tag in image_tags:
            media_url_path: str | list[str] | None = (
                image_tag.get('src'))
            assert isinstance(media_url_path, str)
            original_image_url: str | list[str] | None = (
                image_tag.get('new-cursrc'))
            assert isinstance(original_image_url, str)
            parse_result: ParseResult = urlparse(original_image_url)
            original_image_name: str = parse_result.path.split('/')[-1]
            original_extension: str = parse_qs(parse_result.query)['format'][0]
            original_image_name += '.' + original_extension
            self._download_media(
                urljoin(self.ARCHIVE_TODAY, media_url_path),
                original_image_name,
                accessor)
 
            image_list.append(original_image_name)
 
        return tuple(image_list)
 
    def _replace_links(self, tag: Tag, accessor: AccessorHandler) -> Tag:
        """ツイートの魚拓の、本文内のaタグを整形する。
 
        Args:
            tag (Tag): aタグを置き換えるべきタグ。
            accessor (AccessorHandler): アクセスハンドラ。
 
        Returns:
            Tag: テキスト内のaタグが置き換えられたタグ。
        """
        internal_a_tags: Final[ResultSet[Tag]] = tag.select(
            'div[data-testid="tweetText"]:not(div[role="link"] '
            f'div[data-testid="tweetText"]) a[href*="{self.TWITTER_URL}"]')
        # Twitter内部のリンク
        if len(internal_a_tags) > 0:
            for a_tag in internal_a_tags:
                account_name: str = a_tag.text
                if account_name.startswith('#'):
                    a_tag.replace_with(a_tag.text)
                else:
                    url: str = urljoin(self.TWITTER_URL, account_name[1:])
                    a_tag.replace_with(TableBuilder.archive_url(
                        url,
                        self._archive(url, accessor),
                        account_name))
 
        # 通常のリンク
        a_tags: Final[ResultSet[Tag]] = tag.select(
            'div[dir="auto"] > a:not('
            'div[role="link"] div[dir="auto"] > a)')
        for a_tag in a_tags:
            a_tag.replace_with(
                '{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}')
        return tag
 
    @override
    def _get_tweet_poll(self, tweet: Tag) -> str:
        card_poll: Tag | None = tweet.select_one('div[data-testid="cardPoll"]')
 
        poll_txt: str = ''
        if card_poll is not None:
            polls: list[tuple[str, str]] = []
            max_ratio: float = 0.
            leader_idx: int = 0
            tweet_lis = card_poll.select('li')
            for i, li in enumerate(tweet_lis):
                poll_data: ResultSet[Tag] = li.select('div > span')
                text_tag = poll_data[0]
                ratio_tag = poll_data[1]
                ratio: str = ratio_tag.text
                float_ratio: float = float(ratio[0:-1])
                if max_ratio < float_ratio:
                    leader_idx = i
                    max_ratio = float_ratio
                polls.append((text_tag.text, ratio))
 
            for i, poll_result in enumerate(polls):
                if i == leader_idx:
                    poll_txt += (
                        '<br>\n'
                        '&nbsp; <span style="display: inline-block; '
                        'width: 30em; background: linear-gradient('
                        'to right, '
                        f'rgba(29, 155, 240, 0.58) 0 {poll_result[1]}, '
                        f'transparent {poll_result[1]} 100%); '
                        'font-weight: bold;">'
                    ) + poll_result[1] + ' ' + poll_result[0] + '</span>'
                else:
                    poll_txt += (
                        '<br>\n'
                        '&nbsp; <span style="display: inline-block; '
                        'width: 30em; background: linear-gradient('
                        'to right, '
                        f'rgb(207, 217, 222) 0 {poll_result[1]}, '
                        f'transparent {poll_result[1]} 100%);">'
                    ) + poll_result[1] + ' ' + poll_result[0] + '</span>'
 
            votes_count_tag: Tag | None = card_poll.select_one(
                'div[data-testid="cardPoll"] > div')
            assert votes_count_tag is not None
            poll_txt += '<br>\n&nbsp; <span style="font-size: small;">' \
                + votes_count_tag.text + '</span>'
 
        return poll_txt
 
    @staticmethod
    def _retrieve_emojis(tag: Tag) -> Tag:
        """絵文字を画像タグからUnicodeに戻す。
 
        Args:
            tag (Tag): 絵文字の含まれる可能性のあるタグ。
 
        Returns:
            Tag: 絵文字をUnicodeに戻したタグ。
        """
        img_tags: Final[ResultSet[Tag]] = tag.select(
            'img[new-cursrc^="https://abs-0.twimg.com/emoji"]')
        for img_tag in img_tags:
            alt_text: str | list[str] | None = img_tag.get('alt')
            assert isinstance(alt_text, str)
            img_tag.replace_with(alt_text)
        return tag
 
    @staticmethod
    def _concat_texts(*texts: str) -> str:
        """2つ以上のテキストを改行タグを挟んで結合する。
 
        Args:
            *texts: 入力テキスト。
 
        Returns:
            str: 結合されたテキスト。
        """
        return '\n'.join(filter(None, texts))
 
    def _get_tweet_from_archive(
        self,
        url_pairs: list[UrlTuple],
        accessor: AccessorHandler
    ) -> None:
        """魚拓からツイート本文を取得する。


  def _archive_url(self, url: Final[str], accessor: AccessorHandler, text: Final[str|None] = None) -> str:
        Args:
    """URLをArchiveテンプレートでラップする。
            url_pairs (list[UrlTuple]): URLとその魚拓URLのペアのリスト。
            accessor (AccessorHandler): アクセスハンドラ。
        """
        table_builder: Final[TableBuilder] = TableBuilder()


    フラグメント識別子がURLに含まれていたら、Archive側のURLにも反映させる。
        for url_pair in url_pairs:
            page: str | None = accessor.request(url_pair.archive_url)
            assert page is not None
            article: Tag | None = BeautifulSoup(
                page, 'html.parser'
            ).select_one('article[tabindex="-1"]')
            # リツイート以外のURLを保存する
            if article is not None and article.select_one(
                    'span[data-testid="socialContext"]') is None:


    Args:
                logger.debug(url_pair.url + 'を整形しますを')
      url Final[str]: ラップするURL。
                tweet_date: datetime = self._tweet_date(url_pair.url)
      accessor AccessorHandler: アクセスハンドラ
                table_builder.next_day_if_necessary(tweet_date)
      text Final[str|None]: ArchiveテンプレートでURLの代わりに表示する文字列。


    Returns:
                tweet_callinshow_template: str = (
      str: ArchiveタグでラップしたURL。
                    TableBuilder.callinshowlink_url(
    """
                        url_pair.url, url_pair.archive_url.replace(
    if '#' in url: # フラグメント識別子の処理
                            self.ARCHIVE_TODAY, self.ARCHIVE_TODAY_STANDARD)))
      main_url, fragment = url.split('#', maxsplit=1)
      if text is None:
        return '{{Archive|1=' + unquote(url) + '|2=' + self._archive(main_url, accessor) + '#' + fragment + '}}' ##テンプレートArchiveの文字列返す
      else:
        return '{{Archive|1=' + unquote(url) + '|2=' + self._archive(main_url, accessor) + '#' + fragment + + '|3=' + text + '}}' ##テンプレートArchiveの文字列返す
    else:
      if text is None:
        return '{{Archive|1=' + unquote(url) + '|2=' + self._archive(url, accessor) + '}}' ##テンプレートArchiveの文字列返す
      else:
        return '{{Archive|1=' + unquote(url) + '|2=' + self._archive(url, accessor) + '|3=' + text + '}}' ##テンプレートArchiveの文字列返す


  def _callinshowlink_url(self, url: Final[str], accessor: AccessorHandler) -> str:
                # 本文
    """URLをCallinShowLinkテンプレートでラップする。
                try:
                    text_tag: Tag | None = article.select_one(
                        'div[dir="auto"]')
                    if text_tag is not None:
                        text_tag = self._retrieve_emojis(text_tag)
                        text: str = self._replace_links(text_tag,
                                                        accessor).text
                    else:
                        text: str = ''


    Args:
                    # YouTube等のリンク
      url Final[str]: ラップするURL。
                    card_tag: Tag | None = article.select_one(
      accessor AccessorHandler: アクセスハンドラ
                        'div[aria-label="Play"]:not(div[role="link"] '
                        'div[aria-label="Play"])')
                    if card_tag is not None:
                        text = self._concat_texts(
                            text,
                            '{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}')


    Returns:
                    # 画像に埋め込まれた外部サイトへのリンク
      str: CallinShowLinkタグでラップしたURL。
                    article_tag: Tag | None = article.select_one(
    """
                        'a[role="link"][aria-label] img:not(div[role="link"] '
    return '{{CallinShowLink|1=' + url + '|2=' + self._archive(url, accessor) + '}}'
                        'a[role="link"][aria-label] img)')
                    if article_tag is not None:
                        text = self._concat_texts(
                            text,
                            '{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}')


  def _archive(self, url: Final[str], accessor: AccessorHandler) -> str:
                    # 引用の有無のチェック
    """URLから対応するarchive.todayのURLを返す。
                    retweet_tag: Tag | None = article.select_one(
                        'div[role="link"]')
                    if retweet_tag is not None:
                        account_name_tag: Tag | None = (
                            retweet_tag.select_one(
                                'div[tabindex="-1"] > div > span:not(:has(> *))')) # noqa: E501
                        assert account_name_tag is not None
                        text = self._concat_texts(
                            text, '{{Archive|1='
                            + account_name_tag.text
                            + 'のリツイートがあります|2=リツイートがあります}}')


    取得できれば魚拓ページのURLを返す。
                    # 画像の取得
    魚拓がなければその旨の警告を表示し、https://archive.ph/https%3A%2F%2Fxxxxxxxxの形で返される。
                    image_list: tuple[str, ...] = self._parse_images(
    アクセスに失敗すればその旨の警告を表示し、https://archive.ph/https%3A%2F%2Fxxxxxxxxの形で返される。
                        article, accessor)
                    image_txt: str = ' '.join(map(
                        lambda t: f'[[ファイル:{t}|240px]]', image_list))
                    text = self._concat_texts(text, image_txt)
                    text = TableBuilder.escape_wiki_reserved_words(text)


    Args:
                    # 投票の処理
      url Final[str]: 魚拓を取得するURL。
                    poll_txt: str = self._get_tweet_poll(article)
      accessor AccessorHandler: アクセスハンドラ
                    text = self._concat_texts(text, poll_txt)


    Returns:
                    # バージョンの処理
      str: 魚拓のURL。
                    possible_version_text_tags: ResultSet[Tag] = (
    """
                        article.select(
    archive_url: str = urljoin(self.ARCHIVE_TODAY_STANDARD, quote(unquote(url), safe='&=+?%')) ##wikiに載せるとき用URLで失敗するとこのままhttps://archive.ph/https%3A%2F%2Fxxxxxxxxの形で返される
                            'div > span > '
    res: Final[str | None] = accessor.request(urljoin(self.ARCHIVE_TODAY, quote(unquote(url), safe='&=+?%'))) ##アクセス用URL使って結果を取得
                            'span:not(:has(> *)):not(div[role="link"] div > '
    if res is None : ##魚拓接続失敗時処理
                            'span > span:not(:has(> *)))'))
      print(archive_url + 'にアクセス失敗ナリ。出力されるテキストにはそのまま記載されるナリ。')
                    if len(list(filter(
    else:
                        lambda x: x.text
      soup: Final[BeautifulSoup] = BeautifulSoup(res, 'html.parser') ##beautifulsoupでレスポンス解析
                            == 'There’s a new version of this post.',
      content: bs4.element.Tag = soup.find(id="CONTENT") ##archive.todayの魚拓一覧ページの中身だけ取得
                            possible_version_text_tags))) > 0:
      if content is None or content.get_text()[:len(self.NO_ARCHIVE)] == self.NO_ARCHIVE: ##魚拓があるかないか判定
                        tweet_callinshow_template += '([[新しいバージョンがあります|後の版→]])'
        print(url + "の魚拓がない。これはいけない。")
      else:
        archive_url = content.find('a').get('href').replace(self.ARCHIVE_TODAY, self.ARCHIVE_TODAY_STANDARD)
    return archive_url


  def _go_to_new_page(self, accessor: AccessorHandler) -> None | NoReturn:
                except Exception as e:
    """Nitterで次のページに移動する。
                    # エラーが起きても止めない
                    logger.exception('エラーが発生してツイートが取得できませんでしたを', exc_info=True)
                    text = 'エラーが発生してツイートが取得できませんでした\n' + ''.join(
                        TracebackException.from_exception(e).format())
                table_builder.append(tweet_callinshow_template, text)
            else:
                logger.warning(url_pair.url + 'はリツイートなので飛ばすナリ。'
                              'URLリスト収集の時点でフィルタできなかった、これはいけない。')


    次のページが無ければプログラムを終了する。
        table_builder.dump_file()


     Args:
     @override
      accessor AccessorHandler: アクセスハンドラ
     def execute(
     """
         self,
    soup: Final[BeautifulSoup] = BeautifulSoup(self._page, 'html.parser') ##beautifulsoupでレスポンス解析
        krsw: str | None = None,
    show_mores: Final[bs4.element.ResultSet] = soup.find_all(class_="show-more")
        use_browser: bool = True,
    new_url: str = '' # ここで定義しないと動かなくなった、FIXME?
        enable_javascript: bool = True
    for show_more in show_mores: ##show-moreに次ページへのリンクか前ページへのリンクがある
     ) -> None:
      if show_more.text != self.NEWEST:  ##前ページへのリンクではないか判定
        """通信が必要な部分のロジック。
         new_url = urljoin(self.NITTER_INSTANCE, self.CALLINSHOW + '/' + self.TWEETS_OR_REPLIES + show_more.a.get('href')) ##直下のaタグのhrefの中身取ってURL頭部分と合体
    res: Final[str | None] = accessor.request(new_url) ##接続してHTML取ってくる
    if res is None:
      self._fail()
    new_page_soup: Final[BeautifulSoup] = BeautifulSoup(res, 'html.parser') ##beautifulsoupでレスポンス解析
    if new_page_soup.find(class_="timeline-end") is None: ##ツイートの終端ではtimeline-endだけのページになるので判定
      print(new_url + 'に移動しますを')
      self._page = res ##まだ残りツイートがあるのでページを返して再度ツイート本文収集
     else:
      print("急に残りツイートが無くなったな終了するか")
      self._make_txt()


  def execute(self, krsw: bool=False) -> NoReturn:
        Args:
    """通信が必要な部分のロジック。
            krsw (str | None, optional): `None` でない場合、名前が自動で \
                :const:`~CALLINSHOW` になる。
            use_browser (bool, optional): `True` ならSeleniumを利用する。\
                `False` ならRequestsのみでアクセスする。
            enable_javascript (bool, optional): SeleniumでJavaScriptを利用する場合は \
                `True`。
        """
        logger.info('Wikiに未掲載のツイートのURLを収集しますを')
        warnings.warn(
            '\033[31m'
            'このモードではURLリストの他にツイート本文も整形して取得するナリが、'
            '機能のテストが不十分であることと、TwitterのHTML構造は'
            'しばしば変更されることから、整形後の出力が正しいとは限らないナリ。'
            '機能を過信せず、自分の目で確かめてね(笑)、それはできるよね。'
            '\033[0m')
        # Seleniumドライバーを必ず終了するため、with文を利用する。
        with AccessorHandler(use_browser, enable_javascript) as accessor:
            # 実行前のチェック
            self._check_archive_instance(accessor)
            # 検索クエリの設定
            if not self._set_queries(accessor, krsw):
                sys.exit(1)
            # Wikiに既に掲載されているツイートのURLを取得
            self._get_tweet_urls_from_wiki(accessor)  # Wikiに接続できない時はここをコメントアウト


    Args:
            # 未掲載のツイートのURLを取得する
      krsw bool: Trueの場合、名前が自動で :const:`~CALLINSHOW` になり、クエリと終わりにするツイートが自動で無しになる。
            self._next_url(accessor, '', self._latest_id_digit)
    """
            logger.debug(f'{len(self._url_list)} tweets are missed')
    # Seleniumドライバーを必ず終了するため、with文を利用する。
    with AccessorHandler() as accessor:
      # 実行前のチェック
      self._check_slash() ##スラッシュが抜けてないかチェック
      self._check_nitter_instance(accessor) ##Nitterが死んでないかチェック
      self._check_archive_instance(accessor) ##archive.todayが死んでないかチェック
      ##Invidiousのインスタンスリストの正規表現パターンを取得
      invidious_url_tuple: Final[tuple[str]] = self._invidious_instances(accessor)
      self._invidious_pattern: Final[re.Pattern] = re.compile('|'.join(invidious_url_tuple))


      # 検索クエリの設定
            # URL一覧ファイルのダンプ
      self._set_queries(accessor, krsw)
            with Path(self.URL_LIST_FILENAME).open('w', encoding='utf-8') as f:
                for url_pair in self._url_list:
                    f.write(url_pair.url + '\n')
            logger.info('URL一覧手に入ったやで〜')


      # ツイートを取得し終えるまでループ
            # ツイート本文を取得する
      while True:
            signal.signal(signal.SIGINT, self._signal_handler)
        self._get_tweet(accessor)
            try:
        self._go_to_new_page(accessor) ##新しいページ取得
                self._get_tweet_from_archive(self._url_list, accessor)
            except Exception:
                # エラーが起きたらURLのリストをそのまま返す
                logger.exception(
                    '異常が起きたので終了するナリ。'
                    + self.URL_LIST_FILENAME + 'を元に自分でツイートを整形してほしいナリ')




if __name__ == '__main__':
if __name__ == '__main__':
  if sys.version_info.major < 3 or (sys.version_info.major == 3 and sys.version_info.minor < 10):
    if sys.version_info < (3, 12):
     print('Pythonのバージョンを3.10以上に上げて下さい', file=sys.stderr)
        logger.critical('貴職のPythonのバージョン: ' + str(sys.version_info))
     exit(1)
        sys.exit('Pythonのバージョンを3.12以上に上げて下さい')
  krsw: Final[bool] = len(sys.argv) > 1 and sys.argv[1] == 'krsw' ##コマンドライン引数があるかどうかのフラグ
    parser: Final[ArgumentParser] = ArgumentParser()
     parser.add_argument(
        '--krsw',
        type=str,
        nargs='?',
        const='',
        default=None,
        help=('指定すると、パカデブのツイートを取得上限数まで取得する。'
              '更に--search-unarchivedモードでこのオプションに引数を与えると、'
              'その文言が終わりにするツイートになる。'))
    parser.add_argument(
        '-n',
        '--no-browser',
        action='store_true',
        help='指定すると、Tor Browserを利用しない。')
    parser.add_argument(
        '-d',
        '--disable-script',
        action='store_true',
        help='指定すると、Tor BrowserでJavaScriptを利用しない。')
     parser.add_argument(
        '-u',
        '--search-unarchived',
        action='store_true',
        help=('指定すると、従来のNitterからツイートを収集するモードになる。廃止予定。'))
    args: Final[Namespace] = parser.parse_args()
    logger.debug('args: ' + str(args))


  twitter_archiver: TwitterArchiver = TwitterArchiver()
    twitter_archiver: Final[TwitterArchiver] = (
  twitter_archiver.execute(krsw)
        TwitterArchiver() if args.search_unarchived else ArchiveCrawler()
    )
    twitter_archiver.execute(
        args.krsw,
        not args.no_browser,
        not args.disable_script)
‎</syntaxhighlight>
‎</syntaxhighlight>


== 実行例 ==
== 実行例 ==
20件での実行例。


=== 12月10日 ===
 
=== 2月9日 ===
{|class="wikitable" style="text-align: left;"
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1755640113332449372|2=https://archive.vn/fmp6R}}([[新しいバージョンがあります|後の版→]])
|-
|
リフォーム屋には要注意。<br>
{{Archive|1=@caa_shohishachoのリツイートがあります|2=リツイートがあります}}
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1755640350868467967|2=https://archive.vn/vVtUq}}
|-
|
怪しいリフォーム屋には要注意。<br>
{{Archive|1=@caa_shohishachoのリツイートがあります|2=リツイートがあります}}
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1755725155291246635|2=https://archive.vn/W1IMm}}
|-
|
メタは胡散臭い詐欺広告で稼いでいるんだから、何言ってるんだって話だろ。<br>
<br>
コンテンツ監視に73億円 メタなど、EUの分担義務に異議申し立て<br>
{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}
|-
|}
 
=== 2月10日 ===
{|class="wikitable" style="text-align: left;"
{|class="wikitable" style="text-align: left;"
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1601539154256744449|2=https://archive.md/WjoU1}}
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1756265888423051340|2=https://archive.vn/tazZJ}}
|-
|-
|
|
なお、ひめかファンのワタクシ、ブロックされております。<br>
{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}<br>
<br>
AwichプロデュースHABUSHを最近飲んでいる。<br>
<br>
<br>
ひめかちゃんは整形なんかじゃない。<br>
ハブ酒ってうまいよね。<br>
[[ファイル:FjnPLyyakAE4sD0.jpg|240px]]
{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}
|-
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1601542511302160384|2=https://archive.md/Fj5Fz}}
|}
 
=== 2月12日 ===
{|class="wikitable" style="text-align: left;"
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1756939496283836878|2=https://archive.vn/nvwJw}}
|-
|-
|
|
今日もお疲れ。<br>
これは必読の書だな。<br>
<br>
<br>
また明日な。<br>
誰が売国奴かがここに書いてある。<br>
[[ファイル:FjnSWDPaMAA5QZM.jpg|240px]] [[ファイル:FjnSWDWakAAWrJa.jpg|240px]] [[ファイル:FjnSWDOakAAIcfE.jpg|240px]]
{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1756992905036927027|2=https://archive.vn/PK5s2}}
|-
|
富士山<br>
[[ファイル:GGIXsWQaoAAzP-a.jpg|240px]]
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1756995624837476473|2=https://archive.vn/HQ0ft}}
|-
|
今日月食?
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1757048622028657125|2=https://archive.vn/tguE7}}
|-
|
Who is the person who is impersonating me and making criminal threats in the Philippines. Dear Friipinos, this criminal is a person who does not have a job in Japan and just stays at home. We believe that he is not capable of actually committing a crime. I hope that the Philippine and Japanese governments will cooperate to arrest the criminal and that he will be severely punished.
|-
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1601569138379718656|2=https://archive.md/iFdQ6}}
|}
 
=== 2月13日 ===
{|class="wikitable" style="text-align: left;"
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1757370912171814925|2=https://archive.vn/N7BfI}}
|-
|-
|
|
{{Archive|1=https://youtu.be/QR6Gj0MKcew|2=https://archive.md/KnShQ|3=https://youtu.be/QR6Gj0MKcew}}<br>
菊地翔は詐欺師だ。<br>
<br>
くだらないインスタのメッセージは読む意味ないな。<br>
<br>
お前が浮かび上がることはもうないし、お前はもうただの詐欺師。<br>
<br>
承認欲求がそうさせるんだろうけどな。<br>
<br>
<br>
直生人へ。
お前は偏差値20くらいの文章書くんだよな。
|-
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1601572951463428096|2=https://archive.md/E5gO5}}
|}
 
=== 2月14日 ===
{|class="wikitable" style="text-align: left;"
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1757763709827776850|2=https://archive.vn/D4fm5}}
|-
|-
|
|
菊地翔<br>
今日は一日布団の中にいた。<br>
<br>
<br>
エクシアポケモン図鑑名:キングオブポンジひめに夢中。キングオブポンジキャバ中毒の最終形。<br>
2月15日になるのを待って。
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1757765745986203848|2=https://archive.vn/tSPoj}}
|-
|
2月14日に意味なんかはない。<br>
<br>
<br>
一人称:オレ。<br>
ただの365分の1。<br>
<br>
<br>
得意技:インスタライブでおらつく。<br>
そうだろ。
   ひめかに会いにいく。<br>
   逃げる。<br>
   ちょび髭を生やす。<br>
   写真を加工する。<br>
   アルファロメオに乗り換える。
|-
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1601588268487041024|2=https://archive.md/5hWmc}}
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1757768530165125262|2=https://archive.vn/qTj9R}}
|-
|-
|
|
日曜阪神11R 阪神JF<br>
さあ、自分で買っておいたピエールマルコリーニでも食べるか。<br>
◎ブトンドール 4.5<br>
{{Archive|1=@CallinShowのリツイートがあります|2=リツイートがあります}}
◯ミスヨコハマ 5<br>
▲リバーラ 4.5<br>
△リバティアイランド 4<br>
△アロマディオーサ 3.75<br>
△ラヴェル 3.5<br>
△ウインブライル 2.5<br>
△ドゥーラ 1.5<br>
△キタウイング 1.5
|-
|-
|}
|}


=== 12月11日 ===
=== 2月16日 ===
{|class="wikitable" style="text-align: left;"
{|class="wikitable" style="text-align: left;"
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1601859263928074240|2=https://archive.md/uLabW}}
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1758151064858358179|2=https://archive.vn/gkvDw}}
|-
|
ピエールマルコリーニだと思ったら、キットカットを食べていた。<br>
<br>
ようやく今日自分を取り戻した。
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1758155326850048038|2=https://archive.vn/YcxSn}}
|-
|
{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}<br>
<nowiki>#</nowiki>アロマティックトーク<br>
<br>
最高。<br>
{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1758155650230812748|2=https://archive.vn/tlUpy}}
|-
|-
|
|
とんでもない目にあったが、香港マイルで復活した。<br>
高田馬場のだんご虫<br>
{{Archive|1=https://twitter.com/CallinShow/status/1601588268487041024|2=https://archive.md/5hWmc|3=https://twitter.com/CallinShow/status/1601588268487041024}}<br>
<br>
[[ファイル:FjrygSpVQAEdzwR.jpg|240px]]
ヒロシ・ヤング<br>
<br>
も最高<br>
{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}
|-
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1601870854669094912|2=https://archive.md/ZVWEm}}
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1758164160402395259|2=https://archive.vn/pc1PS}}
|-
|-
|
|
今日は日曜日だ。エクシア合同会社のことについてツイートするのはやめようかと思ったんだ。<br>
{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}<br>
<br>
<br>
みんなどう思う?<br>
NY市がSNS各社を訴える。<br>
<br>
<br>
&nbsp; <span style="display: inline-block; width: 30em; background: linear-gradient(to right, rgba(29, 155, 240, 0.58) 0 56%, transparent 56% 100%); font-weight: bold;">56% 菊地翔は詐欺師だからツイートして欲しい。</span><br>
さすがアメリカだ。<br>
&nbsp; <span style="display: inline-block; width: 30em; background: linear-gradient(to right, rgb(207, 217, 222) 0 16%, transparent 16% 100%);">16% 関戸直生人が逃げているからツイートして欲しい。</span><br>
{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}
&nbsp; <span style="display: inline-block; width: 30em; background: linear-gradient(to right, rgb(207, 217, 222) 0  5%, transparent  5% 100%);"> 5% 高橋佑佳は、いつも菊地の横にいるからツイートしろ。</span><br>
&nbsp; <span style="display: inline-block; width: 30em; background: linear-gradient(to right, rgb(207, 217, 222) 0 23%, transparent 23% 100%);">23% ひめかさんへの応援メッセージお願いします。</span><br>
&nbsp; <span style="font-size: small;">177 votes • Final results</span>
|-
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1601888790489886720|2=https://archive.md/cz02s}}
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1758166055506358335|2=https://archive.vn/jGZH3}}
|-
|-
|
|
ジョーカー菊地<br>
{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}
[[ファイル:FjsNVgTUAAAeZJu.jpg|240px]]
|-
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1601889110507278339|2=https://archive.md/Ux2Mj}}
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1758166909412081993|2=https://archive.vn/lMjJr}}
|-
|-
|
|
た、た、大変だーーーーー<br>
{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}<br>
<br>
<br>
ひめかさんの今年の売り上げ目標10億<br>
気持ち悪いやつには巨額の損害賠償請求をして欲しい。
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1758167792891937190|2=https://archive.vn/KWFNI}}
|-
|
{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}<br>
<br>
<br>
言っちゃうんだな。<br>
5億円の慰謝料って初めて見たな。<br>
<br>
<br>
そこだよ。<br>
もっと法律構成できたんじゃないのかな。<br>
{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1758171644496134615|2=https://archive.vn/XQhRP}}
|-
|
{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}<br>
<br>
<br>
いいところ。<br>
素人じゃないな。<br>
<br>
<br>
今それ言えちゃう、鬼メンタル。<br>
これは匂うな。<br>
<br>
{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}
{{Archive|1=https://news.yahoo.co.jp/articles/7d3609cb0e6c284d5fae6301f35bf2f27573ef35?page=1|2=https://archive.md/xGrou|3=https://news.yahoo.co.jp/articles/7d3609cb0e6c284d5fae6301f35bf2f27573ef35?page=1}}
|-
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1601890607848296448|2=https://archive.md/GPcmz}}
|}
 
=== 2月17日 ===
{|class="wikitable" style="text-align: left;"
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1758537053602816220|2=https://archive.vn/vEzcy}}
|-
|-
|
|
{{Archive|1=https://note.com/takahirokarasawa/n/na966edc6e6be|2=https://archive.md/XTXdU|3=https://note.com/takahirokarasawa/n/na966edc6e6be}}<br>
オレの投稿に捨て垢で張り付く馬鹿はウケるな。<br>
<br>
<br>
エクシア合同会社は改めまして詐欺会社です。
ブロックは楽しみ。
|-
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1601904473906982913|2=https://archive.md/FMbN1}}
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1758540840874778691|2=https://archive.vn/mo5TW}}
|-
|-
|
|
いつも明るい菊地。<br>
やりますよ<br>
[[ファイル:1601904473906982913_0.mp4|240px]]
{{Archive|1=@totonoijinseiのリツイートがあります|2=リツイートがあります}}
|-
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1601915902538051584|2=https://archive.md/y4Fj1}}
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1758542361486094374|2=https://archive.vn/uYdF3}}
|-
|-
|
|
依頼者から送られてきたけど、何の情報か教えて欲しいな。<br>
{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}<br>
<br>
<br>
エクシアの関係者なんでしょ。<br>
ゼロプリ好き<br>
[[ファイル:FjslywSUAAASvCt.jpg|240px]]
{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}
|-
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1601916198425309185|2=https://archive.md/CcEl6}}
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1758557889927737554|2=https://archive.vn/2HXQP}}
|-
|-
|
|
発信している情報は全部裏を取ってるんだよね。<br>
逮捕、送検情報を、テレビが報じるときって、警察が広報すると決めた案件なんだ。<br>
{{Archive|1=https://twitter.com/CallinShow/status/1601915902538051584|2=https://archive.md/y4Fj1|3=https://twitter.com/CallinShow/status/1601915902538051584}}
その中で被疑者の供述が出ている時は、警察からの情報提供を、記者クラブの記者が、記事にしているんだ。
|-
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1601916490587901952|2=https://archive.md/FkDob}}
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1758569972891316469|2=https://archive.vn/opwxx}}
|-
|-
|
|
いくつか大きなネタはあるんだけど、今はあたためてる。<br>
{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}<br>
<br>
ジャボリーミッキーのお姉さんの権利関係が気になる。<br>
<br>
<br>
この事件は本当色んなことがあるんだ。
ちゃんとお姉さんに収益が落ちて欲しいな。<br>
{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}
|-
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1601949819404066816|2=https://archive.md/5vbUl}}
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1758570299287843130|2=https://archive.vn/3ZvbE}}
|-
|-
|
|
菊地翔が夢に出てきた。<br>
マ・ドンソク主演<br>
<br>
<br>
オレも末期だな。<br>
このシリーズ最高。<br>
<br>
{{Archive|1=@hanzaitoshi3のリツイートがあります|2=リツイートがあります}}
なぜか、スナックで一緒にカラオケを歌ってた。
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1758692104111542606|2=https://archive.vn/d7OZy}}
|-
|
<nowiki>#</nowiki>NowReading<br>
[[ファイル:GGgg24Ja0AArevQ.jpg|240px]]
|-
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1601954620204797953|2=https://archive.md/oJIT3}}
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1758702560230396318|2=https://archive.vn/Fe1hA}}
|-
|-
|
|
3枚目と4枚目は微妙に口の開き方が違う。3枚目は「ね」、4枚目は「い」を発音しているときの口の開き方だ。<br>
はい!<br>
{{Archive|1=https://twitter.com/morumokoko/status/1601951502200774656|2=https://archive.md/Zgidz|3=https://twitter.com/morumokoko/status/1601951502200774656}}
{{Archive|1=@totonoijinseiのリツイートがあります|2=リツイートがあります}}
|-
|-
|}
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1758721953941291263|2=https://archive.vn/O4bfO}}
 
=== 12月12日 ===
{|class="wikitable" style="text-align: left;"
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1601955353792430081|2=https://archive.md/UVKrV}}
|-
|-
|
|
エクシア合同会社のツイートの中に、岡ちゃんのツイートをリツイートしてしまってごめん。<br>
どの株買ったって自分で口外して煽っているのって、馬鹿引っ掛ける手口だよな。<br>
<br>
<br>
本当に興味あるのは菊地翔じゃなくて岡ちゃんなんだ。<br>
胡散臭い仮想通貨売ってるときと変わりはない。
{{Archive|1=https://twitter.com/CallinShow/status/1601954620204797953|2=https://archive.md/oJIT3|3=https://twitter.com/CallinShow/status/1601954620204797953}}
|-
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1601958600259371011|2=https://archive.md/EY6da}}
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1758723779872870712|2=https://archive.vn/4dmB2}}
|-
|-
|
|
日曜日に誰もエクシア合同会社のことなんか、呟きたいなんて思わないのが普通だと思う。<br>
{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}<br>
<br>
<br>
でも、エクシア合同会社が詐欺会社であり、皆んな許せないと思ってやってるんだ。<br>
有隣堂しか知らない世界<br>
<br>
<br>
エクシア合同会社を許してはいけない、そんな気持ちで我々会ったことのない大人達はつながっているんだ。
ブッコローとか、この世界観天才。<br>
{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}
|-
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1601959896252981249|2=https://archive.md/Jid3z}}
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1758724192646930697|2=https://archive.vn/YSgQE}}
|-
|-
|
|
ときにはふざけたツイートをしているように思うかもしれない。<br>
弁護士になっただけで逆転できる人生はない。<br>
<br>
<br>
でも、本気なんだよ。<br>
何をしていくかだよ。<br>
{{Archive|1=@bengo4topicsのリツイートがあります|2=リツイートがあります}}
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1758724799046819840|2=https://archive.vn/mGxKa}}
|-
|
昔こうだったから今◯◯になった、はい、立志伝<br>
<br>
<br>
1人でも多くの人に知ってもらって、その声が大きくなって、これが社会問題化して、ちゃんと法の裁きを受ける。<br>
みたいなのって職業に貴賎がある意識の表れがある気がするからオレは好きではない。
<br>
その一心でいつもTwitterに臨んでいる。
|-
|-
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1601961096528613376|2=https://archive.md/V8jbu}}
!{{CallinShowLink|1=https://twitter.com/CallinShow/status/1758814041370431746|2=https://archive.vn/hrSe6}}
|-
|-
|
|
エクシア合同会社と闘う大人たちがやっていることは、Twitterを使った世直し運動なんだ。<br>
明日はフェブラリーステークス<br>
<br>
みんな正業があり、それぞれの生活の中で調べ、問題点を指摘し続けている。<br>
<br>
<br>
政治はまだこの問題を見過ごしているけれど、一万人700億円の話。<br>
勝つのは?<br>
<br>
<br>
これは人々が苦しんでいる話。<br>
長岡騎手勝って欲しいな。
<br>
<br>
だから皆んな立ち上がっている。
&nbsp; <span style="display: inline-block; width: 30em; background: linear-gradient(to right, rgb(207, 217, 222) 0 19.5%, transparent 19.5% 100%);">19.5% オメガギネス</span><br>
&nbsp; <span style="display: inline-block; width: 30em; background: linear-gradient(to right, rgb(207, 217, 222) 0 23.6%, transparent 23.6% 100%);">23.6% ウィルソンテソーロ</span><br>
&nbsp; <span style="display: inline-block; width: 30em; background: linear-gradient(to right, rgba(29, 155, 240, 0.58) 0 29.3%, transparent 29.3% 100%); font-weight: bold;">29.3% ドゥラエレーデ</span><br>
&nbsp; <span style="display: inline-block; width: 30em; background: linear-gradient(to right, rgb(207, 217, 222) 0 27.6%, transparent 27.6% 100%);">27.6% ガイアフォース</span><br>
&nbsp; <span style="font-size: small;">123 votes·Final results</span>
|-
|-
|}
|}

2024年11月9日 (土) 16:59時点における最新版

‎とりあえず取り急ぎ。バグ報告は利用者・トーク:夜泣き

コード

#!/usr/bin/env python3

# © 2022 恒心教 (Koushinism)
# Released under the MIT license
# https://opensource.org/licenses/mit-license.php

"""Twitter自動収集スクリプト

ver4.4.2 2024/11/9恒心

当コードは恒心停止してしまった https://rentry.co/7298g の降臨ショーツイート自動収集スクリプトの復刻改善版です。
前開発者との出会いに感謝。
本スクリプトは `archive.today <https://archive.today>`_ からWikiに未掲載のツイートを収集します。

Examples:
    定数類は状況に応じて変えてください。:class:`~UserProperties` で変更できます。

    ::

        $ python3 (ファイル名)

    オプションに ``--krsw`` とつけると自動モードになります。

    ::

        $ python3 (ファイル名) --krsw

    自動モードではユーザーは降臨ショー、クエリはなし、取得上限まで自動です。
    つまりユーザー入力が要りません。

    ``--no-browser`` オプションでTor Browserを使用しないモードに、
    ``--disable-script`` オプションでTor BrowserのJavaScriptを使用しないモードになります。
    ``--search-unarchived`` オプションでは、従来の通りNitterからツイートを収集するモードになります (廃止予定)。

Note:
    * Pythonのバージョンは3.12以上
    * 環境は玉葱前提です。

        * TailsやWhonixでない場合、Tor Browserを入れておくか、torコマンドでプロキシを立てておくことが必要です。

    * MacOSで動作確認済

        * MacOSの場合はTor Browserをダウンロードするかbrewでtorコマンドを導入してから実行する
        * Whonix-WorkstationなどLinuxやWindowsでの動作が未確認です。確認と修正をお願いします

    * PySocks, bs4, seleniumはインストールしないと標準で入ってません
    * requests, typing_extensionsも環境によっては入っていないかもしれない

    .. code-block:: bash

        $ python3 -m pip install bs4 requests PySocks selenium typing_extensions

    * pipも入っていなければ ``$ sudo apt install pip``
    * `ffmpeg <https://ffmpeg.org>`_ が入っていると動画も自動取得しますが、無くても動きます
    * バグ報告は `利用者・トーク:夜泣き#利用者:夜泣き/スクリプトについて \
        <https://krsw-wiki.in/wiki/利用者・トーク:夜泣き#利用者:夜泣き/スクリプトについて>`_
"""

import json
import logging
import os
import platform
import random
import re
import shutil
import signal
import subprocess
import sys
import warnings
from abc import ABCMeta, abstractmethod
from argparse import ArgumentParser, Namespace
from collections import deque
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from pathlib import Path
from time import sleep
from traceback import TracebackException
from types import FrameType, MappingProxyType, TracebackType
from typing import (Final, NamedTuple, NoReturn, Self, TextIO, assert_never,
                    final, override)
from urllib.parse import (ParseResult, parse_qs, quote, unquote, urljoin,
                          urlparse)
from zoneinfo import ZoneInfo

import requests
from bs4 import BeautifulSoup
from bs4.element import NavigableString, ResultSet, Tag
from selenium.common.exceptions import (InvalidSwitchToTargetException,
                                        WebDriverException)
from selenium.webdriver import Firefox as FirefoxDriver
from selenium.webdriver.common.by import By
from selenium.webdriver.firefox.options import Options as FirefoxOptions
from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.wait import WebDriverWait
from typing_extensions import deprecated


@dataclass(init=False, eq=False, frozen=True)
class UserProperties:
    """ユーザ設定。

    実行時に変更する可能性のある定数はここで定義する。
    """
    media_dir: Final[str] = 'tweet_media'
    """Final[str]: 画像などのツイートメディアをダウンロードするディレクトリ。"""

    filename: Final[str] = 'tweet.txt'
    """Final[str]: ツイートを保存するファイルの名前。"""

    log_file: Final[str] = 'twitter_archiver.log'
    """Final[str]: ログを保存するファイルの名前。"""

    @dataclass(init=False, eq=False, frozen=True)
    class NitterCrawler:
        """``--search-unarchived`` オプション有りの時に利用する設定値。"""
        limit_n_tweets: Final[int] = 100
        """Final[int]: 取得するツイート数の上限。"""

        report_interval: Final[int] = 5
        """Final[int]: 記録件数を報告するインターバル。"""

        nitter_instance: Final[str] = 'https://nitter.poast.org/'  # noqa: E501
        """Final[str]: Nitterのインスタンス。

        生きているのは https://github.com/zedeus/nitter/wiki/Instances で確認。

        Note:
            末尾にスラッシュ必須。

            インスタンスによっては画像の取得ができない。
            Tor用のインスタンスでないと動画の取得ができない。
        """

    @dataclass(init=False, eq=False, frozen=True)
    class ArchiveCrawler:
        """``--search-unarchived`` オプション無しの時に使用する設定値。"""
        url_list_filename: Final[str] = 'url_list.txt'
        """Final[str]: URLのリストをダンプするファイル名。"""


# ログ設定
# basicConfigでレベルを設定するとモジュールのDEBUGログなども出力される
formatter: Final[logging.Formatter] = logging.Formatter(
    fmt='{asctime} [{levelname:.4}] : {message}', style='{')
logger: Final[logging.Logger] = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
# 標準エラー出力へのログ出力
stream_handler: Final[logging.StreamHandler[TextIO]] = logging.StreamHandler()
stream_handler.setLevel(logging.INFO)
stream_handler.setFormatter(formatter)
logger.addHandler(stream_handler)
# ファイルへのログ出力
file_handler: Final[logging.FileHandler] = (
    logging.FileHandler(UserProperties.log_file))
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)


class AccessError(Exception):
    """RequestsとSeleniumで共通のアクセスエラー。"""
    pass


class ReCaptchaRequiredError(Exception):
    """JavaScriptがオフの時にreCAPTCHAを要求された場合のエラー。"""
    pass


class AbstractAccessor(metaclass=ABCMeta):
    """HTTPリクエストでWebサイトに接続するための基底クラス。"""

    WAIT_TIME: Final[int] = 1
    """Final[int]: HTTPリクエスト成功失敗関わらず待機時間(秒)。

    Note:
        1秒待つだけで行儀がいいクローラーだそうなので既定では1秒。
        しかし日本のポリホーモは1秒待っていても捕まえてくるので注意 [1]_。

    References:
        .. [1] 岡崎市立中央図書館事件. (2022, June 21). In Wikipedia.
           https://ja.wikipedia.org/wiki/?curid=2187212&oldid=79121945
    """

    WAIT_RANGE: Final[int] = 5
    """Final[int]: ランダムな時間待機するときの待機時間の幅(秒)。"""

    REQUEST_TIMEOUT: Final[int] = 30
    """Final[int]: HTTPリクエストのタイムアウト秒数。"""

    @abstractmethod
    def get(self, url: str) -> str:
        """URLにアクセスして、HTMLを取得する。

        Args:
            url (str): 接続するURL。

        Raises:
            AccessError: 通信に失敗した場合のエラー。

        Returns:
            str: レスポンスのHTML。
        """
        ...

    @abstractmethod
    def set_cookies(self, cookies: dict[str, str]) -> None:
        """Cookieをセットする。

        Args:
            cookies (dict[str, str]): Cookieのキーバリューペア。
        """
        ...

    @final
    def _random_sleep(self) -> None:
        """ランダムな秒数スリープする。

        自動操縦だとWebサイトに見破られないため。
        """
        sleep(random.randrange(self.WAIT_TIME,
                               self.WAIT_TIME + self.WAIT_RANGE))


class RequestsAccessor(AbstractAccessor):
    """RequestsモジュールでWebサイトに接続するためのクラス。"""

    HEADERS: Final[dict[str, str]] = {
        'User-Agent':
            'Mozilla/5.0 (X11; Linux i686; rv:109.0) Gecko/20100101 Firefox/120.0'  # noqa: E501
    }
    """Final[dict[str, str]]: HTTPリクエスト時のヘッダ。"""

    PROXIES_WITH_BROWSER: Final[dict[str, str]] = {
        'http': 'socks5h://127.0.0.1:9150',
        'https': 'socks5h://127.0.0.1:9150'
    }
    """Final[dict[str, str]]: Tor Browserを起動しているときのHTTPプロキシの設定。"""

    PROXIES_WITH_COMMAND: Final[dict[str, str]] = {
        'http': 'socks5h://127.0.0.1:9050',
        'https': 'socks5h://127.0.0.1:9050'
    }
    """Final[dict[str, str]]: torコマンドを起動しているときのHTTPプロキシの設定。"""

    TOR_CHECK_URL: Final[str] = 'https://check.torproject.org/api/ip'
    """Final[str]: Tor経由で通信しているかチェックするサイトのURL。"""

    def __init__(self) -> None:
        # Torに必要なプロキシをセット
        self._cookies: dict[str, str] = {}
        self._proxies: dict[str, str] | None = self._choose_tor_proxies()

    def _execute(self, url: str) -> requests.models.Response:
        """引数のURLにRequestsモジュールでHTTP接続する。

        Args:
            url (str): 接続するURL。

        Raises:
            requests.HTTPError: ステータスコードが200でない場合のエラー。

        Returns:
            requests.models.Response: レスポンスのオブジェクト。
        """
        sleep(self.WAIT_TIME)  # DoS対策で待つ
        res: Final[requests.models.Response] = requests.get(
            url,
            timeout=self.REQUEST_TIMEOUT,
            headers=self.HEADERS,
            allow_redirects=False,
            proxies=self._proxies,
            cookies=self._cookies)
        res.raise_for_status()  # HTTPステータスコードが200番台以外でエラー発生
        return res

    @override
    def get(self, url: str) -> str:
        try:
            return self._execute(url).text
        except (requests.HTTPError, requests.ConnectionError) as e:
            raise AccessError from e

    @override
    def set_cookies(self, cookies: dict[str, str]) -> None:
        self._cookies.update(cookies)

    def get_image(self, url: str) -> bytes | None:
        """引数のURLから画像のバイナリ列を取得する。

        Args:
            url (str): 接続するURL。

        Raises:
            AccessError: アクセスに失敗した場合のエラー。

        Returns:
            bytes | None: 画像のバイナリ。画像でなければ `None`。
        """
        try:
            res: Final[requests.models.Response] = self._execute(url)
        except (requests.HTTPError, requests.ConnectionError) as e:
            raise AccessError from e

        if 'image' in res.headers['content-type']:
            return res.content
        else:
            return None

    def _choose_tor_proxies(self) -> dict[str, str] | None:
        """Torを使うのに必要なプロキシ情報を返す。

        プロキシなしで接続できれば `None`、
        Tor Browserのプロキシで接続できるなら :const:`~PROXIES_WITH_BROWSER`、
        torコマンドのプロキシで接続できるなら :const:`~PROXIES_WITH_COMMAND`。
        いずれでもアクセスできなければ異常終了する。

        Raises:
            AccessError: :const:`~TOR_CHECK_URL` にアクセスできない場合のエラー。

        Returns:
            dict[str, str] | None: プロキシ情報。
        """
        logger.info('Torのチェック中ですを')
        # プロキシなしでTorにアクセスできるかどうか
        self._proxies = None
        res: str = self._execute(self.TOR_CHECK_URL).text
        is_tor: bool = json.loads(res)['IsTor']
        if is_tor:
            logger.info('Tor connection OK')
            return None

        # Tor BrowserのプロキシでTorにアクセスできるかどうか
        try:
            self._proxies = self.PROXIES_WITH_BROWSER
            res = self._execute(self.TOR_CHECK_URL).text
            is_tor = json.loads(res)['IsTor']
            if is_tor:
                logger.info('Tor browser connection OK')
                return self.PROXIES_WITH_BROWSER
        except requests.exceptions.ConnectionError:
            pass

        # torコマンドのプロキシでTorにアクセスできるかどうか
        try:
            self._proxies = self.PROXIES_WITH_COMMAND
            res = self._execute(self.TOR_CHECK_URL).text
            is_tor = json.loads(res)['IsTor']
            if is_tor:
                logger.info('Tor proxy OK')
                return self.PROXIES_WITH_COMMAND
            else:
                raise AccessError('サイトにTorのIPでアクセスできていないなりを')
        except requests.exceptions.ConnectionError:
            logger.critical('通信がTorのSOCKS proxyを経由していないなりを', exc_info=True)
            sys.exit(1)

    @property
    def proxies(self) -> dict[str, str] | None:
        """dict[str, str] | None: プロキシ設定。"""
        return self._proxies


class SeleniumAccessor(AbstractAccessor):
    """SeleniumでWebサイトに接続するためのクラス。

    Args:
        enable_javascript (bool): JavaScriptを有効にするかどうか。reCAPTCHA対策に必要。
    """

    TOR_BROWSER_PATHS: Final[MappingProxyType[str, str]] = MappingProxyType({
        'Windows': r'C:\Program Files\Tor Browser\Browser\firefox.exe',
        'Darwin': '/Applications/Tor Browser.app/Contents/MacOS/firefox',
        'Linux': '/usr/bin/torbrowser'
    })
    """Final[MappingProxyType[str, str]]: OSごとのTor Browserのパス。"""

    WEB_DRIVER_WAIT_TIME: Final[int] = 15
    """Final[int]: 最初のTor接続時の待機時間(秒)。"""

    WAIT_TIME_FOR_RECAPTCHA: Final[int] = 10_000
    """Final[int]: reCAPTCHAのための待機時間(秒)。"""

    def __init__(self, enable_javascript: bool) -> None:
        self._options: Final[FirefoxOptions] = FirefoxOptions()
        self._options.binary_location = (
            self.TOR_BROWSER_PATHS[platform.system()]
        )
        self._options.add_argument('--user-data-dir=selenium')  # pyright: ignore [reportUnknownMemberType] # noqa: E501

        if enable_javascript:
            logger.warning('reCAPTCHA対策のためJavaScriptをonにしますを')

        self._options.set_preference('javascript.enabled', enable_javascript)
        self._options.set_preference('intl.accept_languages', 'en-US, en')
        self._options.set_preference('intl.locale.requested', 'US')
        self._options.set_preference('font.language.group', 'x-western')
        # 自動操縦と見破られないための設定
        self._options.set_preference('dom.webdriver.enabled', False)
        self._refresh_browser()

    def quit(self) -> None:
        """Seleniumドライバを終了する。"""
        if hasattr(self, '_driver'):
            logger.debug('ブラウザ終了')
            self._driver.quit()

    def _refresh_browser(self) -> None:
        """ブラウザを起動する。"""
        try:
            logger.debug('ブラウザ起動')
            self._driver: FirefoxDriver = FirefoxDriver(
                options=self._options)
            sleep(1)
            web_driver_wait: Final[WebDriverWait[FirefoxDriver]] = (
                WebDriverWait(self._driver, self.WEB_DRIVER_WAIT_TIME))
            web_driver_wait.until(
                ec.element_to_be_clickable((By.ID, 'connectButton'))
            )
            self._driver.find_element(By.ID, 'connectButton').click()
            # Torの接続が完了するまで待つ
            web_driver_wait.until(ec.url_contains('about:blank'))
        except BaseException:
            self.quit()
            raise

    def _check_recaptcha(self, url: str) -> None:
        """reCAPTCHAが表示されているかどうか判定して、入力を待機する。

        botであることが検知された場合、自動でブラウザを再起動する。

        Args:
            url (str): アクセスしようとしているURL。\
                reCAPTCHAが要求されると `current_url` が変わることがあるので必要。

        Raises:
            ReCaptchaRequiredError: JavaScriptがオフの状態でreCAPTCHAが要求された場合のエラー。
        """
        iframe_selector: Final[str] = (
            'iframe[title="recaptcha challenge expires in two minutes"]')

        if len(self._driver.find_elements(By.ID, 'g-recaptcha')) > 0:
            if self._options.preferences.get('javascript.enabled'):  # pyright: ignore[reportUnknownMemberType] # noqa: E501
                logger.warning(f'{url} でreCAPTCHAが要求されたナリ')
                print('reCAPTCHAを解いてね(笑)、それはできるよね。\a\a\a')
                print('botバレしたら自動でブラウザが再起動するナリよ')
                print('Tips: カーソルを迷ったように動かすとか、人間らしく振る舞うのがコツナリ')
                WebDriverWait(self._driver, self.WEB_DRIVER_WAIT_TIME).until(
                    ec.presence_of_element_located(
                        (By.CSS_SELECTOR, iframe_selector)
                    )
                )

                self._driver.switch_to.frame(  # reCAPTCHAのフレームに遷移する
                    self._driver.find_element(By.CSS_SELECTOR, iframe_selector)
                )
                try:
                    WebDriverWait(
                        self._driver, self.WAIT_TIME_FOR_RECAPTCHA
                    ).until(
                        ec.visibility_of_element_located(
                            # bot検知された場合に現れるクラス
                            (By.CLASS_NAME, 'rc-doscaptcha-header')
                        )
                    )
                except InvalidSwitchToTargetException:
                    # reCAPTCHAのフレームがなくなっていた場合
                    logger.info('reCAPTCHAが解かれましたを')
                    self._driver.switch_to.default_content()
                    self._random_sleep()  # DoS対策で待つ
                else:
                    # waitを普通に抜けた場合
                    logger.warning('botバレしたなりを')
                    # 一回ブラウザを落として起動し直す
                    self.quit()
                    self._refresh_browser()
                    self.get(url)
            else:
                raise ReCaptchaRequiredError(
                    f'JavaScriptがオフの状態でreCAPTCHAが要求されました。 URL: {url}')

    @override
    def get(self, url: str) -> str:
        self._random_sleep()  # DoS対策で待つ
        try:
            self._driver.get(url)
            WebDriverWait(self._driver, self.WEB_DRIVER_WAIT_TIME).until_not(
                ec.any_of(
                    ec.title_is('Redirecting'),
                    ec.title_is('Verifying your browser | Nitter')
                )
            )  # Nitterのリダイレクトを検知する
            self._check_recaptcha(url)
        except WebDriverException as e:
            # Selenium固有の例外を共通の例外に変換
            raise AccessError from e
        sleep(5)
        return self._driver.page_source

    @override
    def set_cookies(self, cookies: dict[str, str]) -> None:
        for name, value in cookies.items():
            self._driver.add_cookie(  # pyright: ignore [reportUnknownMemberType] # noqa: E501
                {'name': name, 'value': value})
        self._driver.refresh()


class AccessorHandler:
    """WebサイトからHTMLを取得するためのクラス。

    RequestsとSeleniumのどちらかを選択して使用することができ、その違いを隠蔽する。

    Args:
        use_browser (bool): `True` ならSeleniumを利用する。`False` ならRequestsのみでアクセスする。
        enable_javascript (bool): SeleniumでJavaScriptを利用する場合は `True`。
    """

    LIMIT_N_REQUESTS: Final[int] = 5
    """Final[int]: HTTPリクエスト失敗時の再試行回数。"""

    WAIT_TIME_FOR_ERROR: Final[int] = 4
    """Final[int]: HTTPリクエスト失敗時にさらに追加する待機時間。"""

    def __init__(self, use_browser: bool, enable_javascript: bool) -> None:
        self._selenium_accessor: Final[SeleniumAccessor | None] = (
            SeleniumAccessor(enable_javascript) if use_browser else None
        )
        self._requests_accessor: Final[RequestsAccessor] = RequestsAccessor()
        self._last_url = ''  # 最後にアクセスしたURL

    def __enter__(self) -> Self:
        return self

    def __exit__(self,
                 exc_type: type[BaseException] | None,
                 exc_value: BaseException | None,
                 traceback: TracebackType | None) -> None:
        if self._selenium_accessor is not None:
            self._selenium_accessor.quit()

    def request_once(self, url: str) -> str:
        """引数のURLにHTTP接続する。

        Args:
            url (str): 接続するURL。

        Returns:
            str: レスポンスのテキスト。

        Raises:
            AccessError: アクセスエラー。

        Note:
            失敗かどうかは呼出側で要判定。
        """
        try:
            if self._selenium_accessor is not None:
                return self._selenium_accessor.get(url)
            else:
                return self._requests_accessor.get(url)
        except AccessError:
            raise

    def _request_with_callable[T](
        self,
        url: str,
        request_callable: Callable[[str], T]
    ) -> T | None:
        """`request_callable` の実行を :const:`~LIMIT_N_REQUESTS` で定義された回数まで試す。

        成功すると結果を返す。
        接続失敗が何度も起きると `None` を返す。

        Args:
            url (str): 接続するURL
            request_callable (Callable[[str], T]): 1回リクエストを行うメソッド。

        Returns:
            T | None: レスポンス。接続失敗が何度も起きると `None` を返す。
        """
        assert url, 'URLが空っぽでふ'
        logger.debug('Requesting ' + unquote(url))
        self._last_url = url

        for i in range(self.LIMIT_N_REQUESTS):
            try:
                res: T = request_callable(url)
            except AccessError:
                logger.warning(
                    url + 'への通信失敗ナリ  '
                    f'{i + 1}/{self.LIMIT_N_REQUESTS}回')
                logger.debug('エラーログ', exc_info=True)
                if i < self.LIMIT_N_REQUESTS:
                    sleep(self.WAIT_TIME_FOR_ERROR)  # 失敗時は長めに待つ
            else:
                return res
        return None

    def request(self, url: str) -> str | None:
        """HTTP接続を :const:`~LIMIT_N_REQUESTS` で定義された回数まで試す。

        成功すると結果を返す。
        接続失敗が何度も起きると `None` を返す。

        Args:
            url (str): 接続するURL。

        Returns:
            str | None: レスポンスのテキスト。接続失敗が何度も起きると `None` を返す。

        Note:
            失敗かどうかは呼出側で要判定
        """
        return self._request_with_callable(url, self.request_once)

    def request_with_requests_module(self, url: str) -> str | None:
        """RequestsモジュールでのHTTP接続を :const:`~LIMIT_N_REQUESTS` で定義された回数まで試す。

        成功すると結果を返す。
        接続失敗が何度も起きると `None` を返す。

        Args:
            url (str): 接続するURL。

        Returns:
            str | None: レスポンスのテキスト。接続失敗が何度も起きると `None` を返す。

        Note:
            失敗かどうかは呼出側で要判定
        """
        return self._request_with_callable(url, self._requests_accessor.get)

    def request_image(self, url: str) -> bytes | None:
        """Requestsモジュールで画像ファイルを取得する。

        成功すると結果を返す。
        接続失敗が何度も起きると `None` を返す。

        Args:
            url (str): 接続するURL。

        Returns:
            bytes | None: レスポンスのバイト列。接続失敗が何度も起きると `None` を返します。

        Note:
            失敗かどうかは呼出側で要判定
        """
        return self._request_with_callable(
            url,
            self._requests_accessor.get_image)

    def set_cookies(self, cookies: dict[str, str]) -> None:
        """Cookieをセットする。

        Args:
            cookies (dict[str, str]): Cookieのキーバリューペア。
        """
        self._requests_accessor.set_cookies(cookies)
        if self._selenium_accessor is not None:
            self._selenium_accessor.set_cookies(cookies)

    @property
    def last_url(self) -> str:
        """str: 最後にアクセスしたURL。"""
        return self._last_url

    @property
    def proxies(self) -> dict[str, str] | None:
        """dict[str, str] | None: RequestsAccessorオブジェクトのプロキシ設定。"""
        return self._requests_accessor.proxies


class TableBuilder:
    """Wikiの表を組み立てるためのクラス。

    Args:
        date (datetime | None, optional): 記録するツイートの最新日付。デフォルトは今日の日付。

    Attributes:
        _tables (list[str]): ツイートのリストを日毎にまとめたもの。\
            一番最後の要素が `_date` に対応し、最初の要素が最近の日付となる。
        _count (int): 表に追加したツイートの件数。
        _date (datetime): 現在収集中のツイートの日付。
    """
    FILENAME: Final[str] = UserProperties.filename
    """Final[str]: ツイートを保存するファイルの名前。"""

    def __init__(self, date: datetime | None = None) -> None:
        self._tables: Final[list[str]] = ['']
        self._count: int = 0  # 記録数
        self._date: datetime = date or datetime.today()

    @property
    def count(self) -> int:
        """int: 表に追加したツイートの件数。"""
        return self._count

    def append(self, callinshow_template: str, text: str) -> None:
        """ツイートを表に追加する。

        Args:
            callinshow_template (str): ツイートのURLをCallinshowLinkテンプレートに入れたもの。
            text (str): ツイートの本文。
        """
        self._tables[-1] = (
            '!' + callinshow_template + '\n|-\n|\n'
            + text
            + '\n|-\n'
            + self._tables[-1])
        self._count += 1

    def dump_file(self) -> None:
        """Wikiテーブルをファイル出力する。"""
        self._next_day()
        Path(self.FILENAME).write_text(
            '\n'.join(reversed(self._tables)), 'utf-8'
        )
        logger.info('テキストファイル手に入ったやで〜')

    def next_day_if_necessary(self, date: datetime) -> None:
        """引数dateがインスタンスの持っている日付より前の場合、日付更新処理をする。

        Args:
            date (datetime): 次のツイートの日付。
        """
        if (date.year != self._date.year or date.month != self._date.month
                or date.day != self._date.day):
            self._next_day(date)

    def _next_day(self, date: datetime | None = None) -> None:
        """Wikiテーブルに日付の見出しを付与し、日付を更新する。

        記録済みのツイートが1つもない場合は日付の更新だけをする。

        Args:
            date (datetime | None, optional): 次に記録するツイートの日付。
        """
        if self._tables[-1]:
            self._tables[-1] = self._convert_to_text_table(self._tables[-1])
            if platform.system() == 'Windows':
                self._tables[-1] = self._date.strftime(
                    '\n=== %#m月%#d日 ===\n') + self._tables[-1]
                logger.info(self._date.strftime('%#m月%#d日のツイートを取得完了ですを'))
            else:
                self._tables[-1] = self._date.strftime(
                    '\n=== %-m月%-d日 ===\n') + self._tables[-1]
                logger.info(self._date.strftime('%-m月%-d日のツイートを取得完了ですを'))
        if date is not None:
            self._date = date
            if self._tables[-1]:
                self._tables.append('')

    def _convert_to_text_table(self, text: str) -> str:
        """Wikiでテーブル表示にするためのヘッダとフッタをつける。

        Args:
            text (str): ヘッダとフッタがないWikiテーブル。

        Returns:
            str: テーブル表示用のヘッダとフッタがついたWikiテーブル。
        """
        return '{|class="wikitable" style="text-align: left;"\n' + text + '|}'

    @classmethod
    def escape_wiki_reserved_words(cls, text: str) -> str:
        """MediaWikiの文法と衝突する文字を無効化する。

        Args:
            text (str): ツイートの文字列。

        Returns:
            str: MediaWikiの文法と衝突する文字がエスケープされたツイートの文字列。
        """
        def escape_nolink_urls(text: str) -> str:
            """Archiveテンプレートの中にないURLがWikiでaタグに変換されないよう無効化する。

            Args:
                text (str): ツイートの文字列。

            Returns:
                str: Archiveテンプレートの中にないURLがnowikiタグでエスケープされた文字列。
            """
            is_in_archive_template: bool = False
            i: int = 0
            while i < len(text):
                if is_in_archive_template:
                    if text[i:i + len('}}')] == '}}':
                        is_in_archive_template = False
                        i += len('}}')
                else:
                    if (text[i:i + len('{{Archive|')] == '{{Archive|'
                            or text[i:i + len('{{Archive|')] == '{{archive|'):
                        is_in_archive_template = True
                        i += len('{{Archive|')
                    elif text[i:i + len('https://')] == 'https://':
                        text = (text[:i]
                                + '<nowiki>https://</nowiki>'
                                + text[i + len('https://'):])
                        i += len('<nowiki>https://</nowiki>')
                    elif text[i:i + len('http://')] == 'http://':
                        text = (text[:i]
                                + '<nowiki>http://</nowiki>'
                                + text[i + len('http://'):])
                        i += len('<nowiki>http://</nowiki>')
                i += 1
            return text

        if not hasattr(cls, '_escape_callables'):
            # 初回呼び出しの時だけ正規表現をコンパイルする
            head_space_pattern: Final[re.Pattern[str]] = re.compile(
                r'^ ', re.MULTILINE)
            head_marks_pattern: Final[re.Pattern[str]] = re.compile(
                r'^([\*#:;])', re.MULTILINE)
            bar_pattern: Final[re.Pattern[str]] = re.compile(
                r'^----', re.MULTILINE)

            cls._escape_callables: tuple[Callable[[str], str], ...] = (
                lambda t: t.replace('\n', '<br>\n'),
                lambda t: head_space_pattern.sub('&nbsp;', t),
                lambda t: head_marks_pattern.sub(r'<nowiki>\1</nowiki>', t),
                lambda t: bar_pattern.sub('<nowiki>----</nowiki>', t),
                lambda t: escape_nolink_urls(t),
            )

        escaped_text: str = text
        for escape_callable in cls._escape_callables:
            escaped_text = escape_callable(escaped_text)
        return escaped_text

    @staticmethod
    def archive_url(
        url: str,
        archived_url: str,
        text: str | None = None
    ) -> str:
        """URLをArchiveテンプレートでラップする。

        Args:
            url (str): ラップするURL。
            archive_url (str): ラップするURLの魚拓のURL。
            text (str | None, optional): ArchiveテンプレートでURLの代わりに表示する文字列。

        Returns:
            str: ArchiveタグでラップしたURL。
        """
        if text is None:
            return '{{Archive|1=' + unquote(url) + '|2=' + archived_url + '}}'
        else:
            return '{{Archive|1=' + unquote(url) + '|2=' + archived_url \
                + '|3=' + text + '}}'

    @staticmethod
    def callinshowlink_url(url: str, archived_url: str) -> str:
        """URLをCallinShowLinkテンプレートでラップする。

        Args:
            url (str): ラップするURL。
            archive_url (str): ラップするURLの魚拓のURL。

        Returns:
            str: CallinShowLinkタグでラップしたURL。
        """
        return '{{CallinShowLink|1=' + url + '|2=' + archived_url + '}}'


class FfmpegStatus(Enum):
    """ffmpegでの動画保存ステータス。"""
    MP4 = 1
    """mp4の取得に成功したときのステータス。"""
    TS = 2
    """tsの取得までは成功したが、mp4への変換に失敗したときのステータス。"""
    FAILED = 3
    """m3u8からtsの取得に失敗したときのステータス。"""


class TwitterArchiver:
    """ツイートをWikiの形式にダンプするクラス。

    Nitterからツイートを取得し、Wikiの形式にダンプする。
    削除されたツイートや編集前のツイートは取得できない。

    Important:
        Nitterはサービスを終了しており、いずれすべてのインスタンスが使えなくなる [2]_。
        本クラスを継承した :class:`~ArchiveCrawler` はarchive.todayからツイートを収集するため引き続き利用可能。

    References:
        .. [2] Nitter is over - It's been a fun ride. (2024, February 15).
            https://nitter.cz
    """

    NITTER_INSTANCE: Final[str] = UserProperties.NitterCrawler.nitter_instance
    """Final[str]: Nitterのインスタンス。

    生きているのは https://github.com/zedeus/nitter/wiki/Instances で確認。

    Note:
        末尾にスラッシュ必須。

        インスタンスによっては画像の取得ができない。
        Tor用のインスタンスでないと動画の取得ができない。
    """

    ARCHIVE_TODAY: Final[str] = 'http://archiveiya74codqgiixo33q62qlrqtkgmcitqx5u2oeqnmn5bpcbiyd.onion/'  # noqa: E501
    """Final[str]: archive.todayの魚拓のonionドメイン。

    ページにアクセスして魚拓があるか調べるのにはonion版のarchive.todayを使用。

    Note:
        末尾にスラッシュ必須。
    """

    ARCHIVE_TODAY_STANDARD: Final[str] = 'https://archive.vn/'
    """Final[str]: archive.todayの魚拓のクリアネットドメイン。

    Wiki上の記事にはクリアネット用のarchive.todayリンクを貼る。

    Note:
        末尾にスラッシュ必須。
    """

    TWITTER_URL: Final[str] = 'https://x.com/'
    """Final[str]: TwitterのURL。

    Note:
        末尾にスラッシュ必須。
    """

    TWITTER_MEDIA_URL: Final[str] = 'https://pbs.twimg.com/media/'
    """Final[str]: TwitterのメディアのURL。
    """

    INVIDIOUS_INSTANCES_URL: Final[str] = \
        'https://api.invidious.io/instances.json'
    """Final[str]: Invidiousのインスタンスのリストを取得するAPIのURL。"""

    INVIDIOUS_INSTANCES_TUPLE: Final[tuple[str, ...]] = (
        'piped.kavin.rocks',
        'piped.video',
        'invidious.poast.org'
    )
    """Final[tuple[str, ...]]: よく使われるInvidiousインスタンスのリスト。

    :const:`~INVIDIOUS_INSTANCES_URL` にアクセスしてもインスタンスが取得できないことがあるため、
    それによってURLが置換できないことを防ぐ。
    """

    CALLINSHOW: Final[str] = 'CallinShow'
    """Final[str]: 降臨ショーのユーザーネーム。"""

    LIMIT_N_TWEETS: Final[int] = UserProperties.NitterCrawler.limit_n_tweets
    """Final[int]: 取得するツイート数の上限。"""

    REPORT_INTERVAL: Final[int] = UserProperties.NitterCrawler.report_interval
    """Final[int]: 記録件数を報告するインターバル。"""

    TWEETS_OR_REPLIES: Final[str] = 'with_replies'
    """Final[str]: NitterのURLのドメインとユーザーネーム部分の接続部品。"""

    NITTER_ERROR_TITLE: Final[str] = 'Error|nitter'
    """Final[str]: Nitterでユーザーがいなかったとき返ってくるページのタイトル。

    万が一仕様変更で変わったとき用。
    """

    NO_ARCHIVE: Final[str] = 'No results'
    """Final[str]: archive.todayで魚拓がなかったときのレスポンス。

    万が一仕様変更で変わったとき用。
    """

    NEWEST: Final[str] = 'Load newest'
    """Final[str]: Nitterの前ページ読み込み部分の名前。

    万が一仕様変更で変わったとき用。
    """

    MEDIA_DIR: Final[str] = UserProperties.media_dir
    """Final[str]: 画像などのツイートメディアをダウンロードするディレクトリ。"""

    def __init__(self) -> None:
        self._check_constants()  # スラッシュが抜けてないかチェック
        self._has_ffmpeg: Final[bool] = self._check_ffmpeg()  # ffmpegがあるかチェック

        self._img_ext_pattern: Final[re.Pattern[str]] = re.compile(
            r'%2F([^%]*\.(?:jpg|jpeg|png|gif))')
        self._url_fragment_pattern: Final[re.Pattern[str]] = re.compile(
            r'#[^#]*$')
        self._url_query_pattern: Final[re.Pattern[str]] = re.compile(r'\?.*$')

    def _set_queries(
        self, accessor: AccessorHandler, krsw: str | None
    ) -> bool:
        """検索条件を設定する。

        :class:`~TwitterArchiver` のメンバをセットし、ツイートを収集するアカウントと
        検索クエリ、終わりにするツイートを入力させる。

        Args:
            accessor (AccessorHandler): アクセスハンドラ
            krsw (str | None): `None` でない場合、名前が :const:`~CALLINSHOW` になり、\
                クエリと終わりにするツイートが無しになる。\
                更に空文字でもない場合、この引数が終わりにするツイートになる。

        Returns:
            bool: 処理成功時は `True`。
        """

        # ユーザー名取得
        if krsw is not None:
            logger.info('名前は自動的に' + self.CALLINSHOW + 'にナリます')
            self._name: str = self.CALLINSHOW
        else:
            name_optional: str | None = self._input_name(accessor)
            if name_optional is not None:
                self._name: str = name_optional
            else:
                return False

        # 検索クエリとページ取得
        self._query_strs: list[str] = []
        if krsw is not None:
            logger.info('クエリは自動的になしにナリます')
        else:
            self._input_query()
        page_optional: str | None = accessor.request(
            urljoin(self.NITTER_INSTANCE, self._name + '/'
                    + self.TWEETS_OR_REPLIES))
        if page_optional is None:
            self._on_fail(accessor)
            return False
        self._nitter_page: str = page_optional

        # Nitter用のCookieをセット
        accessor.set_cookies({
            'infiniteScroll': '',
            'proxyVideos': 'on',
            'replaceReddit': '',
            'replaceYouTube': '',
        })

        # 終わりにするツイート取得
        if krsw == '':
            logger.info('終わりにするツイートは自動的になしにナリます')
            self._stop: str = ''
        elif krsw is not None:
            logger.info(f'終わりにするツイートは自動的に"{krsw}"にナリます')
            self._stop: str = krsw
        else:
            self._stop: str = self._input_stop_word()

        logger.info(
            'ユーザー名: @' + self._name
            + ', クエリ: ["' + '", "'.join(self._query_strs)
            + '"], 終わりにする文言: "' + self._stop
            + '"で検索しまふ'
        )

        # 日付取得
        tweet_link: Final[Tag | NavigableString | None] = BeautifulSoup(
            self._nitter_page, 'html.parser'
        ).find(class_='tweet-link')
        assert isinstance(tweet_link, Tag)
        href: Final[str | list[str] | None] = tweet_link.get('href')
        assert isinstance(href, str)
        date: Final[datetime] = self._tweet_date(
            href.split('#')[0])  # フラグメント識別子を除去

        self._table_builder: TableBuilder = TableBuilder(date)
        return True

    def _check_constants(self) -> None:
        """定数のバリデーションを行う。

        Returns:
            None: すべての対象定数が正しければ `None`。失敗したら例外を出す。

        Raises:
            AssertionError: バリデーションに違反した場合に出る。
        """
        assert self.NITTER_INSTANCE[-1] == '/', 'NITTER_INSTANCEの末尾をには/が必須です'
        assert self.ARCHIVE_TODAY[-1] == '/', 'ARCHIVE_TODAYの末尾をには/が必須です'
        assert self.ARCHIVE_TODAY_STANDARD[-1] == '/', \
            'ARCHIVE_TODAY_STANDARDの末尾をには/が必須です'
        assert self.TWITTER_URL[-1] == '/', 'TWITTER_URLの末尾をには/が必須です'

    def _check_ffmpeg(self) -> bool:
        """ffmpegがインストールされているかチェックする。

        Returns:
            bool: ffmpegがインストールされているか。
        """
        return shutil.which('ffmpeg') is not None

    def _check_nitter_instance(self, accessor: AccessorHandler) -> None:
        """Nitterのインスタンスが生きているかチェックする。

        死んでいたらそこで終了。
        接続を一回しか試さない :func:`~_request_once` を使っているのは、
        激重インスタンスが指定されたとき試行回数増やして偶然成功してそのまま実行されるのを躱すため。

        Args:
            accessor (AccessorHandler): アクセスハンドラ。

        Returns:
            None: Nitterにアクセスできれば `None`。できなければ終了。
        """
        logger.info('Nitterのインスタンスチェック中ですを')
        try:
            accessor.request_once(self.NITTER_INSTANCE)
        except AccessError:
            logger.critical('インスタンスが死んでますを', exc_info=True)
            sys.exit(1)
        logger.info('Nitter OK')

    def _check_archive_instance(self, accessor: AccessorHandler) -> None:
        """archive.todayのTor用インスタンスが生きているかチェックする。

        Args:
            accessor (AccessorHandler): アクセスハンドラ。

        Returns:
            None: archive.todayのTorインスタンスにアクセスできれば `None`。できなければ終了。
        """
        logger.info('archive.todayのTorインスタンスチェック中ですを')
        try:
            accessor.request_once(self.ARCHIVE_TODAY)
        except AccessError:  # エラー発生時は終了
            logger.critical('インスタンスが死んでますを', exc_info=True)
            sys.exit(1)
        logger.info('archive.today OK')

    def _invidious_instances(
        self, accessor: AccessorHandler
    ) -> tuple[str, ...]:
        """Invidiousのインスタンスのタプルを取得する。

        Args:
            accessor (AccessorHandler): アクセスハンドラ。

        Returns:
            tuple[str, ...]: Invidiousのインスタンスのタプル。インスタンスが死んでいれば終了。
        """
        logger.info('Invidiousのインスタンスリストを取得中ですを')
        invidious_json: Final[str | None] = (
            accessor.request_with_requests_module(self.INVIDIOUS_INSTANCES_URL)
        )
        if invidious_json is None:
            logger.critical('Invidiousが死んでますを')
            sys.exit(1)
        instance_list: Final[list[str]] = []
        for instance_info in json.loads(invidious_json):
            instance_list.append(instance_info[0])

        # よく使われているものはチェック
        for invidious_api in self.INVIDIOUS_INSTANCES_TUPLE:
            if invidious_api not in instance_list:
                instance_list.append(invidious_api)
        logger.debug('Invidiousのインスタンス: [' + ', '.join(instance_list) + ']')
        return tuple(instance_list)

    def _input_name(self, accessor: AccessorHandler) -> str | None:
        """ツイート収集するユーザー名を標準入力から取得する。

        何も入力しないと :const:`~CALLINSHOW` を指定する。

        Args:
            accessor (AccessorHandler): アクセスハンドラ。

        Returns:
            str | None: ユーザ名。ユーザページの取得に失敗したら `None`。
        """
        while True:
            print(
                'アカウント名を入れなければない。空白だと自動的に'
                + self.CALLINSHOW
                + 'になりますを')
            print('> ', end='')
            account_str: str = input()
            # 空欄で降臨ショー
            if account_str == '':
                return self.CALLINSHOW
            else:
                res: str | None = accessor.request(
                    urljoin(self.NITTER_INSTANCE, account_str))
                if res is None:  # リクエスト失敗判定
                    self._on_fail(accessor)
                    return None
                soup: BeautifulSoup = BeautifulSoup(res, 'html.parser')
                if soup.title == self.NITTER_ERROR_TITLE:
                    print(account_str + 'は実在の人物ではありませんでした')
                else:
                    print('最終的に出会ったのが@' + account_str + 'だった。')
                    logger.info('@' + account_str + 'をクロールしまふ')
                    return account_str

    def _input_query(self) -> None:
        """検索クエリを標準入力から取得する。"""
        print('検索クエリを入れるナリ。複数ある時は一つの塊ごとに入力するナリ。空欄を入れると検索開始ナリ。')
        print('例:「無能」→改行→「脱糞」→改行→「弟殺し」→改行→空欄で改行')
        print('> ', end='')
        query_input: str = input()
        # 空欄が押されるまでユーザー入力受付
        while query_input != '':
            self._query_strs.append(query_input)
            print('> ', end='')
            query_input = input()
        print('クエリのピースが埋まっていく。')

    def _on_fail(self, accessor: AccessorHandler) -> None:
        """接続失敗時処理。

        取得に成功した分だけファイルにダンプする。
        ログにトレースバックも表示する。

        Args:
            accessor (AccessorHandler): アクセスハンドラ。
        """
        logger.critical('接続失敗時処理をしておりまふ', stack_info=True)
        logger.critical('最後にアクセスしたURL: ' + accessor.last_url)
        print('接続失敗しすぎで強制終了ナリ')
        if self._table_builder.count > 0:  # 取得成功したデータがあれば発行
            print('取得成功した分だけ発行しますを')
            self._table_builder.dump_file()

    def _input_stop_word(self) -> str:
        """ツイートの記録を中断するための文をユーザに入力させる。

        Returns:
            str: ツイートの記録を中断するための文。ツイート内容の一文がその文と一致したら記録を中断する。
        """
        print(
            'ここにツイートの本文を入れる!すると・・・・なんとそれを含むツイートで記録を中断する!(これは本当の仕様です。)'
            f'ちなみに、空欄だと{self.LIMIT_N_TWEETS}件まで終了しない。')
        print('> ', end='')
        end_str: Final[str] = input()
        return end_str

    def _download_media(
        self,
        media_url: str,
        media_name: str,
        accessor: AccessorHandler
    ) -> bool:
        """ツイートの画像をダウンロードし、:const:`~MEDIA_DIR` に保存する。

        Args:
            media_url (str): 画像のURL。
            media_name (str): 画像ファイル名。Nitter上のimgタグのsrc属性では、\
                ``/pic/media%2F`` に後続する。
            accessor (AccessorHandler): アクセスハンドラ。

        Returns:
            bool: 保存に成功したかどうか。
        """
        os.makedirs(self.MEDIA_DIR, exist_ok=True)
        image_bytes: Final[bytes | None] = accessor.request_image(media_url)
        if image_bytes is not None:
            Path(self.MEDIA_DIR, media_name).write_bytes(image_bytes)
            return True
        else:
            return False

    def _download_m3u8(
        self,
        media_url: str,
        ts_filename: str | None,
        mp4_filename: str,
        proxies: dict[str, str] | None
    ) -> FfmpegStatus:
        """ffmpegで動画をダウンロードし、:const:`~MEDIA_DIR` に保存する。

        Args:
            media_url (str): 動画のm3u8/mp4ファイルのURL。
            ts_filename (str | None): m3u8から取得したtsファイルのパス。\
                `None` の場合はtsファイルをダウンロードせず、直接mp4をダウンロードする。
            mp4_filename (str): 取得したmp4ファイルのパス。
            proxies (dict[str, str] | None): ffmpegでの通信に用いるプロキシ設定。

        Returns:
            FfmpegStatus: ffmpegでの保存ステータス。
        """
        os.makedirs(self.MEDIA_DIR, exist_ok=True)
        if ts_filename is not None:
            ts_returncode: Final[int] = subprocess.run(
                [
                    'ffmpeg', '-y',
                    '-http_proxy', 'proxies["http"]',
                    '-i', media_url,
                    '-c', 'copy', ts_filename
                ] if proxies is not None else [
                    'ffmpeg', '-y',
                    '-i', media_url,
                    '-c', 'copy', ts_filename
                ],
                stdout=subprocess.DEVNULL).returncode

            # 取得成功したらtsをmp4に変換
            if ts_returncode == 0:
                ts2mp4_returncode: Final[int] = subprocess.run(
                    [
                        'ffmpeg', '-y', '-i', ts_filename,
                        '-acodec', 'copy', '-vcodec', 'copy', mp4_filename
                    ],
                    stdout=subprocess.DEVNULL
                ).returncode
                if ts2mp4_returncode == 0:
                    return FfmpegStatus.MP4
                else:
                    return FfmpegStatus.TS
            else:
                return FfmpegStatus.FAILED

        else:
            returncode: Final[int] = subprocess.run(
                [
                    'ffmpeg', '-y',
                    '-http_proxy', 'proxies["http"]',
                    '-i', media_url,
                    '-c', 'copy', mp4_filename
                ] if proxies is not None else [
                    'ffmpeg', '-y',
                    '-i', media_url,
                    '-c', 'copy', mp4_filename
                ],
                stdout=subprocess.DEVNULL).returncode
            if returncode == 0:
                return FfmpegStatus.MP4
            else:
                return FfmpegStatus.FAILED

    def _tweet_date(self, url: str) -> datetime:
        """ツイートの時刻を取得する。

        URLのIDから、Snowflakeの手順を逆算してタイムスタンプを計算している。
        https://github.com/twitter-archive/snowflake/blob/b3f6a3c6ca8e1b6847baa6ff42bf72201e2c2231/src/main/scala/com/twitter/service/snowflake/IdWorker.scala#L91

        Args:
            url (str): ツイートのURL。

        Returns:
            datetime: ツイートの時刻。
        """
        id: int = int(url.split('/')[-1])
        timestamp: float = ((id >> 22) + 1_288_834_974_657) / 1000.
        return datetime.fromtimestamp(timestamp, tz=ZoneInfo('Asia/Tokyo'))

    def _fetch_tweet_media(
        self,
        tweet: Tag,
        tweet_url: str,
        accessor: AccessorHandler
    ) -> str:
        """ツイートの画像や動画を取得する。

        Args:
            tweet (Tag): ツイート内容である ``.timeline-item`` タグを表すbeautifulsoup4タグ。
            tweet_url (str): ツイートのURL。
            accessor (AccessorHandler): アクセスハンドラ。

        Returns:
            str: Wiki記法でのファイルへのリンクの文字列。
        """
        # 引用リツイート内のメディアを選択しないように.tweet-body直下の.attachmentsを選択
        tweet_media: Final[Tag | None] = tweet.select_one(
            '.tweet-body > .attachments')
        media_txt: str = ''
        if tweet_media is not None:
            media_list: Final[list[str]] = []
            # ツイートの画像の取得
            for image_a in tweet_media.select('.attachment.image a'):
                try:
                    href: str | list[str] | None = image_a.get('href')
                    assert isinstance(href, str)
                    img_matched: re.Match[str] | None = (
                        self._img_ext_pattern.search(href))
                    assert img_matched is not None
                    media_name: str = img_matched.group(1)
                    media_list.append(f'[[ファイル:{media_name}|240px]]')
                    if self._download_media(
                            urljoin(self.TWITTER_MEDIA_URL, media_name),
                            media_name,
                            accessor):
                        logger.info(
                            os.path.join(self.MEDIA_DIR, media_name)
                            + ' をアップロードしなければない。')
                    else:
                        logger.info(
                            urljoin(self.TWITTER_MEDIA_URL, media_name)
                            + ' をアップロードしなければない。')
                except AttributeError:
                    logger.error(f'{tweet_url}の画像が取得できませんでしたを 当職無能')
                    media_list.append('[[ファイル:(画像の取得ができませんでした)|240px]]')

            # ツイートの動画の取得。ffmpegが入っていなければ手動で取得すること
            for i, video_container in enumerate(
                    tweet_media.select('.attachment.video-container')):
                if not self._has_ffmpeg:
                    logger.warning(f'ffmpegがないため{tweet_url}の動画が取得できませんでしたを')
                    media_list.append('[[ファイル:(動画の取得ができませんでした)|240px]]')
                    continue

                # videoタグがない場合は取得できない
                video: Tag | None = video_container.select_one('video')
                if video is None:
                    logger.error(f'{tweet_url}の動画が取得できませんでしたを 当職無能')
                    media_list.append('[[ファイル:(動画の取得ができませんでした)|240px]]')
                    continue

                # videoタグのdata-url属性またはvideoタグ直下のsourceタグからURLが取得できる
                data_url: str | list[str] | None = video.get('data-url')
                source_tag: Tag | None = video.select_one('source')
                src_url: str | list[str] | None = \
                    source_tag.get('src') if source_tag is not None else None
                video_url: str | list[str] | None = data_url or src_url
                assert isinstance(video_url, str)
                tweet_id: str = tweet_url.split('/')[-1]
                if data_url is not None:
                    # data-url属性からURLを取得した場合
                    video_matched: re.Match[str] | None = re.search(
                        r'[^/]+$', video_url)
                    assert video_matched is not None
                    media_path: str = unquote(video_matched.group())
                    media_url: str = urljoin(self.NITTER_INSTANCE, media_path)
                    ts_filename: str | None = (
                        f'{os.path.join(self.MEDIA_DIR, tweet_id)}_{i}.ts'
                    )
                else:
                    # sourceタグからURLを取得した場合
                    media_url: str = video_url
                    ts_filename: str | None = None
                mp4_filename: str = (
                    f'{os.path.join(self.MEDIA_DIR, tweet_id)}_{i}.mp4'
                )

                match self._download_m3u8(
                        media_url,
                        ts_filename,
                        mp4_filename,
                        accessor.proxies):
                    case FfmpegStatus.MP4:
                        logger.info(f'{mp4_filename}をアップロードしなければない。')
                        media_list.append(f'[[ファイル:{tweet_id}_{i}.mp4|240px]]')
                    case FfmpegStatus.TS:
                        logger.info(f'{ts_filename}.tsをmp4に変換してアップロードしなければない。')
                        media_list.append(f'[[ファイル:{tweet_id}_{i}.mp4|240px]]')
                    case FfmpegStatus.FAILED:
                        logger.error(f'{tweet_url}の動画が取得できませんでしたを 当職無能')
                        media_list.append('[[ファイル:(動画の取得ができませんでした)|240px]]')
                    case _ as unreachable:  # pyright: ignore[reportUnnecessaryComparison] # noqa: E501
                        assert_never(unreachable)

            media_txt = ' '.join(media_list)
        return media_txt

    def _get_tweet_quote(self, tweet: Tag, accessor: AccessorHandler) -> str:
        """引用リツイートの引用元へのリンクを取得する。

        Args:
            tweet (Tag): ツイート内容である ``.timeline-item`` タグを表すbeautifulsoup4タグ。
            accessor (AccessorHandler): アクセスハンドラ。

        Returns:
            str: Archiveテンプレートでラップされた引用元ツイートへのリンク。
        """
        tweet_quote: Final[Tag | None] = tweet.select_one(
            '.tweet-body > .quote.quote-big')  # 引用リツイートを選択
        quote_txt: str = ''
        if tweet_quote is not None:
            quote_link: Final[Tag | None] = (
                tweet_quote.select_one('.quote-link'))
            assert quote_link is not None
            link_href: Final[str | list[str] | None] = quote_link.get('href')
            assert isinstance(link_href, str)
            link: str = self._url_fragment_pattern.sub('', link_href)
            link = urljoin(self.TWITTER_URL, link)
            quote_txt = self._archive_url(link, accessor)
        tweet_quote_unavailable: Final[Tag | None] = tweet.select_one(
            '.tweet-body > .quote.unavailable')  # 引用リツイートを選択
        if tweet_quote_unavailable is not None:
            quote_txt = '(引用元が削除されました)'
        return quote_txt

    def _get_tweet_poll(self, tweet: Tag) -> str:
        """ツイートの投票結果を取得する。

        Args:
            tweet (Tag): ツイート内容である ``.timeline-item`` タグを表すbeautifulsoup4タグ。

        Returns:
            str: Wiki形式に書き直した投票結果。
        """
        tweet_poll: Final[Tag | None] = tweet.select_one('.tweet-body > .poll')
        poll_txt: str = ''
        if tweet_poll is not None:
            poll_meters: Final[ResultSet[Tag]] = tweet_poll.select(
                '.poll-meter')
            poll_info: Final[Tag | None] = tweet_poll.select_one('.poll-info')
            assert poll_info is not None
            for poll_meter in poll_meters:
                poll_choice_value: Tag | None = poll_meter.select_one(
                    '.poll-choice-value')
                assert poll_choice_value is not None
                ratio: str = poll_choice_value.text
                poll_choice_option: Tag | None = poll_meter.select_one(
                    '.poll-choice-option')
                assert poll_choice_option is not None
                if 'leader' in poll_meter['class']:
                    poll_txt += ('<br>\n'
                                 '&nbsp; <span style="display: inline-block; '
                                 'width: 30em; background: linear-gradient('
                                 'to right, '
                                 f'rgba(29, 155, 240, 0.58) 0 {ratio}, '
                                 f'transparent {ratio} 100%); '
                                 'font-weight: bold;">') \
                        + ratio + ' ' + poll_choice_option.text + '</span>'
                else:
                    poll_txt += ('<br>\n'
                                 '&nbsp; <span style="display: inline-block; '
                                 'width: 30em; background: linear-gradient('
                                 'to right, '
                                 f'rgb(207, 217, 222) 0 {ratio}, '
                                 f'transparent {ratio} 100%);">') \
                        + ratio + ' ' + poll_choice_option.text + '</span>'
            poll_txt += '<br>\n&nbsp; <span style="font-size: small;">' \
                + poll_info.text + '</span>'
        return poll_txt

    def _get_timeline_items(self, soup: Tag) -> list[Tag]:
        """タイムラインのツイートを取得。

        基本的に投稿時刻の降順に取得し、リプライツリーは最後のツイートの時刻を基準として降順にひとまとまりにする。

        Args:
            soup (Tag): Nitterのページを表すbeautifulsoup4タグ。

        Returns:
            list[Tag]: ツイートのアイテムである ``.timeline-item`` タグを表すTagオブジェクトのリスト。
        """
        timeline_item_list: Final[list[Tag]] = []
        for item_or_list in soup.select(
                '.timeline > .timeline-item, .timeline > .thread-line'):
            if 'unavailable' in item_or_list.attrs['class']:
                continue
            elif 'thread-line' in item_or_list.attrs['class']:
                # そのままtimeline-itemクラスをfind_allするとツイートの順番が逆転するので、順番通りに取得するよう処理
                for item in reversed(item_or_list.select('.timeline-item')):
                    timeline_item_list.append(item)
            else:
                timeline_item_list.append(item_or_list)
        return timeline_item_list

    def _archive_soup(self, tag: Tag, accessor: AccessorHandler) -> None:
        """ツイート内のaタグをテンプレートArchiveの文字列に変化させる。

        NitterリンクをYouTubeへのリンクに、bibliogramへのリンクをInstagramへのリンクに修正する。

        Args:
            tag (Tag): ツイート内容の本文である ``.media-body`` タグを表すbeautifulsoup4タグ。
            accessor (AccessorHandler): アクセスハンドラ。
        """
        urls_in_tweet: Final[ResultSet[Tag]] = tag.find_all('a')
        for url in urls_in_tweet:
            href: str | list[str] | None = url.get('href')
            assert isinstance(href, str)

            if href.startswith('https://') or href.startswith('http://'):
                # 先頭にhttpが付いていない物はハッシュタグの検索ページへのリンクなので処理しない
                if href.startswith('https' + self.NITTER_INSTANCE[4:]):
                    # Nitter上のTwitterへのリンクを直す
                    url_link: str = href.replace(
                        'https' + self.NITTER_INSTANCE[4:], self.TWITTER_URL)
                    url_link = self._url_query_pattern.sub('', url_link)
                    url.replace_with(self._archive_url(url_link, accessor))
                elif href.startswith('https://nitter.kavin.rocks/'):
                    # Nitter上のTwitterへのリンクを直す
                    url_link: str = href.replace(
                        'https://nitter.kavin.rocks/', self.TWITTER_URL)
                    url_link = self._url_query_pattern.sub('', url_link)
                    url.replace_with(self._archive_url(url_link, accessor))
                elif (hasattr(self, '_invidious_pattern')
                        and self._invidious_pattern.search(href)):
                    # Nitter上のYouTubeへのリンクをInvidiousのものから直す
                    invidious_href: str | list[str] | None = (
                        self._invidious_pattern.sub(
                            'youtube.com' if (
                                re.match(r'https://[^/]+/[^/]+/', href)
                                or re.search(r'/@[^/]*$', href)
                            ) else 'youtu.be',
                            href))
                    url.replace_with(self._archive_url(
                        invidious_href, accessor))
                elif href.startswith('https://bibliogram.art/'):
                    # Nitter上のInstagramへのリンクをBibliogramのものから直す
                    # Bibliogramは中止されたようなのでそのうちリンクが変わるかも
                    url_link: str = href.replace(
                        'https://bibliogram.art/',
                        'https://www.instagram.com/')
                    url.replace_with(self._archive_url(url_link, accessor))
                else:
                    url.replace_with(self._archive_url(href, accessor))
            elif url.text.startswith('@'):
                url_link: str = urljoin(self.TWITTER_URL, href)
                url_text: str = url.text
                url.replace_with(
                    self._archive_url(
                        url_link, accessor, url_text))

    def _archive_url(
        self,
        url: str,
        accessor: AccessorHandler,
        text: str | None = None
    ) -> str:
        """URLをArchiveテンプレートでラップする。

        フラグメント識別子がURLに含まれていたら、Archive側のURLにも反映させる。

        Args:
            url (str): ラップするURL。
            accessor (AccessorHandler): アクセスハンドラ。
            text (str | None, optional): ArchiveテンプレートでURLの代わりに表示する文字列。

        Returns:
            str: ArchiveタグでラップしたURL。
        """
        if '#' in url:  # フラグメント識別子の処理
            main_url, fragment = url.split('#', maxsplit=1)
            return TableBuilder.archive_url(
                url, self._archive(main_url, accessor) + '#' + fragment, text)
        else:
            return TableBuilder.archive_url(
                url, self._archive(url, accessor), text)

    def _callinshowlink_url(self, url: str, accessor: AccessorHandler) -> str:
        """URLをCallinShowLinkテンプレートでラップする。

        Args:
            url (str): ラップするURL。
            accessor (AccessorHandler): アクセスハンドラ。

        Returns:
            str: CallinShowLinkタグでラップしたURL。
        """
        return TableBuilder.callinshowlink_url(
            url, self._archive(url, accessor))

    def _archive(self, url: str, accessor: AccessorHandler) -> str:
        """URLから対応するarchive.todayのURLを返す。

        取得できれば魚拓ページのURLを返す。
        魚拓がなければその旨の警告を表示し、https://archive.ph/https%3A%2F%2Fxxxxxxxx の形で返される。
        アクセスに失敗すればその旨の警告を表示し、https://archive.ph/https%3A%2F%2Fxxxxxxxx の形で返される。

        Args:
            url (str): 魚拓を取得するURL。
            accessor (AccessorHandler): アクセスハンドラ。

        Returns:
            str: 魚拓のURL。
        """
        archive_url: str = urljoin(
            self.ARCHIVE_TODAY_STANDARD,
            quote(unquote(url), safe='&=+?%'))
        res: Final[str | None] = accessor.request(urljoin(
            self.ARCHIVE_TODAY, quote(unquote(url), safe='&=+?%')))
        if res is None:  # 魚拓接続失敗時処理
            # https://archive.ph/https%3A%2F%2Fxxxxxxxxの形で返される
            logger.error(archive_url + 'にアクセス失敗ナリ。出力されるテキストにはそのまま記載されるナリ。')
        else:
            soup: Final[BeautifulSoup] = BeautifulSoup(res, 'html.parser')
            content: Final[Tag | NavigableString | None] = soup.find(
                id='CONTENT')  # archive.todayの魚拓一覧ページの中身だけ取得
            if (content is None or content.get_text()[:len(self.NO_ARCHIVE)]
                    == self.NO_ARCHIVE):  # 魚拓があるかないか判定
                logger.warning(url + 'の魚拓がない。これはいけない。')
            else:
                assert isinstance(content, Tag)
                content_a: Final[Tag | NavigableString | None] = (
                    content.select_one('.TEXT-BLOCK > a'))  # 最新の魚拓を取得
                assert isinstance(content_a, Tag)
                href: Final[str | list[str] | None] = content_a.get('href')
                assert isinstance(href, str)
                archive_url = href.replace(
                    self.ARCHIVE_TODAY, self.ARCHIVE_TODAY_STANDARD)
        return archive_url

    def _get_tweet(self, accessor: AccessorHandler) -> bool:
        """ページからツイート本文を ``TableBuilder`` インスタンスに収めていく。

        ツイートの記録を中断するための文を発見するか、記録したツイート数が :const:`~LIMIT_N_TWEETS` 件に達したら
        `False` を返す。

        Args:
            accessor (AccessorHandler): アクセスハンドラ。

        Returns:
            bool: 終わりにするツイートを発見するか、記録件数が上限に達したら `False`。
        """
        soup: Final[BeautifulSoup] = BeautifulSoup(
            self._nitter_page, 'html.parser')
        tweets: Final[list[Tag]] = self._get_timeline_items(soup)
        for tweet in tweets:
            tweet_a: Tag | None = tweet.a
            assert tweet_a is not None
            if tweet_a.text == self.NEWEST:
                # Load Newestのボタンは処理しない
                continue
            if tweet.find(class_='retweet-header') is not None:
                # retweet-headerはリツイートを示すので入っていれば処理しない
                continue
            if tweet.find(class_='pinned') is not None:
                # pinnedは固定ツイートを示すので入っていれば処理しない
                continue
            if len(self._query_strs) > 0:
                # クエリが指定されている場合、一つでも含まないツイートは処理しない
                not_match: bool = False
                for query_str in self._query_strs:
                    if query_str not in tweet.text:
                        not_match = True
                        break
                if not_match:
                    continue

            tweet_link: Tag | NavigableString | None = tweet.find(
                class_='tweet-link')
            assert isinstance(tweet_link, Tag)
            href: str | list[str] | None = tweet_link.get('href')
            assert isinstance(href, str)
            tweet_url: str = urljoin(
                self.TWITTER_URL,
                self._url_fragment_pattern.sub('', href))

            # 日付の更新処理
            date: datetime = self._tweet_date(tweet_url)
            self._table_builder.next_day_if_necessary(date)

            tweet_callinshow_template: str = self._callinshowlink_url(
                tweet_url, accessor)
            tweet_content: Tag | NavigableString | None = tweet.find(
                class_='tweet-content media-body')
            assert isinstance(tweet_content, Tag)
            self._archive_soup(tweet_content, accessor)
            media_txt: str = self._fetch_tweet_media(
                tweet, tweet_url, accessor)
            quote_txt: str = self._get_tweet_quote(tweet, accessor)
            poll_txt: str = self._get_tweet_poll(tweet)
            self._table_builder.append(
                tweet_callinshow_template, '<br>\n'.join(
                    filter(
                        None,
                        [
                            TableBuilder.escape_wiki_reserved_words(
                                tweet_content.get_text()),
                            quote_txt, media_txt, poll_txt
                        ])))

            if self._table_builder.count % self.REPORT_INTERVAL == 0:
                logger.info(
                    f'ツイートを{self._table_builder.count}件も記録したンゴwwwwwwwwwww')
            if self._stop != '' and self._stop in tweet_content.get_text():
                logger.info('目的ツイート発見でもう尾張屋根')
                self._table_builder.dump_file()
                return False
            if self._table_builder.count >= self.LIMIT_N_TWEETS:
                logger.info(f'{self.LIMIT_N_TWEETS}件も記録している。もうやめにしませんか。')
                self._table_builder.dump_file()
                return False
        return True

    def _go_to_new_page(self, accessor: AccessorHandler) -> bool:
        """Nitterで次のページに移動する。

        次のページが無ければ `False` を返す。

        Args:
            accessor (AccessorHandler): アクセスハンドラ。

        Returns:
            bool: 次のページを取得できれば `True`。
        """
        soup: Final[BeautifulSoup] = BeautifulSoup(self._nitter_page,
                                                   'html.parser')
        show_mores: Final[ResultSet[Tag]] = soup.find_all(class_='show-more')
        assert len(show_mores) > 0

        new_url: str = ''
        for show_more in show_mores:  # show-moreに次ページへのリンクか前ページへのリンクがある
            show_more_a: Tag | None = show_more.a
            assert show_more_a is not None
            href: str | list[str] | None = show_more_a.get('href')
            assert isinstance(href, str)
            new_url = urljoin(
                self.NITTER_INSTANCE,
                self._name
                + '/'
                + self.TWEETS_OR_REPLIES
                + href)  # 直下のaタグのhrefの中身取ってURL頭部分と合体

        res: Final[str | None] = accessor.request(new_url)
        if res is None:
            self._on_fail(accessor)
            return False
        new_page_soup: Final[BeautifulSoup] = BeautifulSoup(res, 'html.parser')
        if new_page_soup.find(class_='timeline-end') is None:
            # ツイートの終端ではtimeline-endだけのページになるので判定
            logger.info(new_url + 'に移動しますを')
            self._nitter_page = res  # まだ残りツイートがあるのでページを返して再度ツイート本文収集
            return True
        else:
            logger.info('急に残りツイートが無くなったな終了するか')
            self._table_builder.dump_file()
            return False

    def _signal_handler(
        self, signum: int, frame: FrameType | None
    ) -> NoReturn:
        """ユーザがCtrl + Cでプログラムを止めたときのシグナルハンドラ。

        Args:
            signum (int): シグナル番号。想定されるのはSIGINTのみ。
            frame (FrameType | None): 現在のスタックフレーム。
        """
        logger.info('ユーザがプログラムを中止したなりを')
        self._table_builder.dump_file()
        sys.exit()

    @deprecated('\033[31m'
                'Nitterはサービスを終了しており、いずれすべてのインスタンスが使えなくなるナリ。'
                '--search-unarchived (-u)オプションを外してarchive.todayから収集するモードを'
                '使ってね(笑)、それはできるよね。'
                'See also: https://nitter.cz'
                '\033[0m')
    def execute(
        self,
        krsw: str | None = None,
        use_browser: bool = True,
        enable_javascript: bool = True
    ) -> None:
        """通信が必要な部分のロジック。

        Args:
            krsw (str | None, optional): `None` でない場合、名前が自動で \
                :const:`~CALLINSHOW` になり、クエリと終わりにするツイートが自動で無しになる。\
                更に空文字でもない場合、この引数が終わりにするツイートになる。
            use_browser (bool, optional): `True` ならSeleniumを利用する。\
                `False` ならRequestsのみでアクセスする。
            enable_javascript (bool, optional): SeleniumでJavaScriptを利用する場合は \
                `True`。

        .. deprecated:: 4.2.0
            Nitterはサービスを終了しており、いずれすべてのインスタンスが使えなくなる [2]_。
            :class:`~ArchiveCrawler` はarchive.todayからツイートを収集するため引き続き利用可能。
        """

        # Seleniumドライバーを必ず終了するため、with文を利用する。
        with AccessorHandler(use_browser, enable_javascript) as accessor:
            # 実行前のチェック
            self._check_nitter_instance(accessor)
            self._check_archive_instance(accessor)
            # Invidiousのインスタンスリストの正規表現パターンを取得
            invidious_url_tuple: Final[tuple[str, ...]] = (
                self._invidious_instances(accessor)
            )
            self._invidious_pattern: re.Pattern[str] = re.compile(
                '|'.join(invidious_url_tuple))

            # 検索クエリの設定
            if not self._set_queries(accessor, krsw):
                sys.exit(1)

            # ツイートを取得し終えるまでループ
            signal.signal(signal.SIGINT, self._signal_handler)
            try:
                while True:
                    if not self._get_tweet(accessor):
                        break
                    if not self._go_to_new_page(accessor):
                        break
            except BaseException:
                logger.critical('予想外のエラーナリ。ここまでの成果をダンプして終了するナリ。')
                self._table_builder.dump_file()
                raise


class UrlTuple(NamedTuple):
    """URLとその魚拓のURLのペア。"""
    url: str
    """URL。"""
    archive_url: str
    """魚拓のURL。"""


class ArchiveCrawler(TwitterArchiver):
    """archive.todayに記録された尊師のツイートのうち、Wiki未掲載のものを収集する。

    Warning:
        このモードではURLリストの他にツイート本文も整形して取得するナリが、
        機能のテストが不十分であることと、TwitterのHTML構造はしばしば変更されることから、
        整形後の出力が正しいとは限らないナリ

        機能を過信せず、自分の目で確かめてね(笑)、それはできるよね。

    Todo:
        * ちゃんとテストする。
    """
    WIKI_URL: Final[str] = 'https://krsw-wiki.in'
    """Final[str]: WikiのURL。"""

    TEMPLATE_URL: Final[str] = WIKI_URL + '/wiki/テンプレート:降臨ショー恒心ログ'
    """Final[str]: テンプレート:降臨ショー恒心ログのURL。"""

    URL_LIST_FILENAME: Final[str] = \
        UserProperties.ArchiveCrawler.url_list_filename
    """Final[str]: URLのリストをダンプするファイル名。"""

    @override
    def _set_queries(
        self, accessor: AccessorHandler, krsw: str | None
    ) -> bool:
        """検索条件を設定する。

        :class:`~TwitterArchiver` のメンバをセットし、ツイートを収集するアカウントを入力させ、
        収集するツイートのうち最古のツイートIDを指定させる。

        Args:
            accessor (AccessorHandler): アクセスハンドラ
            krsw (str | None): `None` でない場合、名前が :const:`~CALLINSHOW` になる。

        Returns:
            bool: 処理成功時は `True`。
        """

        # ユーザー名取得
        if krsw is not None:
            logger.info('名前は自動的に' + self.CALLINSHOW + 'にナリます')
            self._name: str = self.CALLINSHOW
        else:
            name_optional: str | None = self._input_name(accessor)
            if name_optional is not None:
                self._name: str = name_optional
            else:
                return False
        # 探索対象のうち最古のツイートID取得
        oldest_id: str = self._input_oldest_id()
        self._oldest_id_queue: deque[int] = deque(map(int, oldest_id)) if \
            len(oldest_id) > 0 else deque([0])
        self._latest_id_digit: int = self._oldest_id_queue.popleft()
        self._oldest_id_queue.append(0)  # 空になると予想外の動作を起こしかねないため
        self._oldest_url: str = self.TWITTER_URL + self._name + '/status/' \
            + oldest_id
        logger.info(
            'ユーザー名: @' + self._name
            + ', 最古のURL: ' + self._oldest_url + '*' + 'で検索しまふ'
        )
        self._twitter_url_pattern: re.Pattern[str] = re.compile(
            '^' + self.TWITTER_URL + self._name + r'/status/\d+')
        self._archive_rt_pattern: re.Pattern[str] = re.compile(
            r'on (?:Twitter|X): "RT @\w+:.+"(?:$| / Twitter$| / X$)')

        self._url_list_on_wiki: list[str] = []
        self._url_list: list[UrlTuple] = []
        return True

    def _input_oldest_id(self) -> str:
        """探索対象のうち、最古のツイートのIDを入力させる。

        正しい形式のツイートIDまたは空白が入力されるまで繰り返す。

        Returns:
            str: ツイートIDまたは空文字列。
        """
        while True:
            print('ツイートを新しい順に取得するので、収集をストップするツイートIDを入力して下さいナリ')
            print('空白だと全ツイートを収集しますを')
            print('ツイートIDとは、`' + self.TWITTER_URL + self.CALLINSHOW
                  + '/status/`の後ろの数字でふ')
            print('> ', end='')
            oldest_id: str = input()
            if re.match(r'^\d*$', oldest_id) is not None:
                return oldest_id
            else:
                print('整数か空白を入力して下さいナリ')

    def _get_tweet_urls_from_wiki(self, accessor: AccessorHandler) -> None:
        """Wikiに未掲載のツイートのURLリストを取得する。

        Args:
            accessor (AccessorHandler): アクセスハンドラ。
        """
        def assert_get(tag: Tag, key: str) -> str:
            """BeautifulSoupのタグから属性値を取得する。

            Args:
                tag (Tag): BeautifulSoupのタグ。
                key (str): 属性キー。

            Returns:
                str: タグの属性値。
            """
            result: Final[str | list[str] | None] = tag.get(key)
            assert isinstance(result, str)
            return result

        template_page: Final[str | None] = (
            accessor.request_with_requests_module(self.TEMPLATE_URL))
        assert template_page is not None
        template_soup: Final[BeautifulSoup] = BeautifulSoup(
            template_page,
            'html.parser')

        urls: Final[list[str]] = list(map(
            lambda x: self.WIKI_URL + assert_get(x, 'href'),
            template_soup.select('.wikitable > tbody > tr > td a')))
        for url in urls:
            logger.info(f'{unquote(url)} で収集済みツイートを探索中でふ')
            page: str | None = accessor.request_with_requests_module(url)
            assert page is not None
            soup: BeautifulSoup = BeautifulSoup(page, 'html.parser')
            url_as = soup.select('tr > th a')
            assert len(url_as) > 0
            for url_a in url_as:
                href: str | list[str] | None = url_a.get('href')
                assert isinstance(href, str)
                if href.startswith(self.TWITTER_URL + self._name):
                    self._url_list_on_wiki.append(href)

    def _append_tweet_urls(self, soup: Tag) -> None:
        """ツイートのURLを保存する。

        リツイートはここで除外する。

        Args:
            soup (Tag): archive.todayでのURL検索結果のページのオブジェクト。
        """
        tweets: Final[ResultSet[Tag]] = soup.select(
            '#CONTENT > div > .TEXT-BLOCK')
        for tweet in tweets:
            # リダイレクトがあればすべてのリンクを、ないなら目的のURLだけが取得できる
            urls: list[str] = list(map(
                lambda a: a.text, tweet.select('a')[1:]))
            # 別のユーザへのリダイレクトがあるものは除く
            if len(set(map(lambda url: urlparse(url).path, urls))) != 1:
                logger.debug('Found an external redirect ' + str(urls))
                continue

            url_matched: Final[re.Match[str]] | None = next(
                filter(
                    lambda x: x is not None,
                    map(lambda url: self._twitter_url_pattern.match(url), urls)
                ), None
            )  # 最初にマッチしたURLを返す
            if url_matched is not None:
                if url_matched.string < self._oldest_url:
                    logger.debug(url_matched.string + 'は最古の探索対象よりも古いのでポア')
                    continue
                a_first_child: Tag | None = tweet.select_one('a:first-child')
                assert a_first_child is not None
                archive_url: str | list[str] | None = a_first_child.get('href')
                assert isinstance(archive_url, str)
                if url_matched[0] not in self._url_list_on_wiki:
                    # ツイートのURLが未取得のものならばURLを保存する
                    text_tag: Tag | None = tweet.select_one('a')
                    assert text_tag is not None
                    if (self._archive_rt_pattern.search(text_tag.text) is not
                            None):
                        logger.debug(url_matched[0] + 'はリツイートなのでポア')
                        continue
                    self._url_list.append(
                        UrlTuple(url_matched[0], archive_url))
                    self._url_list_on_wiki.append(url_matched[0])
        self._url_list.sort(reverse=True, key=lambda x: x.url)  # 降順

    def _fetch_next_page(
        self, soup: Tag, accessor: AccessorHandler
    ) -> str | None:
        """archive.todayの検索結果のページをpaginateする。

        Args:
            soup (Tag): archive.todayでのURL検索結果のページのオブジェクト。
            accessor (AccessorHandler): アクセスハンドラ。

        Returns:
            str | None: 次のページがあればそのHTML。
        """
        next_a: Final[Tag | None] = soup.select_one('#next')
        if next_a is not None:
            link: Final[str | list[str] | None] = next_a.get('href')
            assert isinstance(link, str)
            page: Final[str | None] = accessor.request(link)
            assert page is not None
            return page
        else:
            return

    def _get_tweet_loop(self, soup: Tag, accessor: AccessorHandler) -> None:
        """archive.todayの検索結果に対して、paginateしながら未記載のツイートURLを記録する。

        Args:
            soup (Tag): archive.todayでのURL検索結果のページのオブジェクト。
            accessor (AccessorHandler): アクセスハンドラ。
        """
        has_next: bool = True
        while has_next:
            self._append_tweet_urls(soup)
            next_page: str | None = self._fetch_next_page(soup, accessor)
            if next_page is not None:
                soup = BeautifulSoup(next_page, 'html.parser')
            else:
                has_next = False

    def _next_url(
        self,
        accessor: AccessorHandler,
        tweet_url_prefix: str,
        incremented_num: int,
        incremented: bool = False
    ) -> None:
        """ツイートのURLを、数字部分をインクリメントしながら探索する。

        `https://twitter.com/CallinShow/status/` に続く数字部分について、
        `tweet_url_prefix` で始まるものを、その次の桁を `incremented_num` から9までインクリメントして探索する。

        Args:
            accessor (AccessorHandler): アクセスハンドラ。
            tweet_url_prefix (str): ツイートURLの数字部分のうち、インクリメントする桁以前の部分。
            incremented_num (int): ツイートURLのうちインクリメントする桁の現在の数字。
            incremented (bool, optional): `incremented_num` がインクリメント済みの場合True。

        Examples:
            `https://twitter.com/CallinShow/status/1707` で始まるURLから最新まですべて探索する場合
            ::

                self._next_url(accessor, '1707', 0)

            `https://twitter.com/CallinShow/status/165` で始まるURLから最新まですべて探索する場合
            ::

               self._next_url(accessor, '16', 5)
        """
        assert 0 <= incremented_num <= 9, \
            f'incremented_numが{incremented_num}でふ'
        logger.info(self.TWITTER_URL + self._name + '/status/'
                    + tweet_url_prefix + str(incremented_num) + '*を探索中')

        page: Final[str | None] = accessor.request(
            self.ARCHIVE_TODAY
            + self.TWITTER_URL
            + self._name
            + '/status/'
            + tweet_url_prefix
            + str(incremented_num) + '*')
        assert page is not None
        soup: Final[BeautifulSoup] = BeautifulSoup(page, 'html.parser')

        pager: Final[Tag | None] = soup.select_one('#pager')
        if pager is not None:  # 検索結果が複数ページ
            page_num_matched: Final[re.Match[str] | None] = re.search(
                r'of (\d+) urls', pager.text)
            assert page_num_matched is not None
            page_num: Final[int] = int(page_num_matched[1])
            if page_num > 100:  # ツイート数が100を超えると途中でreCAPTCHAが入るので、もっと細かく検索
                # ユーザが最初に"1512"と指定した時、tweet_url_prefix="151"ならincremented_numを2から開始したいが、
                # tweet_url_prefix="152"や"160"などならincremented_numを0から開始したい
                if incremented:
                    self._next_url(accessor,
                                   tweet_url_prefix + str(incremented_num), 0,
                                   True)
                else:
                    self._latest_id_digit = self._oldest_id_queue.popleft()
                    self._oldest_id_queue.append(0)  # 空になると予想外の動作を起こしかねないため
                    self._next_url(accessor,
                                   tweet_url_prefix + str(incremented_num),
                                   self._latest_id_digit)
            else:
                logger.debug(
                    self.TWITTER_URL + self._name + '/status/'
                    + tweet_url_prefix + str(incremented_num) + '*からURLを収集しまふ')
                self._get_tweet_loop(soup, accessor)
        else:  # 検索結果が1ページだけ
            if soup.select_one('.TEXT-BLOCK'):  # 検索結果が存在する場合
                logger.debug(
                    self.TWITTER_URL + self._name + '/status/'
                    + tweet_url_prefix + str(incremented_num) + '*からURLを収集しまふ')
                self._get_tweet_loop(soup, accessor)

        # 次のurlを探索
        if incremented_num == 9:
            return
        else:
            self._next_url(accessor,
                           tweet_url_prefix,
                           incremented_num + 1,
                           True)

    def _parse_images(
        self, soup: Tag, accessor: AccessorHandler
    ) -> tuple[str, ...]:
        """ツイートの魚拓から画像をダウンロードし、ファイル名のタプルを返す。

        引用リツイート内の画像や、外部リンクの画像は除く。

        Args:
            soup (Tag): ツイートの魚拓のタグ。
            accessor (AccessorHandler): アクセスハンドラ。

        Returns:
            tuple[str, ...]: ファイル名のタプル。
        """
        image_list: list[str] = []

        image_tags: ResultSet[Tag] = soup.select(
            'img[alt="Image"]:not(div[role="link"] img[alt="Image"])')
        for image_tag in image_tags:
            media_url_path: str | list[str] | None = (
                image_tag.get('src'))
            assert isinstance(media_url_path, str)
            original_image_url: str | list[str] | None = (
                image_tag.get('new-cursrc'))
            assert isinstance(original_image_url, str)
            parse_result: ParseResult = urlparse(original_image_url)
            original_image_name: str = parse_result.path.split('/')[-1]
            original_extension: str = parse_qs(parse_result.query)['format'][0]
            original_image_name += '.' + original_extension
            self._download_media(
                urljoin(self.ARCHIVE_TODAY, media_url_path),
                original_image_name,
                accessor)

            image_list.append(original_image_name)

        return tuple(image_list)

    def _replace_links(self, tag: Tag, accessor: AccessorHandler) -> Tag:
        """ツイートの魚拓の、本文内のaタグを整形する。

        Args:
            tag (Tag): aタグを置き換えるべきタグ。
            accessor (AccessorHandler): アクセスハンドラ。

        Returns:
            Tag: テキスト内のaタグが置き換えられたタグ。
        """
        internal_a_tags: Final[ResultSet[Tag]] = tag.select(
            'div[data-testid="tweetText"]:not(div[role="link"] '
            f'div[data-testid="tweetText"]) a[href*="{self.TWITTER_URL}"]')
        # Twitter内部のリンク
        if len(internal_a_tags) > 0:
            for a_tag in internal_a_tags:
                account_name: str = a_tag.text
                if account_name.startswith('#'):
                    a_tag.replace_with(a_tag.text)
                else:
                    url: str = urljoin(self.TWITTER_URL, account_name[1:])
                    a_tag.replace_with(TableBuilder.archive_url(
                        url,
                        self._archive(url, accessor),
                        account_name))

        # 通常のリンク
        a_tags: Final[ResultSet[Tag]] = tag.select(
            'div[dir="auto"] > a:not('
            'div[role="link"] div[dir="auto"] > a)')
        for a_tag in a_tags:
            a_tag.replace_with(
                '{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}')
        return tag

    @override
    def _get_tweet_poll(self, tweet: Tag) -> str:
        card_poll: Tag | None = tweet.select_one('div[data-testid="cardPoll"]')

        poll_txt: str = ''
        if card_poll is not None:
            polls: list[tuple[str, str]] = []
            max_ratio: float = 0.
            leader_idx: int = 0
            tweet_lis = card_poll.select('li')
            for i, li in enumerate(tweet_lis):
                poll_data: ResultSet[Tag] = li.select('div > span')
                text_tag = poll_data[0]
                ratio_tag = poll_data[1]
                ratio: str = ratio_tag.text
                float_ratio: float = float(ratio[0:-1])
                if max_ratio < float_ratio:
                    leader_idx = i
                    max_ratio = float_ratio
                polls.append((text_tag.text, ratio))

            for i, poll_result in enumerate(polls):
                if i == leader_idx:
                    poll_txt += (
                        '<br>\n'
                        '&nbsp; <span style="display: inline-block; '
                        'width: 30em; background: linear-gradient('
                        'to right, '
                        f'rgba(29, 155, 240, 0.58) 0 {poll_result[1]}, '
                        f'transparent {poll_result[1]} 100%); '
                        'font-weight: bold;">'
                    ) + poll_result[1] + ' ' + poll_result[0] + '</span>'
                else:
                    poll_txt += (
                        '<br>\n'
                        '&nbsp; <span style="display: inline-block; '
                        'width: 30em; background: linear-gradient('
                        'to right, '
                        f'rgb(207, 217, 222) 0 {poll_result[1]}, '
                        f'transparent {poll_result[1]} 100%);">'
                    ) + poll_result[1] + ' ' + poll_result[0] + '</span>'

            votes_count_tag: Tag | None = card_poll.select_one(
                'div[data-testid="cardPoll"] > div')
            assert votes_count_tag is not None
            poll_txt += '<br>\n&nbsp; <span style="font-size: small;">' \
                + votes_count_tag.text + '</span>'

        return poll_txt

    @staticmethod
    def _retrieve_emojis(tag: Tag) -> Tag:
        """絵文字を画像タグからUnicodeに戻す。

        Args:
            tag (Tag): 絵文字の含まれる可能性のあるタグ。

        Returns:
            Tag: 絵文字をUnicodeに戻したタグ。
        """
        img_tags: Final[ResultSet[Tag]] = tag.select(
            'img[new-cursrc^="https://abs-0.twimg.com/emoji"]')
        for img_tag in img_tags:
            alt_text: str | list[str] | None = img_tag.get('alt')
            assert isinstance(alt_text, str)
            img_tag.replace_with(alt_text)
        return tag

    @staticmethod
    def _concat_texts(*texts: str) -> str:
        """2つ以上のテキストを改行タグを挟んで結合する。

        Args:
            *texts: 入力テキスト。

        Returns:
            str: 結合されたテキスト。
        """
        return '\n'.join(filter(None, texts))

    def _get_tweet_from_archive(
        self,
        url_pairs: list[UrlTuple],
        accessor: AccessorHandler
    ) -> None:
        """魚拓からツイート本文を取得する。

        Args:
            url_pairs (list[UrlTuple]): URLとその魚拓URLのペアのリスト。
            accessor (AccessorHandler): アクセスハンドラ。
        """
        table_builder: Final[TableBuilder] = TableBuilder()

        for url_pair in url_pairs:
            page: str | None = accessor.request(url_pair.archive_url)
            assert page is not None
            article: Tag | None = BeautifulSoup(
                page, 'html.parser'
            ).select_one('article[tabindex="-1"]')
            # リツイート以外のURLを保存する
            if article is not None and article.select_one(
                    'span[data-testid="socialContext"]') is None:

                logger.debug(url_pair.url + 'を整形しますを')
                tweet_date: datetime = self._tweet_date(url_pair.url)
                table_builder.next_day_if_necessary(tweet_date)

                tweet_callinshow_template: str = (
                    TableBuilder.callinshowlink_url(
                        url_pair.url, url_pair.archive_url.replace(
                            self.ARCHIVE_TODAY, self.ARCHIVE_TODAY_STANDARD)))

                # 本文
                try:
                    text_tag: Tag | None = article.select_one(
                        'div[dir="auto"]')
                    if text_tag is not None:
                        text_tag = self._retrieve_emojis(text_tag)
                        text: str = self._replace_links(text_tag,
                                                        accessor).text
                    else:
                        text: str = ''

                    # YouTube等のリンク
                    card_tag: Tag | None = article.select_one(
                        'div[aria-label="Play"]:not(div[role="link"] '
                        'div[aria-label="Play"])')
                    if card_tag is not None:
                        text = self._concat_texts(
                            text,
                            '{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}')

                    # 画像に埋め込まれた外部サイトへのリンク
                    article_tag: Tag | None = article.select_one(
                        'a[role="link"][aria-label] img:not(div[role="link"] '
                        'a[role="link"][aria-label] img)')
                    if article_tag is not None:
                        text = self._concat_texts(
                            text,
                            '{{Archive|1=リンクがあります。重複に注意して下さい|2=リンクがあります}}')

                    # 引用の有無のチェック
                    retweet_tag: Tag | None = article.select_one(
                        'div[role="link"]')
                    if retweet_tag is not None:
                        account_name_tag: Tag | None = (
                            retweet_tag.select_one(
                                'div[tabindex="-1"] > div > span:not(:has(> *))'))  # noqa: E501
                        assert account_name_tag is not None
                        text = self._concat_texts(
                            text, '{{Archive|1='
                            + account_name_tag.text
                            + 'のリツイートがあります|2=リツイートがあります}}')

                    # 画像の取得
                    image_list: tuple[str, ...] = self._parse_images(
                        article, accessor)
                    image_txt: str = ' '.join(map(
                        lambda t: f'[[ファイル:{t}|240px]]', image_list))
                    text = self._concat_texts(text, image_txt)
                    text = TableBuilder.escape_wiki_reserved_words(text)

                    # 投票の処理
                    poll_txt: str = self._get_tweet_poll(article)
                    text = self._concat_texts(text, poll_txt)

                    # バージョンの処理
                    possible_version_text_tags: ResultSet[Tag] = (
                        article.select(
                            'div > span > '
                            'span:not(:has(> *)):not(div[role="link"] div > '
                            'span > span:not(:has(> *)))'))
                    if len(list(filter(
                        lambda x: x.text
                            == 'There’s a new version of this post.',
                            possible_version_text_tags))) > 0:
                        tweet_callinshow_template += '([[新しいバージョンがあります|後の版→]])'

                except Exception as e:
                    # エラーが起きても止めない
                    logger.exception('エラーが発生してツイートが取得できませんでしたを', exc_info=True)
                    text = 'エラーが発生してツイートが取得できませんでした\n' + ''.join(
                        TracebackException.from_exception(e).format())
                table_builder.append(tweet_callinshow_template, text)
            else:
                logger.warning(url_pair.url + 'はリツイートなので飛ばすナリ。'
                               'URLリスト収集の時点でフィルタできなかった、これはいけない。')

        table_builder.dump_file()

    @override
    def execute(
        self,
        krsw: str | None = None,
        use_browser: bool = True,
        enable_javascript: bool = True
    ) -> None:
        """通信が必要な部分のロジック。

        Args:
            krsw (str | None, optional): `None` でない場合、名前が自動で \
                :const:`~CALLINSHOW` になる。
            use_browser (bool, optional): `True` ならSeleniumを利用する。\
                `False` ならRequestsのみでアクセスする。
            enable_javascript (bool, optional): SeleniumでJavaScriptを利用する場合は \
                `True`。
        """
        logger.info('Wikiに未掲載のツイートのURLを収集しますを')
        warnings.warn(
            '\033[31m'
            'このモードではURLリストの他にツイート本文も整形して取得するナリが、'
            '機能のテストが不十分であることと、TwitterのHTML構造は'
            'しばしば変更されることから、整形後の出力が正しいとは限らないナリ。'
            '機能を過信せず、自分の目で確かめてね(笑)、それはできるよね。'
            '\033[0m')
        # Seleniumドライバーを必ず終了するため、with文を利用する。
        with AccessorHandler(use_browser, enable_javascript) as accessor:
            # 実行前のチェック
            self._check_archive_instance(accessor)
            # 検索クエリの設定
            if not self._set_queries(accessor, krsw):
                sys.exit(1)
            # Wikiに既に掲載されているツイートのURLを取得
            self._get_tweet_urls_from_wiki(accessor)  # Wikiに接続できない時はここをコメントアウト

            # 未掲載のツイートのURLを取得する
            self._next_url(accessor, '', self._latest_id_digit)
            logger.debug(f'{len(self._url_list)} tweets are missed')

            # URL一覧ファイルのダンプ
            with Path(self.URL_LIST_FILENAME).open('w', encoding='utf-8') as f:
                for url_pair in self._url_list:
                    f.write(url_pair.url + '\n')
            logger.info('URL一覧手に入ったやで〜')

            # ツイート本文を取得する
            signal.signal(signal.SIGINT, self._signal_handler)
            try:
                self._get_tweet_from_archive(self._url_list, accessor)
            except Exception:
                # エラーが起きたらURLのリストをそのまま返す
                logger.exception(
                    '異常が起きたので終了するナリ。'
                    + self.URL_LIST_FILENAME + 'を元に自分でツイートを整形してほしいナリ')


if __name__ == '__main__':
    if sys.version_info < (3, 12):
        logger.critical('貴職のPythonのバージョン: ' + str(sys.version_info))
        sys.exit('Pythonのバージョンを3.12以上に上げて下さい')
    parser: Final[ArgumentParser] = ArgumentParser()
    parser.add_argument(
        '--krsw',
        type=str,
        nargs='?',
        const='',
        default=None,
        help=('指定すると、パカデブのツイートを取得上限数まで取得する。'
              '更に--search-unarchivedモードでこのオプションに引数を与えると、'
              'その文言が終わりにするツイートになる。'))
    parser.add_argument(
        '-n',
        '--no-browser',
        action='store_true',
        help='指定すると、Tor Browserを利用しない。')
    parser.add_argument(
        '-d',
        '--disable-script',
        action='store_true',
        help='指定すると、Tor BrowserでJavaScriptを利用しない。')
    parser.add_argument(
        '-u',
        '--search-unarchived',
        action='store_true',
        help=('指定すると、従来のNitterからツイートを収集するモードになる。廃止予定。'))
    args: Final[Namespace] = parser.parse_args()
    logger.debug('args: ' + str(args))

    twitter_archiver: Final[TwitterArchiver] = (
        TwitterArchiver() if args.search_unarchived else ArchiveCrawler()
    )
    twitter_archiver.execute(
        args.krsw,
        not args.no_browser,
        not args.disable_script)
‎

実行例

2月9日

https://twitter.com/CallinShow/status/1755640113332449372(魚拓)(後の版→)

リフォーム屋には要注意。
[@caa_shohishachoのリツイートがあります @caa_shohishachoのリツイートがあります]([リツイートがあります 魚拓])

https://twitter.com/CallinShow/status/1755640350868467967(魚拓)

怪しいリフォーム屋には要注意。
[@caa_shohishachoのリツイートがあります @caa_shohishachoのリツイートがあります]([リツイートがあります 魚拓])

https://twitter.com/CallinShow/status/1755725155291246635(魚拓)

メタは胡散臭い詐欺広告で稼いでいるんだから、何言ってるんだって話だろ。

コンテンツ監視に73億円 メタなど、EUの分担義務に異議申し立て
[リンクがあります。重複に注意して下さい リンクがあります。重複に注意して下さい]([リンクがあります 魚拓])

2月10日

https://twitter.com/CallinShow/status/1756265888423051340(魚拓)

[リンクがあります。重複に注意して下さい リンクがあります。重複に注意して下さい]([リンクがあります 魚拓])

AwichプロデュースHABUSHを最近飲んでいる。

ハブ酒ってうまいよね。
[リンクがあります。重複に注意して下さい リンクがあります。重複に注意して下さい]([リンクがあります 魚拓])

2月12日

https://twitter.com/CallinShow/status/1756939496283836878(魚拓)

これは必読の書だな。

誰が売国奴かがここに書いてある。
[リンクがあります。重複に注意して下さい リンクがあります。重複に注意して下さい]([リンクがあります 魚拓])

https://twitter.com/CallinShow/status/1756992905036927027(魚拓)

富士山
GGIXsWQaoAAzP-a.jpg

https://twitter.com/CallinShow/status/1756995624837476473(魚拓)

今日月食?

https://twitter.com/CallinShow/status/1757048622028657125(魚拓)

Who is the person who is impersonating me and making criminal threats in the Philippines. Dear Friipinos, this criminal is a person who does not have a job in Japan and just stays at home. We believe that he is not capable of actually committing a crime. I hope that the Philippine and Japanese governments will cooperate to arrest the criminal and that he will be severely punished.

2月13日

https://twitter.com/CallinShow/status/1757370912171814925(魚拓)

菊地翔は詐欺師だ。

くだらないインスタのメッセージは読む意味ないな。

お前が浮かび上がることはもうないし、お前はもうただの詐欺師。

承認欲求がそうさせるんだろうけどな。

お前は偏差値20くらいの文章書くんだよな。

2月14日

https://twitter.com/CallinShow/status/1757763709827776850(魚拓)

今日は一日布団の中にいた。

2月15日になるのを待って。

https://twitter.com/CallinShow/status/1757765745986203848(魚拓)

2月14日に意味なんかはない。

ただの365分の1。

そうだろ。

https://twitter.com/CallinShow/status/1757768530165125262(魚拓)

さあ、自分で買っておいたピエールマルコリーニでも食べるか。
[@CallinShowのリツイートがあります @CallinShowのリツイートがあります]([リツイートがあります 魚拓])

2月16日

https://twitter.com/CallinShow/status/1758151064858358179(魚拓)

ピエールマルコリーニだと思ったら、キットカットを食べていた。

ようやく今日自分を取り戻した。

https://twitter.com/CallinShow/status/1758155326850048038(魚拓)

[リンクがあります。重複に注意して下さい リンクがあります。重複に注意して下さい]([リンクがあります 魚拓])
#アロマティックトーク

最高。
[リンクがあります。重複に注意して下さい リンクがあります。重複に注意して下さい]([リンクがあります 魚拓])

https://twitter.com/CallinShow/status/1758155650230812748(魚拓)

高田馬場のだんご虫

ヒロシ・ヤング

も最高
[リンクがあります。重複に注意して下さい リンクがあります。重複に注意して下さい]([リンクがあります 魚拓])

https://twitter.com/CallinShow/status/1758164160402395259(魚拓)

[リンクがあります。重複に注意して下さい リンクがあります。重複に注意して下さい]([リンクがあります 魚拓])

NY市がSNS各社を訴える。

さすがアメリカだ。
[リンクがあります。重複に注意して下さい リンクがあります。重複に注意して下さい]([リンクがあります 魚拓])

https://twitter.com/CallinShow/status/1758166055506358335(魚拓)

[リンクがあります。重複に注意して下さい リンクがあります。重複に注意して下さい]([リンクがあります 魚拓])

https://twitter.com/CallinShow/status/1758166909412081993(魚拓)

[リンクがあります。重複に注意して下さい リンクがあります。重複に注意して下さい]([リンクがあります 魚拓])

気持ち悪いやつには巨額の損害賠償請求をして欲しい。

https://twitter.com/CallinShow/status/1758167792891937190(魚拓)

[リンクがあります。重複に注意して下さい リンクがあります。重複に注意して下さい]([リンクがあります 魚拓])

5億円の慰謝料って初めて見たな。

もっと法律構成できたんじゃないのかな。
[リンクがあります。重複に注意して下さい リンクがあります。重複に注意して下さい]([リンクがあります 魚拓])

https://twitter.com/CallinShow/status/1758171644496134615(魚拓)

[リンクがあります。重複に注意して下さい リンクがあります。重複に注意して下さい]([リンクがあります 魚拓])

素人じゃないな。

これは匂うな。
[リンクがあります。重複に注意して下さい リンクがあります。重複に注意して下さい]([リンクがあります 魚拓])

2月17日

https://twitter.com/CallinShow/status/1758537053602816220(魚拓)

オレの投稿に捨て垢で張り付く馬鹿はウケるな。

ブロックは楽しみ。

https://twitter.com/CallinShow/status/1758540840874778691(魚拓)

やりますよ
[@totonoijinseiのリツイートがあります @totonoijinseiのリツイートがあります]([リツイートがあります 魚拓])

https://twitter.com/CallinShow/status/1758542361486094374(魚拓)

[リンクがあります。重複に注意して下さい リンクがあります。重複に注意して下さい]([リンクがあります 魚拓])

ゼロプリ好き
[リンクがあります。重複に注意して下さい リンクがあります。重複に注意して下さい]([リンクがあります 魚拓])

https://twitter.com/CallinShow/status/1758557889927737554(魚拓)

逮捕、送検情報を、テレビが報じるときって、警察が広報すると決めた案件なんだ。
その中で被疑者の供述が出ている時は、警察からの情報提供を、記者クラブの記者が、記事にしているんだ。

https://twitter.com/CallinShow/status/1758569972891316469(魚拓)

[リンクがあります。重複に注意して下さい リンクがあります。重複に注意して下さい]([リンクがあります 魚拓])

ジャボリーミッキーのお姉さんの権利関係が気になる。

ちゃんとお姉さんに収益が落ちて欲しいな。
[リンクがあります。重複に注意して下さい リンクがあります。重複に注意して下さい]([リンクがあります 魚拓])

https://twitter.com/CallinShow/status/1758570299287843130(魚拓)

マ・ドンソク主演

このシリーズ最高。
[@hanzaitoshi3のリツイートがあります @hanzaitoshi3のリツイートがあります]([リツイートがあります 魚拓])

https://twitter.com/CallinShow/status/1758692104111542606(魚拓)

#NowReading
GGgg24Ja0AArevQ.jpg

https://twitter.com/CallinShow/status/1758702560230396318(魚拓)

はい!
[@totonoijinseiのリツイートがあります @totonoijinseiのリツイートがあります]([リツイートがあります 魚拓])

https://twitter.com/CallinShow/status/1758721953941291263(魚拓)

どの株買ったって自分で口外して煽っているのって、馬鹿引っ掛ける手口だよな。

胡散臭い仮想通貨売ってるときと変わりはない。

https://twitter.com/CallinShow/status/1758723779872870712(魚拓)

[リンクがあります。重複に注意して下さい リンクがあります。重複に注意して下さい]([リンクがあります 魚拓])

有隣堂しか知らない世界

ブッコローとか、この世界観天才。
[リンクがあります。重複に注意して下さい リンクがあります。重複に注意して下さい]([リンクがあります 魚拓])

https://twitter.com/CallinShow/status/1758724192646930697(魚拓)

弁護士になっただけで逆転できる人生はない。

何をしていくかだよ。
[@bengo4topicsのリツイートがあります @bengo4topicsのリツイートがあります]([リツイートがあります 魚拓])

https://twitter.com/CallinShow/status/1758724799046819840(魚拓)

昔こうだったから今◯◯になった、はい、立志伝

みたいなのって職業に貴賎がある意識の表れがある気がするからオレは好きではない。

https://twitter.com/CallinShow/status/1758814041370431746(魚拓)

明日はフェブラリーステークス

勝つのは?

長岡騎手勝って欲しいな。
  19.5% オメガギネス
  23.6% ウィルソンテソーロ
  29.3% ドゥラエレーデ
  27.6% ガイアフォース
  123 votes·Final results