「利用者:夜泣き/スクリプト」の版間の差分
ナビゲーションに移動
検索に移動
→コード: v4.0.5 動画を取得できるnitterインスタンスに修正
>Fet-Fe (→コード: v4.0.4 SeleniumAccessor.quit()がself._driverの未定義時にエラーを吐かないよう修正) |
>Fet-Fe (→コード: v4.0.5 動画を取得できるnitterインスタンスに修正) |
||
7行目: | 7行目: | ||
"""Twitter自動収集スクリプト | """Twitter自動収集スクリプト | ||
ver4.0. | ver4.0.5 2023/10/1恒心 | ||
当コードは恒心停止してしまった https://rentry.co/7298g の降臨ショーツイート自動収集スクリプトの復刻改善版です | 当コードは恒心停止してしまった https://rentry.co/7298g の降臨ショーツイート自動収集スクリプトの復刻改善版です | ||
32行目: | 32行目: | ||
* Pythonのバージョンは3.11以上 | * Pythonのバージョンは3.11以上 | ||
* 環境は玉葱前提です。 | * 環境は玉葱前提です。 | ||
* Tor Browserを入れておくか、torコマンドでプロキシを立てておくことが必要です。 | |||
* Whonix-Workstation, MacOSで動作確認済 | * Whonix-Workstation, MacOSで動作確認済 | ||
39行目: | 42行目: | ||
* requestsも環境によっては入っていないかもしれない | * requestsも環境によっては入っていないかもしれない | ||
* $ pip install bs4 requests PySocks selenium | * ``$ python3 -m pip install bs4 requests PySocks selenium`` | ||
* 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.org/wiki/利用者・トーク:夜泣き#利用者:夜泣き/スクリプトについて>`_ | |||
""" | """ | ||
59行目: | 63行目: | ||
from collections.abc import Callable | from collections.abc import Callable | ||
from datetime import datetime | from datetime import datetime | ||
from enum import Enum | |||
from logging import Logger, getLogger | from logging import Logger, getLogger | ||
from re import Match | from re import Match | ||
74行目: | 79行目: | ||
WebDriverException) | WebDriverException) | ||
from selenium.webdriver.common.by import By | from selenium.webdriver.common.by import By | ||
from selenium.webdriver.firefox.options import Options | from selenium.webdriver.firefox.options import Options as FirefoxOptions | ||
from selenium.webdriver.support import expected_conditions as ec | from selenium.webdriver.support import expected_conditions as ec | ||
from selenium.webdriver.support.wait import WebDriverWait | from selenium.webdriver.support.wait import WebDriverWait | ||
128行目: | 133行目: | ||
class RequestsAccessor(AbstractAccessor): | class RequestsAccessor(AbstractAccessor): | ||
""" | """RequestsモジュールでWebサイトに接続するためのクラス。 | ||
""" | """ | ||
HEADERS: Final[dict[str, str]] = { | HEADERS: Final[dict[str, str]] = { | ||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; rv: | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/118.0' | ||
} | } | ||
"""Final[dict[str, str]]: HTTPリクエスト時のヘッダ。 | """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プロキシの設定。 | |||
""" | """ | ||
142行目: | 154行目: | ||
} | } | ||
"""Final[dict[str, str]]: torコマンドを起動しているときのHTTPプロキシの設定。 | """Final[dict[str, str]]: torコマンドを起動しているときのHTTPプロキシの設定。 | ||
""" | """ | ||
163行目: | 168行目: | ||
def _execute(self, | def _execute(self, | ||
url: str, | url: str, | ||
proxies: dict[str, | proxies: dict[str, str] | None) -> requests.models.Response: | ||
"""引数のURLにRequestsモジュールでHTTP接続する。 | |||
""" | |||
Args: | Args: | ||
url str: 接続するURL。 | url (str): 接続するURL。 | ||
proxies | proxies (dict[str, str] | None): 接続に利用するプロキシ。 | ||
デフォルトでは :func:`~_choose_tor_proxies` で設定した値を利用する。 | |||
Raises: | Raises: | ||
requests.exceptions.ConnectionError: Torで接続ができないなど、接続のエラー。 | requests.exceptions.ConnectionError: Torで接続ができないなど、接続のエラー。 | ||
AccessError: ステータスコードが200でない場合のエラー。 | AccessError: ステータスコードが200でない場合のエラー。 | ||
Returns: | |||
requests.models.Response: レスポンスのオブジェクト。 | |||
""" | """ | ||
sleep(self.WAIT_TIME) # DoS対策で待つ | sleep(self.WAIT_TIME) # DoS対策で待つ | ||
197行目: | 201行目: | ||
def get(self, | def get(self, | ||
url: str, | url: str, | ||
proxies: dict[str, | proxies: dict[str, str] | None = None) -> str: | ||
"""引数のURLにRequestsモジュールでHTTP接続する。 | |||
""" | |||
Args: | Args: | ||
url str: 接続するURL。 | url (str): 接続するURL。 | ||
proxies | proxies (dict[str, str] | None, optional): 接続に利用するプロキシ。 | ||
デフォルトでは :func:`~_choose_tor_proxies` で設定した値を利用する。 | |||
Raises: | Raises: | ||
requests.exceptions.ConnectionError: Torで接続ができないなど、接続のエラー。 | requests.exceptions.ConnectionError: Torで接続ができないなど、接続のエラー。 | ||
AccessError: ステータスコードが200でない場合のエラー。 | AccessError: ステータスコードが200でない場合のエラー。 | ||
Returns: | |||
str: レスポンスのHTML。 | |||
""" | """ | ||
try: | try: | ||
221行目: | 225行目: | ||
Args: | Args: | ||
url str: | url (str): 接続するURL。 | ||
Raises: | Raises: | ||
requests.exceptions.ConnectionError: Torで接続ができないなど、接続のエラー。 | requests.exceptions.ConnectionError: Torで接続ができないなど、接続のエラー。 | ||
AccessError: ステータスコードが200でない場合のエラー。 | AccessError: ステータスコードが200でない場合のエラー。 | ||
Returns: | |||
bytes | None: 画像のバイナリ。画像でなければNone。 | |||
""" | """ | ||
try: | try: | ||
247行目: | 251行目: | ||
torコマンドのプロキシで接続できるなら :const:`~PROXIES_WITH_COMMAND`。 | torコマンドのプロキシで接続できるなら :const:`~PROXIES_WITH_COMMAND`。 | ||
いずれでもアクセスできなければ異常終了する。 | いずれでもアクセスできなければ異常終了する。 | ||
Raises: | |||
AccessError: :const:`~TOR_CHECK_URL` にアクセスできない場合のエラー。 | |||
Returns: | Returns: | ||
dict[str, str] | None | NoReturn: プロキシ情報。 | dict[str, str] | None | NoReturn: プロキシ情報。 | ||
""" | """ | ||
logger.info('Torのチェック中ですを') | logger.info('Torのチェック中ですを') | ||
282行目: | 286行目: | ||
return self.PROXIES_WITH_COMMAND | return self.PROXIES_WITH_COMMAND | ||
else: | else: | ||
raise | raise AccessError('サイトにTorのIPでアクセスできていないなりを') | ||
except requests.exceptions.ConnectionError as e: | except requests.exceptions.ConnectionError as e: | ||
logger.critical(e) | logger.critical(e) | ||
logger.critical('通信がTorのSOCKS proxyを経由していないなりを') | logger.critical('通信がTorのSOCKS proxyを経由していないなりを') | ||
exit(1) | sys.exit(1) | ||
@property | @property | ||
303行目: | 307行目: | ||
TOR_BROWSER_PATHS: MappingProxyType[str, str] = MappingProxyType({ | TOR_BROWSER_PATHS: MappingProxyType[str, str] = MappingProxyType({ | ||
'Windows': '', | 'Windows': 'C:\\Program Files\\Tor Browser\\Browser\\firefox.exe', | ||
'Darwin': '/Applications/Tor Browser.app/Contents/MacOS/firefox', | 'Darwin': '/Applications/Tor Browser.app/Contents/MacOS/firefox', | ||
'Linux': '/usr/bin/torbrowser' | 'Linux': '/usr/bin/torbrowser' | ||
}) | }) | ||
"""MappingProxyType[str, str]: OSごとのTor Browserのパス。 | """MappingProxyType[str, str]: OSごとのTor Browserのパス。 | ||
""" | """ | ||
WAIT_TIME_FOR_INIT: Final[int] = 15 | WAIT_TIME_FOR_INIT: Final[int] = 15 | ||
"""Final[int]: 最初のTor接続時の待機時間。 | """Final[int]: 最初のTor接続時の待機時間。 | ||
""" | |||
WAIT_TIME_FOR_RECAPTCHA: Final[int] = 10000 | |||
"""Final[int]: reCAPTCHAのための待機時間。 | |||
""" | """ | ||
323行目: | 328行目: | ||
Args: | Args: | ||
enable_javascript bool: JavaScriptを有効にするかどうか。reCAPTCHA対策に必要。 | enable_javascript (bool): JavaScriptを有効にするかどうか。reCAPTCHA対策に必要。 | ||
""" | """ | ||
self._javascript_enabled: bool = enable_javascript | self._javascript_enabled: bool = enable_javascript | ||
options: | options: FirefoxOptions = FirefoxOptions() | ||
options.binary_location = self.TOR_BROWSER_PATHS[platform.system()] | options.binary_location = self.TOR_BROWSER_PATHS[platform.system()] | ||
353行目: | 358行目: | ||
# Torの接続が完了するまで待つ | # Torの接続が完了するまで待つ | ||
wait_init.until(ec.url_contains('about:blank')) # type: ignore | wait_init.until(ec.url_contains('about:blank')) # type: ignore | ||
except BaseException: | except BaseException: | ||
self.quit() | self.quit() | ||
363行目: | 365行目: | ||
"""Seleniumドライバを終了する。 | """Seleniumドライバを終了する。 | ||
""" | """ | ||
if hasattr(self, ' | if hasattr(self, '_driver'): | ||
self._driver.quit() | self._driver.quit() | ||
384行目: | 386行目: | ||
WebDriverWait( | WebDriverWait( | ||
self._driver, | self._driver, | ||
self.WAIT_TIME_FOR_RECAPTCHA).until( # type: ignore | |||
ec.staleness_of( | ec.staleness_of( | ||
self._driver.find_element( | self._driver.find_element( | ||
402行目: | 404行目: | ||
Args: | Args: | ||
url str: 接続するURL。 | url (str): 接続するURL。 | ||
Raises: | |||
AccessError: 通信に失敗した場合のエラー。 | |||
Returns: | Returns: | ||
437行目: | 442行目: | ||
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. | self._selenium_accessor: SeleniumAccessor | None = SeleniumAccessor( | ||
enable_javascript) if use_browser else None | enable_javascript) if use_browser else None | ||
self. | self._requests_accessor: RequestsAccessor = RequestsAccessor() | ||
def __enter__(self) -> Self: | def __enter__(self) -> Self: | ||
463行目: | 468行目: | ||
traceback (TracebackType | None): コンテキスト内で例外を吐いた場合のトレースバック。 | traceback (TracebackType | None): コンテキスト内で例外を吐いた場合のトレースバック。 | ||
""" | """ | ||
if self. | if self._selenium_accessor is not None: | ||
self. | self._selenium_accessor.quit() | ||
def request_once(self, url: str) -> str: | def request_once(self, url: str) -> str: | ||
470行目: | 475行目: | ||
Args: | Args: | ||
url str: 接続するURL。 | url (str): 接続するURL。 | ||
Returns: | Returns: | ||
482行目: | 487行目: | ||
""" | """ | ||
try: | try: | ||
if self. | if self._selenium_accessor is not None: | ||
return self. | return self._selenium_accessor.get(url) | ||
else: | else: | ||
return self. | return self._requests_accessor.get(url) | ||
except AccessError: | except AccessError: | ||
raise | raise | ||
491行目: | 496行目: | ||
def _request_with_callable(self, | def _request_with_callable(self, | ||
url: str, | url: str, | ||
request_callable: Callable[[str], | request_callable: Callable[[str], Any] | ||
) -> Any | None: | |||
"""request_callableの実行を :const:`~LIMIT_N_REQUESTS` で定義された回数まで試す。 | """request_callableの実行を :const:`~LIMIT_N_REQUESTS` で定義された回数まで試す。 | ||
499行目: | 504行目: | ||
Args: | Args: | ||
url str: 接続するURL | url (str): 接続するURL | ||
request_callable Callable[[str], Any]: 1回リクエストを行うメソッド。 | request_callable (Callable[[str], Any]): 1回リクエストを行うメソッド。 | ||
Returns: | Returns: | ||
526行目: | 531行目: | ||
Args: | Args: | ||
url str: 接続するURL | url (str): 接続するURL | ||
Returns: | Returns: | ||
537行目: | 542行目: | ||
def request_with_requests_module(self, url: str) -> str | None: | def request_with_requests_module(self, url: str) -> str | None: | ||
""" | """RequestsモジュールでのHTTP接続を :const:`~LIMIT_N_REQUESTS` で定義された回数まで試す。 | ||
成功すると結果を返す。 | 成功すると結果を返す。 | ||
543行目: | 548行目: | ||
Args: | Args: | ||
url str: 接続するURL | url (str): 接続するURL | ||
Returns: | Returns: | ||
551行目: | 556行目: | ||
失敗かどうかは呼出側で要判定 | 失敗かどうかは呼出側で要判定 | ||
""" | """ | ||
return self._request_with_callable(url, self. | return self._request_with_callable(url, self._requests_accessor.get) | ||
def request_image(self, url: str) -> bytes | None: | def request_image(self, url: str) -> bytes | None: | ||
""" | """Requestsモジュールで画像ファイルを取得する。 | ||
成功すると結果を返す。 | |||
接続失敗が何度も起きるとNoneを返す。 | |||
Args: | Args: | ||
url | url (str): 接続するURL | ||
Returns: | Returns: | ||
bytes | None: レスポンスのバイト列。接続失敗が何度も起きるとNoneを返します。 | bytes | None: レスポンスのバイト列。接続失敗が何度も起きるとNoneを返します。 | ||
Note: | |||
失敗かどうかは呼出側で要判定 | |||
""" | """ | ||
return self._request_with_callable(url, | return self._request_with_callable(url, | ||
self. | self._requests_accessor.get_image) | ||
@property | @property | ||
575行目: | 583行目: | ||
dict[str, str] | None: RequestsAccessorオブジェクトのプロキシ設定。 | dict[str, str] | None: RequestsAccessorオブジェクトのプロキシ設定。 | ||
""" | """ | ||
return self. | return self._requests_accessor.proxies | ||
class TableBuilder: | class TableBuilder: | ||
"""Wikiの表を組み立てるためのクラス。 | |||
""" | |||
def __init__(self, date: datetime) -> None: | def __init__(self, date: datetime) -> None: | ||
"""コンストラクタ。 | |||
Args: | |||
date (datetime): 記録するツイートの最新日付。 | |||
""" | |||
self._tables: list[str] = [''] | self._tables: list[str] = [''] | ||
self._count: int = 0 # 記録数 | self._count: int = 0 # 記録数 | ||
586行目: | 601行目: | ||
@property | @property | ||
def count(self) -> int: | def count(self) -> int: | ||
"""表に追加したツイートの件数を返す。 | |||
Returns: | |||
int: 表に追加したツイートの件数。 | |||
""" | |||
return self._count | return self._count | ||
def append(self, | def append(self, tweet_archive_template: str, text: str) -> None: | ||
self._tables[ | """ツイートを表に追加する。 | ||
Args: | |||
archived_tweet_url (str): ツイートのURLをCallinshowLinkテンプレートに入れたもの。 | |||
text (str): ツイートの本文。 | |||
""" | |||
self._tables[-1] = '!' + tweet_archive_template + '\n|-\n|\n' \ | |||
+ text \ | + text \ | ||
+ '\n|-\n' \ | + '\n|-\n' \ | ||
+ self._tables[ | + self._tables[-1] | ||
self._count += 1 | self._count += 1 | ||
def dump_file(self) -> | def dump_file(self) -> None: | ||
""" | """Wikiテーブルをファイル出力する。 | ||
""" | """ | ||
self._next_day() | self._next_day() | ||
result_txt: Final[str] = '\n'.join(self._tables) | result_txt: Final[str] = '\n'.join(reversed(self._tables)) | ||
with codecs.open('tweet.txt', 'w', 'utf-8') as f: | with codecs.open('tweet.txt', 'w', 'utf-8') as f: | ||
f.write(result_txt) | f.write(result_txt) | ||
logger.info('テキストファイル手に入ったやで〜') | logger.info('テキストファイル手に入ったやで〜') | ||
def next_day_if_necessary(self, date: datetime | def next_day_if_necessary(self, date: datetime) -> None: | ||
"""引数dateがインスタンスの持っている日付より前の場合、日付更新処理をする。 | |||
if date.year != self._date.year or date.month != self._date.month or date.day != self._date.day: | 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) | self._next_day(date) | ||
def _next_day(self, date: datetime | None = None) -> None: | def _next_day(self, date: datetime | None = None) -> None: | ||
if self._tables[ | """Wikiテーブルに日付の見出しを付与する。 | ||
self._tables[ | |||
if | Args: | ||
self._tables[ | date (datetime | None, optional): 次に記録するツイートの日付。 | ||
'\n=== %#m月%#d日 ===\n') + self._tables[ | """ | ||
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日のツイートを取得完了ですを')) | logger.info(self._date.strftime('%#m月%#d日のツイートを取得完了ですを')) | ||
else: | else: | ||
self._tables[ | self._tables[-1] = self._date.strftime( | ||
'\n=== %-m月%-d日 ===\n') + self._tables[ | '\n=== %-m月%-d日 ===\n') + self._tables[-1] | ||
logger.info(self._date.strftime('%-m月%-d日のツイートを取得完了ですを')) | logger.info(self._date.strftime('%-m月%-d日のツイートを取得完了ですを')) | ||
if date is not None: | if date is not None: | ||
self._date = date | self._date = date | ||
if self._tables[-1]: | |||
self._tables.append('') | |||
def _convert_to_text_table(self, text: str) -> str: | def _convert_to_text_table(self, text: str) -> str: | ||
""" | """Wikiでテーブル表示にするためのヘッダとフッタをつける。 | ||
Args: | Args: | ||
text str: ヘッダとフッタがないWikiテーブル。 | text (str): ヘッダとフッタがないWikiテーブル。 | ||
Returns: | Returns: | ||
643行目: | 678行目: | ||
Args: | Args: | ||
text str: ツイートの文字列。 | text (str): ツイートの文字列。 | ||
Returns: | Returns: | ||
652行目: | 687行目: | ||
Args: | Args: | ||
text str: ツイートの文字列。 | text (str): ツイートの文字列。 | ||
Returns: | Returns: | ||
661行目: | 696行目: | ||
while i < len(text): | while i < len(text): | ||
if is_in_archive_template: | if is_in_archive_template: | ||
if text[i:i + | if text[i:i + len('}}')] == '}}': | ||
is_in_archive_template = False | is_in_archive_template = False | ||
i += | i += len('}}') | ||
else: | else: | ||
if (text[i:i + | if (text[i:i + len('{{Archive|')] == '{{Archive|' | ||
or text[i:i + | or text[i:i + len('{{Archive|')] == '{{archive|'): | ||
is_in_archive_template = True | is_in_archive_template = True | ||
i += | i += len('{{Archive|') | ||
elif text[i:i + | elif text[i:i + len('https://')] == 'https://': | ||
text = text[:i] + \ | text = text[:i] + \ | ||
'<nowiki>https://</nowiki>' + text[i + | '<nowiki>https://</nowiki>' + \ | ||
i += | text[i + len('https://'):] | ||
elif text[i:i + | i += len('<nowiki>https://</nowiki>') | ||
elif text[i:i + len('http://')] == 'http://': | |||
text = text[:i] + \ | text = text[:i] + \ | ||
'<nowiki>http://</nowiki>' + text[i + | '<nowiki>http://</nowiki>' + \ | ||
i += | text[i + len('http://'):] | ||
i += len('<nowiki>http://</nowiki>') | |||
i += 1 | i += 1 | ||
return text | return text | ||
703行目: | 740行目: | ||
Args: | Args: | ||
url str: ラップするURL。 | url (str): ラップするURL。 | ||
archive_url str: ラップするURLの魚拓のURL。 | archive_url (str): ラップするURLの魚拓のURL。 | ||
text str | None: ArchiveテンプレートでURLの代わりに表示する文字列。 | text (str | None, optional): ArchiveテンプレートでURLの代わりに表示する文字列。 | ||
Returns: | Returns: | ||
721行目: | 758行目: | ||
Args: | Args: | ||
url str: ラップするURL。 | url (str): ラップするURL。 | ||
archive_url str: ラップするURLの魚拓のURL。 | archive_url (str): ラップするURLの魚拓のURL。 | ||
Returns: | Returns: | ||
728行目: | 765行目: | ||
""" | """ | ||
return '{{CallinShowLink|1=' + url + '|2=' + archived_url + '}}' | return '{{CallinShowLink|1=' + url + '|2=' + archived_url + '}}' | ||
class FfmpegStatus(Enum): | |||
"""ffmpegでの動画保存ステータス。 | |||
""" | |||
MP4 = 1 | |||
"""mp4の取得に成功したときのステータス。 | |||
""" | |||
TS = 2 | |||
"""tsの取得までは成功したが、mp4への変換に失敗したときのステータス。 | |||
""" | |||
FAILED = 3 | |||
"""m3u8からtsの取得に失敗したときのステータス。 | |||
""" | |||
737行目: | 788行目: | ||
""" | """ | ||
NITTER_INSTANCE: Final[str] = ' | NITTER_INSTANCE: Final[str] = 'http://nitter.pjsfkvpxlinjamtawaksbnnaqs2fc2mtvmozrzckxh7f3kis6yea25ad.onion/' | ||
"""Final[str]: Nitterのインスタンス。 | """Final[str]: Nitterのインスタンス。 | ||
745行目: | 796行目: | ||
末尾にスラッシュ必須。 | 末尾にスラッシュ必須。 | ||
インスタンスによっては画像の取得ができない。 | |||
Tor用のインスタンスでないと動画の取得ができない。 | |||
""" | """ | ||
753行目: | 804行目: | ||
ページにアクセスして魚拓があるか調べるのにはonion版のarchive.todayを使用。 | ページにアクセスして魚拓があるか調べるのにはonion版のarchive.todayを使用。 | ||
Note: | Note: | ||
末尾にスラッシュ必須。 | 末尾にスラッシュ必須。 | ||
771行目: | 823行目: | ||
Note: | Note: | ||
末尾にスラッシュ必須。 | 末尾にスラッシュ必須。 | ||
""" | |||
TWITTER_MEDIA_URL: Final[str] = 'https://pbs.twimg.com/media/' | |||
"""Final[str]: TwitterのメディアのURL。 | |||
""" | """ | ||
815行目: | 871行目: | ||
""" | """ | ||
self._check_slash() # スラッシュが抜けてないかチェック | self._check_slash() # スラッシュが抜けてないかチェック | ||
self._has_ffmpeg: bool = self._check_ffmpeg() # ffmpegがあるかチェック | |||
def _set_queries(self, accessor: AccessorHandler, krsw: bool) -> None: | def _set_queries(self, accessor: AccessorHandler, krsw: bool) -> None: | ||
823行目: | 880行目: | ||
Args: | Args: | ||
accessor AccessorHandler: アクセスハンドラ | accessor (AccessorHandler): アクセスハンドラ | ||
krsw bool: | krsw (bool): Trueの場合、名前が :const:`~CALLINSHOW` になり、 | ||
クエリと終わりにするツイートが無しになる。 | |||
""" | """ | ||
833行目: | 891行目: | ||
# 検索クエリとページ取得 | # 検索クエリとページ取得 | ||
self. | self._query_strs: list[str] = [] | ||
if krsw: | if krsw: | ||
logger.info('クエリは自動的になしにナリます') | logger.info('クエリは自動的になしにナリます') | ||
842行目: | 900行目: | ||
+ self.TWEETS_OR_REPLIES)) | + self.TWEETS_OR_REPLIES)) | ||
if page_optional is None: | if page_optional is None: | ||
self. | self._on_fail() | ||
self._page: str = page_optional | self._page: str = page_optional | ||
860行目: | 918行目: | ||
def _check_slash(self) -> None | NoReturn: | def _check_slash(self) -> None | NoReturn: | ||
""" | """URLの最後にスラッシュが付いていなければエラーを出す。 | ||
Returns: | Returns: | ||
869行目: | 927行目: | ||
""" | """ | ||
if self.NITTER_INSTANCE[-1] != '/': | if self.NITTER_INSTANCE[-1] != '/': | ||
raise RuntimeError(' | raise RuntimeError('NITTER_INSTANCEの末尾をには/が必須です') | ||
if self.ARCHIVE_TODAY[-1] != '/': | if self.ARCHIVE_TODAY[-1] != '/': | ||
raise RuntimeError(' | raise RuntimeError('ARCHIVE_TODAYの末尾をには/が必須です') | ||
if self.ARCHIVE_TODAY_STANDARD[-1] != '/': | if self.ARCHIVE_TODAY_STANDARD[-1] != '/': | ||
raise RuntimeError(' | raise RuntimeError('ARCHIVE_TODAY_STANDARDの末尾をには/が必須です') | ||
if self.TWITTER_URL[-1] != '/': | if self.TWITTER_URL[-1] != '/': | ||
raise RuntimeError(' | raise RuntimeError('TWITTER_URLの末尾をには/が必須です') | ||
def _check_ffmpeg(self) -> bool: | |||
"""ffmpegがインストールされているかチェックする。 | |||
Returns: | |||
bool: ffmpegがインストールされているか。 | |||
""" | |||
return subprocess.run(['which', 'ffmpeg'], | |||
stdout=subprocess.DEVNULL, | |||
stderr=subprocess.DEVNULL).returncode == 0 | |||
def _check_nitter_instance( | def _check_nitter_instance( | ||
886行目: | 954行目: | ||
Args: | Args: | ||
accessor AccessorHandler: アクセスハンドラ | accessor (AccessorHandler): アクセスハンドラ | ||
Returns: | Returns: | ||
894行目: | 962行目: | ||
try: | try: | ||
accessor.request_once(self.NITTER_INSTANCE) | accessor.request_once(self.NITTER_INSTANCE) | ||
except AccessError as e: | except AccessError as e: | ||
logger.critical(e) | logger.critical(e) | ||
logger.critical('インスタンスが死んでますを') | logger.critical('インスタンスが死んでますを') | ||
exit(1) | sys.exit(1) | ||
logger.info('Nitter OK') | logger.info('Nitter OK') | ||
905行目: | 973行目: | ||
Args: | Args: | ||
accessor AccessorHandler: アクセスハンドラ | accessor (AccessorHandler): アクセスハンドラ | ||
Returns: | Returns: | ||
916行目: | 984行目: | ||
logger.critical(e) | logger.critical(e) | ||
logger.critical('インスタンスが死んでますを') | logger.critical('インスタンスが死んでますを') | ||
exit(1) | sys.exit(1) | ||
logger.info('archive.today OK') | logger.info('archive.today OK') | ||
925行目: | 993行目: | ||
Args: | Args: | ||
accessor AccessorHandler: アクセスハンドラ | accessor (AccessorHandler): アクセスハンドラ | ||
Returns: | Returns: | ||
tuple[str, ...] | NoReturn: | tuple[str, ...] | NoReturn: Invidiousのインスタンスのタプル。インスタンスが死んでいれば終了。 | ||
""" | """ | ||
logger.info('Invidiousのインスタンスリストを取得中ですを') | logger.info('Invidiousのインスタンスリストを取得中ですを') | ||
invidious_json: Final[str | invidious_json: Final[str | None] = ( | ||
accessor.request_with_requests_module( | |||
'https://api.invidious.io/instances.json') | |||
) | |||
if invidious_json is None: | if invidious_json is None: | ||
logger.critical('Invidiousが死んでますを') | logger.critical('Invidiousが死んでますを') | ||
exit(1) | sys.exit(1) | ||
instance_list: list[str] = [] | instance_list: list[str] = [] | ||
for instance_info in json.loads(invidious_json): | for instance_info in json.loads(invidious_json): | ||
953行目: | 1,023行目: | ||
Args: | Args: | ||
accessor AccessorHandler: | accessor (AccessorHandler): アクセスハンドラ。 | ||
Returns: | Returns: | ||
971行目: | 1,041行目: | ||
urljoin(self.NITTER_INSTANCE, account_str)) | urljoin(self.NITTER_INSTANCE, account_str)) | ||
if res is None: # リクエスト失敗判定 | if res is None: # リクエスト失敗判定 | ||
self. | self._on_fail() | ||
soup: BeautifulSoup = BeautifulSoup(res, 'html.parser') | soup: BeautifulSoup = BeautifulSoup(res, 'html.parser') | ||
if soup.title == self.NITTER_ERROR_TITLE: | if soup.title == self.NITTER_ERROR_TITLE: | ||
981行目: | 1,051行目: | ||
def _get_query(self) -> None: | def _get_query(self) -> None: | ||
"""検索クエリを標準入力から取得する。 | """検索クエリを標準入力から取得する。 | ||
""" | """ | ||
logger.info('検索クエリを入れるナリ。複数ある時は一つの塊ごとに入力するナリ。空欄を入れると検索開始ナリ。') | logger.info('検索クエリを入れるナリ。複数ある時は一つの塊ごとに入力するナリ。空欄を入れると検索開始ナリ。') | ||
989行目: | 1,057行目: | ||
# 空欄が押されるまでユーザー入力受付 | # 空欄が押されるまでユーザー入力受付 | ||
while query_input != '': | while query_input != '': | ||
self. | self._query_strs.append(query_input) | ||
query_input = input() | query_input = input() | ||
logger.info('クエリのピースが埋まっていく。') | logger.info('クエリのピースが埋まっていく。') | ||
def | def _on_fail(self) -> NoReturn: | ||
"""接続失敗時処理。 | """接続失敗時処理。 | ||
1,002行目: | 1,070行目: | ||
logger.critical('取得成功した分だけ発行しますを') | logger.critical('取得成功した分だけ発行しますを') | ||
self._table_builder.dump_file() | self._table_builder.dump_file() | ||
exit(1) | sys.exit(1) | ||
def _stop_word(self) -> str: | def _stop_word(self) -> str: | ||
1,011行目: | 1,079行目: | ||
""" | """ | ||
logger.info( | logger.info( | ||
f' | 'ここにツイートの本文を入れる!すると・・・・なんとそれを含むツイートで記録を中断する!(これは本当の仕様です。)' | ||
+ f'ちなみに、空欄だと{self.LIMIT_N_TWEETS}件まで終了しない。') | |||
end_str: Final[str] = input() | end_str: Final[str] = input() | ||
return end_str | return end_str | ||
1,022行目: | 1,091行目: | ||
Args: | Args: | ||
media_name str: 画像ファイル名。Nitter上のimgタグのsrc属性では、``/pic/media%2F`` に後続する。 | media_name (str): 画像ファイル名。Nitter上のimgタグのsrc属性では、 | ||
accessor AccessorHandler: | ``/pic/media%2F`` に後続する。 | ||
accessor (AccessorHandler): アクセスハンドラ。 | |||
Returns: | Returns: | ||
1,029行目: | 1,099行目: | ||
""" | """ | ||
os.makedirs(self.MEDIA_DIR, exist_ok=True) | os.makedirs(self.MEDIA_DIR, exist_ok=True) | ||
url: Final[str] = urljoin( | url: Final[str] = urljoin(self.TWITTER_MEDIA_URL, media_name) | ||
image_bytes: Final[bytes | None] = accessor.request_image(url) | image_bytes: Final[bytes | None] = accessor.request_image(url) | ||
if image_bytes is not None: | if image_bytes is not None: | ||
1,037行目: | 1,107行目: | ||
else: | else: | ||
return False | return False | ||
def _download_m3u8(self, | |||
media_path: str, | |||
ts_filename: str, | |||
mp4_filename: str, | |||
proxies: dict[str, str] | None) -> FfmpegStatus: | |||
"""ffmpegで動画をダウンロードし、:const:`~MEDIA_DIR` に保存する。 | |||
Args: | |||
media_path (str): 動画のm3u8ファイルのNitter上でのパス。 | |||
ts_filename (str): m3u8から取得したtsファイルのパス。 | |||
mp4_filename (str): tsファイルから変換したmp4ファイルのパス。 | |||
proxies (dict[str, str] | None): m3u8での通信に用いるプロキシ設定。 | |||
Returns: | |||
FfmpegStatus: ffmpegでの保存ステータス。 | |||
""" | |||
if proxies is not None: | |||
returncode: int = subprocess.run( | |||
[ | |||
'ffmpeg', '-y', '-http_proxy', | |||
'proxies["http"]', '-i', | |||
urljoin(self.NITTER_INSTANCE, media_path), | |||
'-c', 'copy', ts_filename | |||
], | |||
stdout=subprocess.DEVNULL).returncode | |||
else: | |||
returncode: int = subprocess.run( | |||
[ | |||
'ffmpeg', '-y', '-i', | |||
urljoin(self.NITTER_INSTANCE, media_path), | |||
'-c', 'copy', ts_filename | |||
], | |||
stdout=subprocess.DEVNULL).returncode | |||
# 取得成功したらtsをmp4に変換 | |||
if returncode == 0: | |||
ts2mp4_returncode: 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 | |||
def _tweet_date(self, tweet: Tag) -> datetime: | def _tweet_date(self, tweet: Tag) -> datetime: | ||
1,042行目: | 1,161行目: | ||
Args: | Args: | ||
tweet Tag: ツイート内容である ``.timeline-item`` タグを表すbeautifulsoup4タグ。 | tweet (Tag): ツイート内容である ``.timeline-item`` タグを表すbeautifulsoup4タグ。 | ||
Returns: | Returns: | ||
1,067行目: | 1,186行目: | ||
Args: | Args: | ||
tweet Tag: ツイート内容である ``.timeline-item`` タグを表すbeautifulsoup4タグ。 | tweet (Tag): ツイート内容である ``.timeline-item`` タグを表すbeautifulsoup4タグ。 | ||
accessor AccessorHandler: アクセスハンドラ | accessor (AccessorHandler): アクセスハンドラ | ||
Returns: | Returns: | ||
str: Wiki記法でのファイルへのリンクの文字列。 | str: Wiki記法でのファイルへのリンクの文字列。 | ||
""" | """ | ||
# 引用リツイート内のメディアを選択しないように.tweet-body直下の.attachmentsを選択 | |||
tweet_media: Tag | None = tweet.select_one( | tweet_media: Tag | None = tweet.select_one( | ||
'.tweet-body > .attachments') | '.tweet-body > .attachments') | ||
media_txt: str = '' | media_txt: str = '' | ||
if tweet_media is not None: | if tweet_media is not None: | ||
1,083行目: | 1,203行目: | ||
href: str | list[str] | None = image_a.get('href') | href: str | list[str] | None = image_a.get('href') | ||
assert isinstance(href, str) | assert isinstance(href, str) | ||
matched: Match[str] | None = re.search( | |||
r'%2F([^%]*\.jpg)|%2F([^%]*\.jpeg)|%2F([^%]*\.png)|%2F([^%]*\.gif)', | |||
href) | |||
assert matched is not None | |||
media_name: str = [ | media_name: str = [ | ||
group for group in | group for group in matched.groups() if group][0] | ||
media_list.append(f'[[ファイル:{media_name}|240px]]') | media_list.append(f'[[ファイル:{media_name}|240px]]') | ||
if self._download_media(media_name, accessor): | if self._download_media(media_name, accessor): | ||
logger.info( | logger.info( | ||
os.path.join( | os.path.join(self.MEDIA_DIR, media_name) | ||
+ ' をアップロードしなければない。') | + ' をアップロードしなければない。') | ||
else: | else: | ||
logger.info( | logger.info( | ||
urljoin( | urljoin(self.TWITTER_MEDIA_URL, media_name) | ||
+ ' をアップロードしなければない。') | + ' をアップロードしなければない。') | ||
except AttributeError: | except AttributeError: | ||
1,111行目: | 1,229行目: | ||
logger.error(f'{tweet_url}の画像が取得できませんでしたを 当職無能') | logger.error(f'{tweet_url}の画像が取得できませんでしたを 当職無能') | ||
media_list.append('[[ファイル:(画像の取得ができませんでした)|240px]]') | media_list.append('[[ファイル:(画像の取得ができませんでした)|240px]]') | ||
# ツイートの動画の取得。ffmpegが入っていなければ手動で取得すること | # ツイートの動画の取得。ffmpegが入っていなければ手動で取得すること | ||
for i, video_container in enumerate( | for i, video_container in enumerate( | ||
1,122行目: | 1,241行目: | ||
self.TWITTER_URL, | self.TWITTER_URL, | ||
re.sub('#[^#]*$', '', href)) # ツイートのURL作成 | re.sub('#[^#]*$', '', href)) # ツイートのURL作成 | ||
if not self._has_ffmpeg: | |||
logger.error(f'ffmpegがないため{tweet_url}の動画が取得できませんでしたを') | |||
media_list.append('[[ファイル:(動画の取得ができませんでした)|240px]]') | |||
continue | |||
# videoタグがない場合は取得できない | |||
video = video_container.select_one('video') | video = video_container.select_one('video') | ||
if video is None: | if video is None: | ||
1,128行目: | 1,254行目: | ||
continue | continue | ||
data_url: str | list[str] | None = video.get('data-url') | |||
assert isinstance(data_url, str) | |||
matched: Match[str] | None = re.search(r'[^\/]+$', data_url) | |||
assert matched is not None | |||
media_path: str = unquote(matched.group(0)) | |||
tweet_id: str = tweet_url.split('/')[-1] | |||
ts_filename: str = ( | |||
f'{os.path.join(self.MEDIA_DIR, tweet_id)}_{i}.ts' | |||
) | |||
mp4_filename: str = ( | |||
f'{os.path.join(self.MEDIA_DIR, tweet_id)}_{i}.mp4' | |||
) | |||
match self._download_m3u8( | |||
media_path, | |||
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]]') | media_list.append(f'[[ファイル:{tweet_id}_{i}.mp4|240px]]') | ||
case _: | |||
logger.error(f'{tweet_url}の動画が取得できませんでしたを 当職無能') | logger.error(f'{tweet_url}の動画が取得できませんでしたを 当職無能') | ||
media_list.append('[[ファイル:(動画の取得ができませんでした)|240px]]') | media_list.append('[[ファイル:(動画の取得ができませんでした)|240px]]') | ||
media_txt = ' '.join(media_list) | media_txt = ' '.join(media_list) | ||
return media_txt | return media_txt | ||
1,193行目: | 1,292行目: | ||
Args: | Args: | ||
tweet Tag: ツイート内容である ``.timeline-item`` タグを表すbeautifulsoup4タグ。 | tweet (Tag): ツイート内容である ``.timeline-item`` タグを表すbeautifulsoup4タグ。 | ||
accessor AccessorHandler: | accessor (AccessorHandler): アクセスハンドラ。 | ||
Returns: | Returns: | ||
1,220行目: | 1,319行目: | ||
Args: | Args: | ||
tweet Tag: ツイート内容である ``.timeline-item`` タグを表すbeautifulsoup4タグ。 | tweet (Tag): ツイート内容である ``.timeline-item`` タグを表すbeautifulsoup4タグ。 | ||
Returns: | Returns: | ||
1,242行目: | 1,341行目: | ||
assert poll_choice_option is not None | assert poll_choice_option is not None | ||
if 'leader' in poll_meter['class']: | if 'leader' in poll_meter['class']: | ||
poll_txt += | poll_txt += ('<br>\n' | ||
' <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>' | ratio + ' ' + poll_choice_option.text + '</span>' | ||
else: | else: | ||
poll_txt += | poll_txt += ('<br>\n' | ||
' <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>' | ratio + ' ' + poll_choice_option.text + '</span>' | ||
poll_txt += '<br>\n <span style="font-size: small;">' + \ | poll_txt += '<br>\n <span style="font-size: small;">' + \ | ||
1,258行目: | 1,368行目: | ||
Args: | Args: | ||
soup BeautifulSoup: Nitterのページを表すBeautifulSoupオブジェクト。 | soup (BeautifulSoup): Nitterのページを表すBeautifulSoupオブジェクト。 | ||
Returns: | Returns: | ||
1,275行目: | 1,385行目: | ||
timeline_item_list.append(item_or_list) | timeline_item_list.append(item_or_list) | ||
return timeline_item_list | return timeline_item_list | ||
def _archive_soup( | def _archive_soup( | ||
1,355行目: | 1,395行目: | ||
Args: | Args: | ||
tag Tag: ツイート内容の本文である ``.media-body`` タグを表すbeautifulsoup4タグ。 | tag (Tag): ツイート内容の本文である ``.media-body`` タグを表すbeautifulsoup4タグ。 | ||
accessor AccessorHandler: アクセスハンドラ | accessor (AccessorHandler): アクセスハンドラ | ||
""" | """ | ||
urls_in_tweet: Final[ResultSet[Tag]] = tag.find_all( | urls_in_tweet: Final[ResultSet[Tag]] = tag.find_all( | ||
1,415行目: | 1,455行目: | ||
Args: | Args: | ||
url str: ラップするURL。 | url (str): ラップするURL。 | ||
accessor AccessorHandler: アクセスハンドラ。 | accessor (AccessorHandler): アクセスハンドラ。 | ||
text str|None: ArchiveテンプレートでURLの代わりに表示する文字列。 | text (str | None, optional): ArchiveテンプレートでURLの代わりに表示する文字列。 | ||
Returns: | Returns: | ||
1,434行目: | 1,474行目: | ||
Args: | Args: | ||
url str: ラップするURL。 | url (str): ラップするURL。 | ||
accessor AccessorHandler: アクセスハンドラ。 | accessor (AccessorHandler): アクセスハンドラ。 | ||
Returns: | Returns: | ||
1,451行目: | 1,491行目: | ||
Args: | Args: | ||
url str: 魚拓を取得するURL。 | url (str): 魚拓を取得するURL。 | ||
accessor AccessorHandler: | accessor (AccessorHandler): アクセスハンドラ。 | ||
Returns: | Returns: | ||
1,461行目: | 1,501行目: | ||
quote( | quote( | ||
unquote(url), | unquote(url), | ||
safe='&=+?%')) | safe='&=+?%')) | ||
res: Final[str | None] = accessor.request(urljoin( | res: Final[str | None] = accessor.request(urljoin( | ||
self.ARCHIVE_TODAY, quote(unquote(url), safe='&=+?%'))) | self.ARCHIVE_TODAY, quote(unquote(url), safe='&=+?%'))) | ||
if res is None: # 魚拓接続失敗時処理 | if res is None: # 魚拓接続失敗時処理 | ||
# https://archive.ph/https%3A%2F%2Fxxxxxxxxの形で返される | |||
logger.error(archive_url + 'にアクセス失敗ナリ。出力されるテキストにはそのまま記載されるナリ。') | logger.error(archive_url + 'にアクセス失敗ナリ。出力されるテキストにはそのまま記載されるナリ。') | ||
else: | else: | ||
1,470行目: | 1,511行目: | ||
content: Tag | NavigableString | None = soup.find( | content: Tag | NavigableString | None = soup.find( | ||
id='CONTENT') # archive.todayの魚拓一覧ページの中身だけ取得 | id='CONTENT') # archive.todayの魚拓一覧ページの中身だけ取得 | ||
if content is None or content.get_text()[:len( | if (content is None or content.get_text()[:len(self.NO_ARCHIVE)] | ||
== self.NO_ARCHIVE): # 魚拓があるかないか判定 | |||
logger.warning(url + 'の魚拓がない。これはいけない。') | logger.warning(url + 'の魚拓がない。これはいけない。') | ||
else: | else: | ||
1,482行目: | 1,523行目: | ||
self.ARCHIVE_TODAY, self.ARCHIVE_TODAY_STANDARD) | self.ARCHIVE_TODAY, self.ARCHIVE_TODAY_STANDARD) | ||
return archive_url | return archive_url | ||
def _get_tweet(self, accessor: AccessorHandler) -> None | NoReturn: | |||
"""ページからツイート本文を ``TableBuilder`` インスタンスに収めていく。 | |||
ツイートの記録を中断するための文を発見するか、記録したツイート数が :const:`~LIMIT_N_TWEETS` 件に達したら終了する。 | |||
Args: | |||
accessor (AccessorHandler): アクセスハンドラ | |||
Returns: | |||
None | NoReturn: 終わりにするツイートを発見するか、記録件数が上限に達したら終了。 | |||
""" | |||
soup: Final[BeautifulSoup] = BeautifulSoup( | |||
self._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, | |||
re.sub('#[^#]*$', '', href)) | |||
date: datetime = self._tweet_date(tweet) | |||
self._table_builder.next_day_if_necessary(date) | |||
archived_tweet_url: 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._get_tweet_media(tweet, accessor) | |||
quote_txt: str = self._get_tweet_quote(tweet, accessor) | |||
poll_txt: str = self._get_tweet_poll(tweet) | |||
self._table_builder.append( | |||
archived_tweet_url, '<br>\n'.join( | |||
filter( | |||
None, | |||
[ | |||
self._table_builder.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() | |||
sys.exit(0) | |||
if self._table_builder.count >= self.LIMIT_N_TWEETS: | |||
logger.info(f'{self.LIMIT_N_TWEETS}件も記録している。もうやめにしませんか。') | |||
self._table_builder.dump_file() | |||
sys.exit(0) | |||
def _go_to_new_page(self, accessor: AccessorHandler) -> None | NoReturn: | def _go_to_new_page(self, accessor: AccessorHandler) -> None | NoReturn: | ||
1,489行目: | 1,608行目: | ||
Args: | Args: | ||
accessor AccessorHandler: アクセスハンドラ | accessor (AccessorHandler): アクセスハンドラ | ||
Returns: | |||
None | NoReturn: Nitterで次のページが無ければ終了。 | |||
""" | """ | ||
soup: Final[BeautifulSoup] = BeautifulSoup(self._page, 'html.parser') | soup: Final[BeautifulSoup] = BeautifulSoup(self._page, 'html.parser') | ||
show_mores: Final[ResultSet[Tag] | show_mores: Final[ResultSet[Tag]] = soup.find_all(class_='show-more') | ||
new_url: str = '' | |||
new_url: str = '' | |||
for show_more in show_mores: # show-moreに次ページへのリンクか前ページへのリンクがある | for show_more in show_mores: # show-moreに次ページへのリンクか前ページへのリンクがある | ||
if show_more.text != self.NEWEST: # 前ページへのリンクではないか判定 | if show_more.text != self.NEWEST: # 前ページへのリンクではないか判定 | ||
1,509行目: | 1,630行目: | ||
res: Final[str | None] = accessor.request(new_url) | res: Final[str | None] = accessor.request(new_url) | ||
if res is None: | if res is None: | ||
self. | self._on_fail() | ||
new_page_soup: Final[BeautifulSoup] = BeautifulSoup(res, 'html.parser') | new_page_soup: Final[BeautifulSoup] = BeautifulSoup(res, 'html.parser') | ||
if new_page_soup.find( | if new_page_soup.find( | ||
1,519行目: | 1,640行目: | ||
logger.info('急に残りツイートが無くなったな終了するか') | logger.info('急に残りツイートが無くなったな終了するか') | ||
self._table_builder.dump_file() | self._table_builder.dump_file() | ||
sys.exit(0) | |||
def execute(self, krsw: bool = False, use_browser: bool = True, | def execute(self, krsw: bool = False, use_browser: bool = True, | ||
1,525行目: | 1,647行目: | ||
Args: | Args: | ||
krsw bool: Trueの場合、名前が自動で :const:`~CALLINSHOW` | krsw (bool, optional): Trueの場合、名前が自動で :const:`~CALLINSHOW` になり、 | ||
use_browser bool: | クエリと終わりにするツイートが自動で無しになる。 | ||
enable_javascript bool: | use_browser (bool, optional): TrueならSeleniumを利用する。 | ||
FalseならRequestsのみでアクセスする。 | |||
enable_javascript (bool, optional): SeleniumでJavaScriptを利用する場合は | |||
True。 | |||
""" | """ | ||
# Seleniumドライバーを必ず終了するため、with文を利用する。 | # Seleniumドライバーを必ず終了するため、with文を利用する。 | ||
1,535行目: | 1,660行目: | ||
self._check_archive_instance(accessor) | self._check_archive_instance(accessor) | ||
# Invidiousのインスタンスリストの正規表現パターンを取得 | # Invidiousのインスタンスリストの正規表現パターンを取得 | ||
invidious_url_tuple: Final[tuple[str, ...] | invidious_url_tuple: Final[tuple[str, ...]] = ( | ||
self._invidious_instances(accessor) | |||
) | |||
self._invidious_pattern: re.Pattern[str] = re.compile( | self._invidious_pattern: re.Pattern[str] = re.compile( | ||
'|'.join(invidious_url_tuple)) | '|'.join(invidious_url_tuple)) | ||
1,553行目: | 1,679行目: | ||
sys.version_info.major == 3 and sys.version_info.minor < 11): | sys.version_info.major == 3 and sys.version_info.minor < 11): | ||
logger.critical('Pythonのバージョンを3.11以上に上げて下さい') | logger.critical('Pythonのバージョンを3.11以上に上げて下さい') | ||
exit(1) | sys.exit(1) | ||
parser: ArgumentParser = ArgumentParser() | parser: ArgumentParser = ArgumentParser() | ||
parser.add_argument( | parser.add_argument( |