[{"data":1,"prerenderedAt":362},["ShallowReactive",2],{"/work/misskey-x-mirror-bot/":3},{"id":4,"title":5,"body":6,"category":347,"coverImage":348,"date":349,"description":350,"draft":351,"extension":352,"meta":353,"navigation":354,"path":355,"seo":356,"stem":357,"tags":358,"__hash__":361},"work/work/misskey-x-mirror-bot/index.md","Misskey-X-Mirror-Bot",{"type":7,"value":8,"toc":338},"minimark",[9,16,25,29,37,40,43,59,62,65,68,89,92,96,99,114,128,139,252,256,259,281,284,287,295,301,304,317,320,327,334],[10,11,12],"alert",{},[13,14,15],"p",{},"本BotはXの利用規約に抵触するおそれがある所謂グレーゾーンなものです。本記事はあくまでも技術的な解説が目的であり、規約違反に該当する可能性のある行為を推奨するものではありません。また、本記事を参考にした結果生じたいかなる損害についても、「こは」は責任を負いません。",[13,17,18],{},[19,20,24],"a",{"href":21,"rel":22},"https://github.com/Kohxax/misskey-x-mirror-bot/",[23],"nofollow","Githubリポジトリ",[26,27,28],"h2",{"id":28},"概要",[13,30,31,32,36],{},"Xの特定アカウントの投稿を、Misskeyにミラー投稿する個人用botを作ってきました。botの核となる部分は",[33,34,35],"code",{},"@the-convocation/twitter-scraper","という、Cookieに保存されたXのトークンを使って認証、そしてXの投稿を取得することができるというOSSです。",[13,38,39],{},"設計は基本Claudeと壁打ちして、コード部分については9割がClaude製、人間は手直しを少々という感じでした。Xの認証周りでだけ少し詰まったので、そこら辺について重点的に書ければいいかなと思います。逆に言うとそれ以外詰まることはありませんでした。AIって本当に凄いですね。",[26,41,42],{"id":42},"基本的な機能",[44,45,46,50,53,56],"ul",{},[47,48,49],"li",{},"指定したXアカウントのツイートを定期取得（5分ごと）してMisskeyに投稿",[47,51,52],{},"画像の添付に対応",[47,54,55],{},"Misskeyのフォローバック機能",[47,57,58],{},"トークン失効時にdiscordへ通知",[13,60,61],{},"以上4つです。フォロバ機能については別につけなくても良かったかもなとか思ったりしています。そのうち環境変数から設定できるようにしましょうかね。",[26,63,64],{"id":64},"仕組み",[13,66,67],{},"やっていることがシンプルな分、中身も非常にシンプルです。",[69,70,71,74,77,80,83,86],"ol",{},[47,72,73],{},"Xのトークン(auth_token, ct0)を渡して認証する",[47,75,76],{},"MisskeyのAPIトークンを渡して認証する",[47,78,79],{},"設定したアカウントの最新投稿を、5分ごとに取得する",[47,81,82],{},"取得したツイートのうち、保存済みの最新IDより新しいものだけを抽出する",[47,84,85],{},"該当ツイートをMisskeyに投稿する(画像があればDrive経由で添付)",[47,87,88],{},"投稿したツイートの中で最新のIDをJSONに保存する",[13,90,91],{},"主にこの6ステップとなります。起動後は3~6をループする感じです。これに、以前作った時報botについてたフォロバ機能なんかをそのまま流用したものが完成品です。",[26,93,95],{"id":94},"xの認証について苦労話","Xの認証について(苦労話)",[13,97,98],{},"Misskeyのクライアントのあれこれについてはコードを見てくださればと思います。とてもシンプルでわかりやすいので助かってます。しゅいろママありがとう。",[13,100,101,102,105,106,109,110,113],{},"で、問題のXの認証についてです。当初は現在の",[33,103,104],{},"twitter-scraper","ではなくそれを元にした",[33,107,108],{},"agent-twitter-client","という別のライブラリを使ってXの投稿取得を試みていました。初めのうちはユーザーID, パスワード, メールアドレスなんかを使った簡単な認証方式でできないものかと格闘していたのですが、何をどう頑張っても",[33,111,112],{},"401 Unauthorized","が出て認証に失敗してしまっていました。",[13,115,116,117,120,121,124,125,127],{},"その後、認証方式をcookieから取得できる",[33,118,119],{},"ct0","と",[33,122,123],{},"auth_token","を使うものに変更したのですが、こちらも変わらず401を吐かれてしまいました。色々試して他の方法がなくなってしまったので、最終的に使うことになった",[33,126,104],{},"にライブラリを切り替えました。",[13,129,130,131,134,135,138],{},"切り替え以降も、イーロンくんがtwitterからXに変えたりした影響で、トークンを渡すドメインが違っていたりなんだりでつまりはしましたが、最終的に以下のようなコードでツイートを取得できるようになりました。取得対象のアカウントを",[33,132,133],{},"username","、取得数を",[33,136,137],{},"count","としてぶん投げる感じですね。当たり前ですが、認証とかはこれ走らせる前に通しています。",[140,141,146],"pre",{"className":142,"code":143,"language":144,"meta":145,"style":145},"language-ts shiki shiki-themes github-dark","async getRecentTweets(username: string, count: number = 20): Promise\u003CTweet[]> {\n    const tweets: Tweet[] = [];\n    for await (const tweet of this.scraper.getTweets(username, count)) {\n        tweets.push(tweet);\n    }\n    return tweets;\n}\n","ts","",[33,147,148,190,201,228,234,240,246],{"__ignoreMap":145},[149,150,153,157,161,164,168,172,175,178,181,184,187],"span",{"class":151,"line":152},"line",1,[149,154,156],{"class":155},"s95oV","async ",[149,158,160],{"class":159},"svObZ","getRecentTweets",[149,162,163],{"class":155},"(username: string, count: number ",[149,165,167],{"class":166},"snl16","=",[149,169,171],{"class":170},"sDLfK"," 20",[149,173,174],{"class":155},"): ",[149,176,177],{"class":170},"Promise",[149,179,180],{"class":166},"\u003C",[149,182,183],{"class":155},"Tweet[]",[149,185,186],{"class":166},">",[149,188,189],{"class":155}," {\n",[149,191,193,196,198],{"class":151,"line":192},2,[149,194,195],{"class":155},"    const tweets: Tweet[] ",[149,197,167],{"class":166},[149,199,200],{"class":155}," [];\n",[149,202,204,207,210,213,216,219,222,225],{"class":151,"line":203},3,[149,205,206],{"class":155},"    for ",[149,208,209],{"class":166},"await",[149,211,212],{"class":155}," (const tweet ",[149,214,215],{"class":166},"of",[149,217,218],{"class":170}," this",[149,220,221],{"class":155},".scraper.",[149,223,224],{"class":159},"getTweets",[149,226,227],{"class":155},"(username, count)) {\n",[149,229,231],{"class":151,"line":230},4,[149,232,233],{"class":155},"        tweets.push(tweet);\n",[149,235,237],{"class":151,"line":236},5,[149,238,239],{"class":155},"    }\n",[149,241,243],{"class":151,"line":242},6,[149,244,245],{"class":155},"    return tweets;\n",[149,247,249],{"class":151,"line":248},7,[149,250,251],{"class":155},"}\n",[26,253,255],{"id":254},"画像の投稿について工夫","画像の投稿について(工夫)",[13,257,258],{},"見出し通り、画像の投稿については少しだけ工夫というか、misskeyの仕様を活かした設計になっています。具体的には、",[69,260,261,264,267,270],{},[47,262,263],{},"取得したツイートに画像があるかチェック",[47,265,266],{},"画像URLからfetchしてDL",[47,268,269],{},"DLした画像をドライブにアップロードして画像IDを取得",[47,271,272,273,276,277,280],{},"画像IDを",[33,274,275],{},"misskey-js","の",[33,278,279],{},"note/create","に渡して投稿",[13,282,283],{},"という流れです。X側の画像のURLをmisskeyの投稿に直接貼るのはできないので、一度ドライブ経由でアップロードする感じになっています。多少なりともドライブの容量を圧迫してしまうので、長期的な運用を予定している場合は、misskeyのbotアカウントのドライブ容量上限を引き上げておくのを忘れないようにしましょう。",[26,285,286],{"id":286},"実働に際して",[13,288,289,290,294],{},"Dockerが動く環境ならどこでも動くと思います。動かし方なんかは",[19,291,293],{"href":21,"rel":292},[23],"GithubのReadme","に載っているので、そちらを参照していただければと思います。ただし、何度も強調している通りXの規約上グレーな行為であることを自覚した上で、自己責任でお願いします。",[13,296,297,298,300],{},"唯一問題になってくるのがcookieから取得したトークン情報の有効期限です。Devtoolsで確認するとわかりますが、大体半年くらいでトークンの有効期限が切れます。ですので、このbotでは環境変数にdiscordのwebhookURLを入力することで、トークン失効時の",[33,299,112],{},"を検知して通知を飛ばす機能を組み込んでいます。少し面倒ですが、これはしゃーないかなと思います。",[26,302,303],{"id":303},"おわりに",[13,305,306,307,311,312,120,314,316],{},"ということで、今回作ってきた",[308,309,310],"strong",{},"misskey-x-mirror-bot","の中身の紹介と、開発における苦労話でした。",[33,313,104],{},[33,315,275],{},"のおおまかな仕様がつかめたのは勿論、コーディングエージェントの凄さを改めて実感できた開発でした。せっかくの環境ですし、もう少し難しいものも作ってみたいなと思ったり。",[13,318,319],{},"今回の完成品を使って実際に動かしているアカウントを以下にリンクしておくので、良ければ見てみてください。最後まで読んでくださりありがとうございました。",[13,321,322],{},[19,323,326],{"href":324,"rel":325},"https://mi.bokukoha.dev/@AKEndfieldJP_mi",[23],"アークナイツ：エンドフィールド(ミラー)",[13,328,329],{},[19,330,333],{"href":331,"rel":332},"https://mi.bokukoha.dev/@houkaistarrail_mi",[23],"崩壊：スターレイル(ミラー)",[335,336,337],"style",{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":145,"searchDepth":230,"depth":230,"links":339},[340,341,342,343,344,345,346],{"id":28,"depth":192,"text":28},{"id":42,"depth":192,"text":42},{"id":64,"depth":192,"text":64},{"id":94,"depth":192,"text":95},{"id":254,"depth":192,"text":255},{"id":286,"depth":192,"text":286},{"id":303,"depth":192,"text":303},"bot","https://images.bokukoha.dev/work/misskey-x-mirror-bot/main.png","2026-04-01","Xの特定アカウントの投稿を、Misskeyにミラー投稿する個人用botを作ってきました。botの核となる部分はtwitter-scraperという、Cookieに保存されたXのトークンを使って認証、そしてXの投稿を取得することができるというOSSです。",false,"md",{},true,"/work/misskey-x-mirror-bot",{"title":5,"description":350},"work/misskey-x-mirror-bot/index",[359,104,360],"Typescript","Misskey-js","p_d6xBFOGPlusPVaHBHljhqigkaGnBINLasGIsujrc4",1777182799222]