公式APIに頼らずX(Twitter)へポストできるPython Twikitライブラリを使って、WordPressサイトのRSSフィードから得た新着記事情報を自動ポストする仕組みを構築します。
Python Twikitライブラリ
PythonでX(Twitter)へポストする手段はまず、公式のX APIを叩く方法が挙げられますが、利用に必要な開発者登録が煩雑なのが難点。そんな中、見つけたのがAPIを介さずに手軽に利用できて話題の、Twikitライブラリです。
どうも内部ではスクレイピングのようなことをして実現しているようなので、あまり利用者が増えるとサーバ側で対策されてしまう懸念も。
まぁ、いずれは公式APIを利用するとして、今回はそれまでの時間稼ぎに、このTwikitライブラリを使ってポストする仕組みを作ってみることにします。
Python 3.8→3.10へ
前回記事同様、Oracle Cloud上のUbuntu 20.04ベースの仮想マシンで作業を始めるも、Python3.8ではこのTwikitは動きません。
|
1 2 3 4 5 6 7 8 9 10 11 |
Python 3.8.10 (default, Sep 11 2024, 16:02:53) >>> from twikit import Client Traceback (most recent call last): File "<stdin>", line 1, in <module> File "/home/ubuntu/.local/lib/python3.8/site-packages/twikit/__init__.py", line 22, in <module> from .client.client import Client File "/home/ubuntu/.local/lib/python3.8/site-packages/twikit/client/client.py", line 43, in <module> from ..streaming import Payload, StreamingSession, _payload_from_data File "/home/ubuntu/.local/lib/python3.8/site-packages/twikit/streaming.py", line 207, in <module> StreamEventType = (ConfigEvent | SubscriptionsEvent | TypeError: unsupported operand type(s) for |: 'type' and 'type' |
これは、当該部の型ヒントの表記方法に、Python3.10以降のモダンな記述が採用されているためで、必然的にTwikitはPython 3.10以降でないと動作しないことを意味します。
ところが、Ubuntu 20.04のシステムリポジトリにPython 3.10が収録されていないので、古いUbuntuへ新しいPythonを載せるのに重宝するこちらのサードパーティリポジトリを追加してからインストールします。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
~$ sudo add-apt-repository ppa:deadsnakes/ppa This PPA contains more recent Python versions packaged for Ubuntu. Press [ENTER] to continue or Ctrl-c to cancel adding it. Reading package lists... Done ~$ sudo apt update Hit:4 http://ppa.launchpad.net/deadsnakes/ppa/ubuntu focal InRelease ~$ apt list -a python3.10 Listing... Done python3.10/focal 3.10.15-1+focal1 arm64 ~$ sudo apt install python3.10 The following additional packages will be installed: libpython3.10-minimal libpython3.10-stdlib python3.10-minimal The following NEW packages will be installed: libpython3.10-minimal libpython3.10-stdlib python3.10 python3.10-minimal Do you want to continue? [Y/n] y ubuntu@ubnxc:~$python3.10 Python 3.10.15 (main, Sep 7 2024, 18:35:33) [GCC 9.4.0] on linux Type "help", "copyright", "credits" or "license" for more information. >>> |
Python 3.10のpip導入と棲み分け
Python 3.10は入りましたが、 pip は元々入っているPython 3.8用のまま。
|
1 2 3 4 5 6 |
~$ pip --version pip 24.2 from /home/ubuntu/.local/lib/python3.8/site-packages/pip (python 3.8) ~$ python3.10 -m pip --version Traceback (most recent call last): ModuleNotFoundError: No module named 'distutils.util' |
python3.10-distutils パッケージを入れるとバージョン番号ぐらいはまともに返せるようにはなりましたが、 list でまたコケるのでこれは解決とは言えず。
結局、 pip のインストールスクリプトを使ってバージョン指定でインストール。
|
1 2 3 4 5 6 7 |
~$ curl -sS https://bootstrap.pypa.io/get-pip.py | python3.10 Defaulting to user installation because normal site-packages is not writeable Collecting pip Using cached pip-24.2-py3-none-any.whl.metadata (3.6 kB) Using cached pip-24.2-py3-none-any.whl (1.8 MB) Installing collected packages: pip Successfully installed pip-24.2 |
しかしこれでは、デフォルトのpipがPython 3.10の pip になってしまって不都合。このシステムのデフォルトのPythonはPython 3.8なので、デフォルトの pip も3.8のものであって欲しいのです(Python 3.10環境はバージョン名指しで使うことに限定)。
|
1 2 |
~$ pip --version pip 24.2 from /home/ubuntu/.local/lib/python3.10/site-packages/pip (python 3.10) |
そこで、上述のインストールスクリプトでPython 3.8を指定して再実行。これで思っていた仕様になりました。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
~$ curl -sS https://bootstrap.pypa.io/get-pip.py | python3.8 Defaulting to user installation because normal site-packages is not writeable Collecting pip Using cached pip-24.2-py3-none-any.whl.metadata (3.6 kB) Using cached pip-24.2-py3-none-any.whl (1.8 MB) Installing collected packages: pip Attempting uninstall: pip Found existing installation: pip 24.2 Uninstalling pip-24.2: Successfully uninstalled pip-24.2 Successfully installed pip-24.2 ~$ pip --version pip 24.2 from /home/ubuntu/.local/lib/python3.8/site-packages/pip (python 3.8) ~$ python3.10 -m pip --version pip 24.2 from /home/ubuntu/.local/lib/python3.10/site-packages/pip (python 3.10) ~$ python3.8 -m pip --version pip 24.2 from /home/ubuntu/.local/lib/python3.8/site-packages/pip (python 3.8) |
Twikitの導入
ようやく準備が整ったので、 pip でtwikitをインストールします。
|
1 2 3 4 5 6 7 |
~$ pip3.10 install twikit - 略 - Installing collected packages: filetype, typing-extensions, soupsieve, socksio, sniffio, pyotp, lxml, h11, exceptiongroup, httpcore, beautifulsoup4, anyio, httpx, twikit Successfully installed anyio-4.6.0 beautifulsoup4-4.12.3 exceptiongroup-1.2.2 filetype-1.2.0 h11-0.14.0 httpcore-1.0.5 httpx-0.27.2 lxml-5.3.0 pyotp-2.9.0 sniffio-1.3.1 socksio-1.0.0 soupsieve-2.6 twikit-2.1.3 typing-extensions-4.12.2 ~$ pip3.10 list | grep twikit twikit 2.1.3 |
そして、前回使ったfeedparserライブラリも、Python 3.10環境へ忘れずにインストール。
|
1 2 3 4 5 6 7 |
~$ pip3.10 install feedparser - 略 - Installing collected packages: sgmllib3k, feedparser Successfully installed feedparser-6.0.11 sgmllib3k-1.0.0 ~$ pip3.10 list | grep feedparser feedparser 6.0.11 |
Twitterへ初回ログインしてツイート
Twikitはv2系になって asyncio による非同期処理を使って実行するようになったようで、使ったことのない処理方法に四苦八苦。
さらに、潜在的なバグも完全に解決しきれていないようですがいろいろ調べ、ひとまず以下のスクリプトでTwitterへログインしてツイートするところまで、こぎつけました(キモは、 user_agent を偽装するところ)。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
import asyncio from twikit import Client client = Client(language="ja", user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36' ) async def main(): await client.login( auth_info_1="##USERID##" , auth_info_2="##EMAIL##", password="##PASSWORD##" ) client.save_cookies("twitter_cookies.json") await client.create_tweet(text='Hello World from twikit/Python3.10/Osaka') asyncio.run(main()) |
未知のデバイスからの初回ログインは、Twitterの通知に挙がります。
なので、実行の度にログインし直していると、おそらくTwitter側にあまり良くない印象を与えることになりそう。
次回以降はクッキーで認証してツイート
そこで、上述のスクリプトで保存したクッキーファイルで次回以降は認証の上、ツイートを投げつけます。
|
1 2 3 4 5 6 7 8 9 10 11 |
import asyncio from twikit import Client client = Client(language="ja", user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36' ) async def post_tweet(msg): client.load_cookies("twitter_cookies.json") await client.create_tweet(msg) asyncio.run(post_tweet('Hello World from twikit/Python3.10/Osaka')) |
スクリプト内にパスワード平文で記述しないで済むのは、精神的にも平穏。
RSSフィードから新着記事抽出してツイート
これを前回製作したfeedparserによる、RSSフィード新着記事抽出スクリプトへ実装するにあたり、ハマったのは asyncio.run() は一度しか使えないということ。
そこで、新着記事が複数あることに備えたループ処理を非同期関数で包み、それを最後に asyncio.run() で実行させるようにしています(かなり力技)。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
client = Client(language="ja", user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36' ) async def post_tweet(msg): client.load_cookies("twitter_cookies.json") await client.create_tweet(msg) async def newfeed_loop(): for idx,entry in enumerate(newentries): msg = '[Fun Scripting 2.0]\n' msg += entry.title + '\n\n' + entry.link print(msg) await post_tweet(msg) pickle_wb(feedfile, entry) await asyncio.sleep(5) asyncio.run(newfeed_loop()) |
こうしてようやく組み上がった以下のスクリプトを、cronで日毎に自動実行するようにしています。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
#!/usr/bin/env python3.10 # -*- coding: utf-8 -*- import feedparser, pickle, os, sys from datetime import datetime as dt import asyncio from twikit import Client ## VARs feedfile = 'lastfeed.bin' lastfeed = {} feedurl = "https://servercan.net/blog/feed/" dtformat = '%a, %d %b %Y %H:%M:%S %z' ## FUNCs def str2date(str): return dt.strptime(str, dtformat) def pickle_rb(file): with open(file, 'rb') as f: return pickle.load(f) def pickle_wb(file, obj): with open(file, 'wb') as f: pickle.dump(obj, f) async def post_tweet(msg): client.load_cookies("twitter_cookies.json") await client.create_tweet(msg) ## LOAD LAST FEED FILE IF EXISTS print('batch start at '+dt.now().strftime('%Y-%m-%d %H:%M:%S')) if os.path.isfile(feedfile): print('feed file found, loading...') lastfeed = pickle_rb(feedfile) ## GET RSS FEED feed = feedparser.parse(feedurl) if feed.entries == 0: print('feedparser returns err, aborting...') sys.exit(0) ## INITIAL RUN : SAVE LATEST ENTRY ONLY if len(lastfeed) == 0: print('no feed file fould, creating new.') pickle_wb(feedfile, feed.entries[0]) print('feed file created, batch end.') sys.exit(0) ## FEED LOOP lastdate = str2date(lastfeed.published) newentries = [] print('loop entries to check new') for idx,entry in enumerate(feed.entries): if lastdate >= str2date(entry.published): print(str(idx)+' '+entry.published+' : OLD') else: print(str(idx)+' '+entry.published+' : NEW') newentries.append(entry) ## SORT NEW ENTRIES IF MORE THAN 1 if len(newentries) > 1: print('more than 1 entries detected. entries list reversing...') newentries.reverse() elif len(newentries) == 0: print('new post NOT found, batch end.'); sys.exit(0) #DEBUG print('New Entries: '+str(len(newentries))) for idx,entry in enumerate(newentries): print(str(idx)+' '+entry.published) ## NEW ENTRIES ACTION client = Client(language="ja", user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36' ) async def newfeed_loop(): for idx,entry in enumerate(newentries): #print(str(idx)+' '+entry.published) msg = '[Fun Scripting 2.0]\n' msg += entry.title + '\n\n' + entry.link print(msg) await post_tweet(msg) pickle_wb(feedfile, entry) await asyncio.sleep(5) asyncio.run(newfeed_loop()) print('batch end properly at '+dt.now().strftime('%Y-%m-%d %H:%M:%S')) |
このスクリプトが動いているうちに、公式API利用のための開発者登録を進めたいと思います。
create_tweet問題
しばらく上記のスクリプトで動いていたものの、ある時よりツイートに失敗するようになりました。スクリプトをPythonコンソールから一つ一つ実行してみると、最後にエラー。
|
1 2 3 4 5 6 7 8 9 10 11 |
Traceback (most recent call last): File "<stdin>", line 1, in <module> File "/usr/lib/python3.10/asyncio/runners.py", line 44, in run return loop.run_until_complete(main) File "/usr/lib/python3.10/asyncio/base_events.py", line 649, in run_until_complete return future.result() File "<stdin>", line 8, in main File "/home/ubuntu/.local/lib/python3.10/site-packages/twikit/client/client.py", line 1241, in create_tweet _result = response['data']['create_tweet']['tweet_results'] KeyError: 'create_tweet' >>> |
調べてみてたどり着いたのは、こちらのスレッド。
類似内容の連投や文字数超過が疑わしいようなので、いろいろ試してみた結果、記事のURLのみを渡してようやくツイートが発せられました。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
#!/usr/bin/env python3.10 # -*- coding: utf-8 -*- import asyncio from twikit import Client client = Client(language="ja", user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36' ) #msg = '[Fun Scripting 2.0]\n' #msg = 'MT7612搭載USB WiFiアダプタの煩わしい内蔵ドライバCDを無効にする改造\n' msg = 'https://servercan.net/blog/2024/11/mt7612%e6%90%ad%e8%bc%89usb-wifi%e3%82%a2%e3%83%80%e3%83%97%e3%82%bf%e3%81%ae%e7%85%a9%e3%82%8f%e3%81%97%e3%81%84%e5%86%85%e8%94%b5%e3%83%89%e3%83%a9%e3%82%a4%e3%83%90cd%e3%82%92%e7%84%a1%e5%8a%b9/' async def post_tweet(msg): client.load_cookies("twitter_cookies.json") await client.create_tweet(msg) asyncio.run(post_tweet(msg)) |
毎回どきまぎさせられるのはさすがに疲れるので、X開発者登録を早急に進めようと思います。






