「利用者:夜泣き/スクリプト」の版間の差分
ナビゲーションに移動
検索に移動
→コード: ツイートの画像を自動取得するため、勝手ながら改造させていただきました
>夜泣き 編集の要約なし |
>Fet-Fe (→コード: ツイートの画像を自動取得するため、勝手ながら改造させていただきました) |
||
6行目: | 6行目: | ||
''' | ''' | ||
ver2.1.0 2022/8/8恒心 | |||
当コードは恒心停止してしまったhttps://rentry.co/7298gの降臨ショーツイート自動収集スクリプトの復刻改善版です | 当コードは恒心停止してしまったhttps://rentry.co/7298gの降臨ショーツイート自動収集スクリプトの復刻改善版です | ||
前開発者との出会いに感謝 | 前開発者との出会いに感謝 | ||
22行目: | 24行目: | ||
・環境は玉葱前提です。 | ・環境は玉葱前提です。 | ||
・Whonix- | ・Whonix-Workstation, MacOSで動作確認済 | ||
・MacOSの場合はbrewでtorコマンドを導入し、実行 | |||
・PySocks, bs4はインストールしないと標準で入ってません | |||
・requestsも環境によっては入っていないかもしれない | ・requestsも環境によっては入っていないかもしれない | ||
・$ pip install bs4 requests | ・$ pip install bs4 requests PySocks | ||
・pipも入っていなければ$ sudo apt install pip | ・pipも入っていなければ$ sudo apt install pip | ||
・バグ報告はhttps://krsw-wiki.org/wiki/?curid=15799 | ・バグ報告はhttps://krsw-wiki.org/wiki/?curid=15799#利用者:夜泣き/スクリプトについて | ||
ーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーー | ーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーー | ||
34行目: | 37行目: | ||
#インポート類 | #インポート類 | ||
import sys | import sys | ||
import os | |||
import codecs | import codecs | ||
import re | import re | ||
import json | |||
from time import sleep | from time import sleep | ||
from typing import Final, Optional, Union, NoReturn, ClassVar | |||
from urllib.parse import quote, unquote | |||
import warnings | |||
import requests | import requests | ||
import bs4 | |||
from | from bs4 import BeautifulSoup | ||
##おそらくはツイートの内容によってMarkupResemblesLocatorWarningが吐き出されることがあるので無効化 | ##おそらくはツイートの内容によってMarkupResemblesLocatorWarningが吐き出されることがあるので無効化 | ||
warnings.simplefilter('ignore') | warnings.simplefilter('ignore') | ||
##nitterのインスタンス | class TwitterArchiver: | ||
##生きているのはhttps://github.com/zedeus/nitter/wiki/Instancesで確認 | #定数・設定類 | ||
##nitterのインスタンス | |||
##生きているのはhttps://github.com/zedeus/nitter/wiki/Instancesで確認 | |||
##末尾にスラッシュ必須 | |||
NITTER_INSTANCE: Final[str] = 'http://ibsboeui2im5o7dxnik3s5yghufumgy5abevtij5nbizequfpu4qi4ad.onion/' | |||
##archive.todayの魚拓 | |||
##実際にアクセスして魚拓があるか調べるのにはonionを使用 | |||
##末尾にスラッシュ必須 | |||
ARCHIVE_TODAY: Final[str] = 'http://archiveiya74codqgiixo33q62qlrqtkgmcitqx5u2oeqnmn5bpcbiyd.onion/' | |||
##archive.todayの魚拓 | |||
##記事の文章に使うのはクリアネット | |||
##末尾にスラッシュ必須 | |||
ARCHIVE_TODAY_STANDARD: Final[str] = 'https://archive.ph/' | |||
##twitterのURL | |||
##末尾にスラッシュ禁止 | |||
TWITTER_URL: Final[str] = 'https://twitter.com' | |||
##降臨ショーのユーザーネーム | |||
CALLINSHOW: Final[str] = 'CallinShow' | |||
##HTTPリクエストのタイムアウト秒数 | |||
REQUEST_TIMEOUT: Final[int] = 30 | |||
## | ##取得するツイート数の上限 | ||
LIMIT_N_TWEETS: Final[int] = 300 | |||
## | ##記録件数を報告するインターバル | ||
REPORT_INTERVAL: Final[int] = 5 | |||
## | ##HTTPリクエスト失敗時の再試行回数 | ||
LIMIT_N_REQUESTS: Final[int] = 5 | |||
## | ##HTTPリクエスト失敗時にさらに追加する待機時間 | ||
WAIT_TIME_FOR_ERROR: Final[int] = 4 | |||
## | ##HTTPリクエスト成功失敗関わらず待機時間 | ||
##1秒待つだけで行儀がいいクローラーだそうなので既定では1秒 | |||
##しかし日本のポリホーモは1秒待っていても捕まえてくるので注意 | |||
##https://ja.wikipedia.org/wiki/?curid=2187212 | |||
WAIT_TIME: Final[int] = 1 | |||
## | ##nitterのURLのドメインとユーザーネーム部分の接続部品 | ||
SEARCH_QUERY: Final[str] = 'search?q=from%3A' | |||
## | ##HTTPリクエスト時のユーザーエージェントとヘッダ | ||
HEADERS: Final[dict[str, str]] = { | |||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0' | |||
} | |||
## | ##HTTP | ||
PROXIES: Final[dict[str, str]] = { | |||
'http': 'socks5h://127.0.0.1:9050', | |||
'https': 'socks5h://127.0.0.1:9050' | |||
} | |||
## | ##nitterでユーザーがいなかったとき返ってくるページのタイトル | ||
## | ##万が一仕様変更で変わったとき用 | ||
NITTER_ERROR_TITLE: Final[str] = 'Error|nitter' | |||
## | ##archive.todayで魚拓がなかったときのレスポンス | ||
##万が一仕様変更で変わったとき用 | |||
NO_ARCHIVE: Final[str] = 'No results' | |||
## | ##nitterで次ページ遷移時のURLから抜け落ちてる部分 | ||
SEARCH: Final[str] = 'search' | |||
## | ##nitterの前ページ読み込み部分の名前 | ||
##万が一仕様変更で変わったとき用 | ##万が一仕様変更で変わったとき用 | ||
NEWEST: Final[str] = 'Load newest' | |||
## | ##画像などのツイートメディアをダウンロードするディレクトリ | ||
MEDIA_DIR: Final[str] = 'tweet_media' | |||
# | #関数類 | ||
def __init__(self, krsw: bool=False): | |||
self.txt_data = '' | |||
self.limit_count = 0 ##記録数 | |||
self._check_slash() ##スラッシュが抜けてないかチェック | |||
self._check_tor() ##Torが使えているかチェック | |||
self._check_instance() ##インスタンスが死んでないかチェック | |||
## | ##ユーザー名取得 | ||
if krsw: | |||
print('名前は自動的に' + self.CALLINSHOW + 'にナリます') | |||
name: Final[str] = self.CALLINSHOW | |||
else: | |||
name: Final[str] = self._get_name() | |||
# | ##検索クエリとページ取得 | ||
# | if krsw: | ||
print('クエリは自動的になしにナリます') | |||
self._page: str = self._request(self.NITTER_INSTANCE + self.SEARCH_QUERY + name) | |||
if self._page is None: | |||
self._fail() | |||
else: | |||
self._page: str = self._get_query(name) | |||
## | ##終わりにするツイート取得 | ||
if krsw: | |||
print('終わりにするツイートは自動的になしにナリます') | |||
self._stop: Final[str] = '' | |||
else: | |||
self._stop: Final[str] = self._stop_word() | |||
print() | |||
## | ##坂根輝美に場所を知らせます | ||
def _pickup_counter(self): | |||
def | print('ピックアップカウンター付近でふ') | ||
## | ##引数のURLにHTTP接続します | ||
def | ##失敗かどうかは呼出側で要判定 | ||
def _request_once(self, url: Final[str]) -> requests.models.Response: | |||
res = requests.get(url, timeout=self.REQUEST_TIMEOUT, headers=self.HEADERS, allow_redirects=False, proxies=self.PROXIES) | |||
sleep(self.WAIT_TIME) ##DoS対策で待つ | |||
return res | |||
## | ##HTTP接続を再試行回数まで試します | ||
def | ##成功すると結果を返します | ||
##接続失敗が何度も起きるとNoneを返します | |||
##呼出側で要None判定 | |||
def _request(self, url: Final[str]) -> Optional[requests.models.Response]: | |||
counter = 1 ##リクエスト挑戦回数を記録 | |||
while True: | |||
try: | |||
res = self._request_once(url) ##リクエスト | |||
res.raise_for_status() ##HTTPステータスコードが200番台以外でエラー発生 | |||
except requests.exceptions.RequestException as e: | |||
print(url + 'への通信失敗ナリ ' + f"{counter}/{self.LIMIT_N_REQUESTS}回") | |||
if counter < self.LIMIT_N_REQUESTS: ##エラー発生時上限まで再挑戦 | |||
counter += 1 ##現在の試行回数1回増やす | |||
sleep(self.WAIT_TIME_FOR_ERROR) ##失敗時は長めに待つ | |||
else: | |||
return None ##失敗したらNone返却し呼出側で対処してもらう | |||
else: | |||
return res ##リクエストの結果返す | |||
## | ##URLの最後にスラッシュ付いていなければ付ける | ||
def | ##Twitterだけスラッシュ付いていないほうが都合いいので抜く | ||
def _check_slash(self) -> Union[None, NoReturn]: | |||
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の末尾には/があってはいけません') | |||
## | ##Torが使えているかチェック | ||
def | def _check_tor(self) -> Union[None, NoReturn]: | ||
print('Torのチェック中ですを') | |||
res = self._request_once('https://check.torproject.org/api/ip') ##リクエスト | |||
is_tor = json.loads(res.text)['IsTor'] | |||
if is_tor: | |||
print('Tor OK') | |||
else: | |||
raise RuntimeError('通信がTorを経由していないなりを') | |||
# | ##nitterのインスタンスが生きているかチェック | ||
##死んでいたらそこで終了 | |||
##接続を一回しか試さない_request_onceを使っているのは | |||
##激重インスタンスが指定されたとき試行回数増やして偶然成功してそのまま実行されるのを躱すため | |||
def _check_instance(self) -> Union[None, NoReturn]: | |||
print("nitterのインスタンスチェック中ですを") | |||
try: | |||
res = self._request_once(self.NITTER_INSTANCE) ##リクエスト | |||
res.raise_for_status() ##HTTPステータスコードが200番台以外でエラー発生 | |||
except requests.exceptions.RequestException as e: ##エラー発生時は終了 | |||
print('インスタンスが死んでますを') | |||
exit() | |||
# | ##ツイート収集するユーザー名を取得 | ||
def | ##何も入力しないと尊師を指定するよう改良 | ||
def _get_name(self) -> Union[str, NoReturn]: | |||
while True: | |||
print('アカウント名を入れなければない。空白だと自動的に' + self.CALLINSHOW + 'になりますを') | |||
account_str = input() ##ユーザー入力受付 | |||
##空欄で降臨ショー | |||
if account_str == '': | |||
return self.CALLINSHOW | |||
else: | |||
res = self._request(self.NITTER_INSTANCE + account_str) ##リクエストして結果取得 | |||
if res is None : ##リクエスト失敗判定 | |||
self._fail() | |||
soup = BeautifulSoup(res.text, 'html.parser') ##beautifulsoupでレスポンス解析 | |||
if soup.title == self.NITTER_ERROR_TITLE: ##タイトルがエラーでないか判定 | |||
print(account_str + "は実在の人物ではありませんでした") ##エラー時ループに戻る | |||
else: | |||
print("最終的に出会ったのが@" + account_str + "だった。") | |||
return account_str ##成功時アカウント名返す | |||
# | ##検索クエリを取得 | ||
def | def _get_query(self, name: Final[str]) -> Union[requests.models.Response, NoReturn]: | ||
while True: | |||
print("検索クエリを入れるナリ。複数ある時は一つの塊ごとに入力するナリ。空欄を入れると検索開始ナリ。") | |||
# | print("例:「無能」→改行→「脱糞」→改行→「弟殺し」→改行→空欄で改行") | ||
query_str = [name] ##検索クエリ文字列をquery_strに収納。ユーザー名を先に含めておく | |||
query_input = input() ##ユーザー入力受付 | |||
##空欄が押されるまでユーザー入力受付 | |||
while query_input != '': | |||
query_str.append(quote(query_input)) | |||
query_input = input() | |||
res = self._request(self.NITTER_INSTANCE + self.SEARCH_QUERY + '+'.join(query_str)) ##リクエストして結果取得 | |||
if res is None : ##リクエスト失敗判定 | |||
self._fail() | |||
soup = BeautifulSoup(res.text, 'html.parser') ##beautifulsoupでレスポンス解析 | |||
##1件もないときはHTMLにtimeline-noneがあるから判定 | |||
if soup.find(class_='timeline-none') is None: | |||
print("クエリのピースが埋まっていく。") | |||
return res ##成功したリクエストのレスポンスを返す | |||
else: | |||
print("適切なクエリを入力することを切に望む。") | |||
## | ##接続失敗時処理 | ||
def | def _fail(self) -> NoReturn: | ||
print("接続失敗しすぎで強制終了ナリ") | |||
if self.txt_data != '': ##取得成功したデータがあれば発行 | |||
print("取得成功した分だけ発行しますを") | |||
self._make_txt() | |||
exit() ##終了 | |||
## | ##テキスト発行 | ||
def | def _make_txt(self) -> NoReturn: | ||
self.txt_data = '{|class="wikitable" style="text-align: left;"\n' + self.txt_data + '|}' ##wikiの表の最初と最後 | |||
##ファイル出力 | |||
with codecs.open('tweet.txt', 'w', 'utf-8') as f: | |||
f.write(self.txt_data) | |||
print("テキストファイル手に入ったやで〜") | |||
exit() ##終了 | |||
##記録を中断するツイート | |||
def _stop_word(self) -> str: | |||
print(f"ここにツイートの本文を入れる!すると・・・・なんと一致するツイートで記録を中断する!(これは本当の仕様です。)ちなみに、空欄だと{self.LIMIT_N_TWEETS}件まで終了しない。") | |||
end_str = input() ##ユーザー入力受付 | |||
return end_str | |||
def _mkdir(self): | |||
try: | |||
os.mkdir(self.MEDIA_DIR) | |||
except FileExistsError: | |||
pass | |||
## | ##Twitterの画像をダウンロード | ||
def _download_media(self, media_name) -> bool: | |||
self._mkdir() | |||
url = 'https://pbs.twimg.com/media/' + media_name | |||
res = self._request(url) | |||
if | if res is not None: | ||
if 'image' not in res.headers['content-type']: | |||
return False | |||
with open(self.MEDIA_DIR + '/' + media_name, "wb") as f: | |||
f.write(res.content) | |||
return True | |||
else: | |||
return False | |||
## | def _get_tweet_media(self, tweet: bs4.element.Tag) -> str: | ||
if | tweet_media = tweet.select_one('.tweet-body > .attachments') # 引用リツイート内のメディアを選択しないように.tweet-body直下の.attachmentsを選択 | ||
media_txt = '' | |||
if tweet_media is not None: | |||
for tweet_medium in tweet_media: | |||
# ツイートの画像の取得 | |||
if tweet_medium.select_one('.attachment.image a'): | |||
media_name = re.search(r'%2F([^%]*\.jpg)|%2F([^%]*\.jpeg)|%2F([^%]*\.png)|%2F([^%]*\.gif)', tweet_medium.select_one('.attachment.image a').get('href')).group(1) | |||
media_txt += f"<br>\n[[ファイル:{media_name}|240px]]" | |||
if self._download_media(media_name): | |||
print(self.MEDIA_DIR + '/' + media_name + ' をアップロードしなければない。') | |||
else: | |||
print('https://pbs.twimg.com/media/' + media_name + ' をアップロードしなければない。') | |||
# 動画についてはm3u8で落ちてきて面倒臭いため取得しない | |||
""" | |||
elif tweet_medium.select_one('.attachment.video-container video'): | |||
media_url = unquote(re.search(r'[^\/]+$', tweet_medium.select_one('.attachment.video-container video').get('data-url')).group(0)) | |||
print(media_url) | |||
""" | |||
return media_txt | |||
def _get_tweet_quote(self, tweet: bs4.element.Tag) -> str: | |||
tweet_quote = tweet.select_one('.tweet-body > .quote.quote-big') # 引用リツイートを選択 | |||
quote_txt = '' | |||
if tweet_quote is not None: | |||
link = tweet_quote.select_one('.quote-link').get('href') | |||
link = re.sub('#.*$', '', link) | |||
link = self.TWITTER_URL + link | |||
quote_txt = '<br>\n' + self._archive_url(link, link) | |||
return quote_txt | |||
## | #ページからツイート本文をself.txt_dataに収めていく | ||
if | def get_tweet(self) -> Union[None, NoReturn]: | ||
soup = BeautifulSoup(self._page.text, 'html.parser') ##beautifulsoupでレスポンス解析 | |||
tweets = soup.find_all(class_='timeline-item') ##一ツイートのブロックごとにリストで取得 | |||
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 | |||
tweet_url = self.TWITTER_URL + re.sub('#[^#]*$', '', tweet.find(class_='tweet-link').get('href')) ##ツイートのURL作成 | |||
archived_tweet_url = self._callinshowlink_url(tweet_url) ##ツイートURLをテンプレートCallinShowlinkに変化 | |||
tweet_content = tweet.find(class_='tweet-content media-body') ##ツイートの中身だけ取り出す | |||
tweet_content = self._archive_soup(tweet_content) ##ツイートの中身のリンクをテンプレートArchiveに変化 | |||
media_txt = self._get_tweet_media(tweet) ##ツイートに画像などのメディアを追加 | |||
quote_txt = self._get_tweet_quote(tweet) ##引用リツイートの場合、元ツイートを追加 | |||
self.txt_data = '!' + archived_tweet_url + '\n|-\n|\n' + tweet_content.get_text().replace('\n', '<br>\n').replace('#', '<nowiki>#</nowiki>') + quote_txt + media_txt + '\n|-\n' + self.txt_data ##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() | |||
##ツイートを取得し終えるまでループ | #soupをテンプレートArchiveに変化させる | ||
while True: | def _archive_soup(self, tag: bs4.element.Tag) -> BeautifulSoup: | ||
soup = BeautifulSoup(str(tag)) | |||
urls_in_tweet = soup.find_all('a') | |||
for url in urls_in_tweet: | |||
if re.match('^https?://', url.get('href')) is not None: ##先頭にhttpが付いていない物はハッシュタグの検索ページへのリンクなので処理しない | |||
newstr = soup.new_string(self._archive_url(url.get('href'), url.text)) ##テンプレートArchiveの文字列作成 | |||
url.replace_with(newstr) ##テンプレートArchiveに変化 | |||
return soup | |||
#URLをテンプレートArchiveに変化させる | |||
def _archive_url(self, url: Final[str], text: Final[str]) -> str: | |||
return '{{Archive|1=' + url + '|2=' + self._archive(url) + '|3=' + text + '}}' ##テンプレートArchiveの文字列返す | |||
#URLをテンプレートCallinShowlinkに変化させる | |||
def _callinshowlink_url(self, url: Final[str]) -> str: | |||
return '{{CallinShowLink|1=' + url + '|2=' + self._archive(url) + '}}' ##テンプレートCallinShowlinkの文字列返す | |||
##URLから魚拓返す | |||
def _archive(self, url: Final[str]) -> str: | |||
archive_url = self.ARCHIVE_TODAY_STANDARD + url ##wikiに載せるとき用URLで失敗するとこのままhttps://archive.ph/https://xxxxxxxxの形で返される | |||
res = self._request(self.ARCHIVE_TODAY + url) ##アクセス用URL使って結果を取得 | |||
if res is None : ##魚拓接続失敗時処理 | |||
print(archive_url + 'にアクセス失敗ナリ。出力されるテキストにはそのまま記載されるナリ。') | |||
else: | |||
soup = BeautifulSoup(res.text, 'html.parser') ##beautifulsoupでレスポンス解析 | |||
content = soup.find(id="CONTENT") ##archive.todayの魚拓一覧ページの中身だけ取得 | |||
if content.get_text()[:len(self.NO_ARCHIVE)] == self.NO_ARCHIVE: ##魚拓があるかないか判定 | |||
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) -> Union[None, NoReturn]: | |||
soup = BeautifulSoup(self._page.text, 'html.parser') ##beautifulsoupでレスポンス解析 | |||
showmores = soup.find_all(class_="show-more") | |||
for showmore in showmores: ##show-moreに次ページへのリンクか前ページへのリンクがある | |||
if showmore.text != self.NEWEST: ##前ページへのリンクではないか判定 | |||
newurl = self.NITTER_INSTANCE + self.SEARCH + showmore.a.get('href') ##直下のaタグのhrefの中身取ってURL頭部分と合体 | |||
res = self._request(newurl) ##接続してHTML取ってくる | |||
if res is None: | |||
self._fail() | |||
newsoup = BeautifulSoup(res.text, 'html.parser') ##beautifulsoupでレスポンス解析 | |||
if newsoup.find(class_="timeline-end") is None: ##ツイートの終端ではtimeline-endだけのページになるので判定 | |||
print(res.url + 'に移動しますを') | |||
self._page = res ##まだ残りツイートがあるのでページを返して再度ツイート本文収集 | |||
else: | |||
print("急に残りツイートが無くなったな終了するか") | |||
self._make_txt() | |||
if __name__ == '__main__': | |||
krsw = len(sys.argv) > 1 and sys.argv[1] == 'krsw' ##コマンドライン引数があるかどうかのフラグ | |||
twitter_archiver = TwitterArchiver(krsw) | |||
##ツイートを取得し終えるまでループ | |||
while True: | |||
twitter_archiver.get_tweet() ##self.txt_dataにページ内のリツイート以外の全ツイートの中身突っ込んでいく | |||
twitter_archiver.go_to_new_page() ##新しいページ取得 | |||
</syntaxhighlight> | </syntaxhighlight> | ||
== 実行例 == | == 実行例 == | ||
20件での実行例。 | 20件での実行例。 |