すがブログ

聖地巡礼とか各期のアニメ振り返りとか書きます

AnimetickのWatchボタンを押したら別タブでツイート投稿画面を表示させるchrome拡張機能を作った話

はじめに

皆さんはanimetickというサイトをご存知でしょうか?

非常に便利で有用なサービスなので、毎クールアニメを複数視聴する方はぜひ使ってみてほしいのですが、 簡単に言うと「アニメの視聴状況をチケットで管理できるサービス」です。

↑こんな感じで、視聴できるテレビ局と視聴中の作品を登録すると、放送日の前日にそのアニメの放送話数の「チケット」が未視聴リストに割り当てられます。

どのテレビ局で何時からどの作品の何話が放送されるかが一目でわかりますね。

該当話数の視聴が終わったら、そのチケットの「Watch」ボタンをクリックすることで視聴済み扱いとなり、各作品何話まで見終えたかの確認も容易になります。

詳しい操作方法は開発者の @KAZZONEさんのブログに書いてあるので、こちらを確認すると良いでしょう。

kazz187.hatenablog.com


そんなanimetickですが、以前はログイン中のtwitterアカウントと連携して、 「Watch」ボタンをクリックすると自動で「〇〇のn話を見ました」という感じのツイートを投稿できる機能が存在しました。

しかし昨年、某イー□ン・マスク氏による改革の影響でTwitterAPIが仕様変更となり、利用するためには高額な料金を支払わないといけなくなってしまいました。

無料サービスであるanimetickが有償化したAPIを利用するのは現実的ではなく、結論としてこの連携投稿機能は作動しなくなりました。


個人的に、この連携投稿はかなり必要性の高い機能でした。

フォロワーに今期の作品を宣伝できるということに加えて、各話視聴後にこの自動投稿が行われることで「〇〇のn話を視聴したが、」という枕詞をつけずにアニメの感想をツイートできるからです。

代替手段として手動で連携投稿できるような機能(ボタンを押したらツイート投稿画面が表示されるなど)があれば良かったのですが、 もともと自動で連携投稿できたサービスでしたので、わざわざ手動でも投稿できるような機能は用意されていません。

仕方ないので、他の似たようなサービスで手動連携投稿できるものはないかと探し、いくつか試してみました。

1ヶ月ほど使ってみたのですが、どうしても機能性やシンプルさの点でanimetickの方が優れているな…という思いは拭えませんでした。


そんな時、ふと「これくらいならGoogleChrome拡張機能とかで実装できるのでは…?」と思い至りました。

要は「ボタンを押したところの作品名とか話数とかを取得して、それをデフォルト文章として含んだtwitterの投稿画面リンク作って別タブで開く」ができれば良いわけです。

当時は拡張機能がどういうメカニズムで動いているのかすら知らない状態でしたが、とりあえず作ってみることにしました。




chrome拡張機能の設定ファイルの作成

拡張機能を作るにあたり色々調べましたが、特に以下の記事が参考になりました(感謝)。

qiita.com

qiita.com


まず、manifest.jsonというファイルを作る必要があります。 拡張機能に関する設定ファイルみたいなものです。

公式リファレンスなどを参考に作ってみたものがこちら。

{
    "manifest_version": 3,
    "name": "animetick-tweet",
    "version": "1.0.0",
    "description": "AnimetickのWatchボタンを押した際に別タブでツイート画面を表示させる",
    "content_scripts": [
        {
            "matches": ["http://animetick.net/*"],
            "js": ["content.js"]
        }
    ]
}

各項目をざっくり説明をすると、

  • manifest_version(必須): 拡張機能が使用するマニフェストファイル形式のバージョンを指定する整数。2024年2月時点では「3」を指定すればOK。
  • name(必須): 拡張機能の名前。
  • version(必須): バージョン番号。最初は1.0.0を入れて、アップデートするたびに増やしていけばよい。
  • description: 拡張機能の説明。ストアに公開する場合は必要っぽい。
  • content_scripts: 実行されるファイルに関する説明。
    • matches: 適用したいページのURL。 書式についてはこちらを参照
    • js: 実装するJavaScriptのファイル。

という感じです。 他にも色々設定できる項目はありますが、最低これだけ書いておけば大丈夫っぽいですね。

では、実際にcontent.jsの中身を作っていきます。




JavaScriptファイルの実装

JavaScriptをまともに書いた経験はないので、とりあえずググって出てくるような基礎的な書き方を組み合わせていきました。

まず「各チケットについてWatchボタンを押したらツイート投稿画面が開く」ところまで実装します。

const tickets = document.getElementsByClassName("ticket_relative ticket_mouse");
for(let ticket of tickets) {
    let button = ticket.querySelector("button");
    if (button != null) {
        // ボタンが存在する(=放送済みの作品)のみイベントを設置
        button.addEventListener('click', buttonClick, false);
    }
};

function buttonClick() {
    window.open("https://twitter.com/intent/tweet");
}

未視聴チケットの欄でリスト化されているチケットのdiv要素はすべて class="ticket_relative ticket_mouse"を持っているので、getElementsByClassName()ですべてのチケットの要素を取得することができます。

これをループで回して各チケットのbutton要素をquerySelector()で取得し、それぞれに対してボタンクリック時のイベントをaddEventListener()で仕込んでいきます。

注意点として、未放送のチケットにはWatchボタンが設置されていないので、そのチケットはbuttonにnullが入ってしまいます。この状態でbutton.addEventListener()と実行するとエラーになるので、ボタンが存在する場合のみイベントを設置するよう分岐を入れる必要があります。

イベントとして「ツイート投稿画面を別タブで開く」という処理を書くことになりますが、これは単純に https://twitter.com/intent/tweetwindow.open() するだけでOKです。


余談:

変数の定義でconst/letの2パターンありますが、これは再代入可能かどうかの違いがあるようです。

qiita.com

個人的に「const」と書かれると定数値かな?という印象を受けるのですが、 いろいろ調べたところJavaScriptでは再代入不要であればconstで宣言するのが一般的らしいので、 再代入の必要があるものだけletで、それ以外はconstにしています。




続いて、「投稿内容を取得してツイート投稿画面に反映させる」処理を追加します。

const tickets = document.getElementsByClassName("ticket_relative ticket_mouse");
for(let ticket of tickets) {
    let button = ticket.querySelector("button");
    if (button != null) {
        // ボタンが存在する(=放送済みの作品)のみイベントを設置
        button.addEventListener('click', buttonClick.bind(ticket), false);
    }
};

function buttonClick() {
    let title = this.getElementsByClassName("title")[0].querySelector("span").innerText;
    let episodeNumber = this.getElementsByClassName("sub_title")[0].getElementsByClassName("count")[0].innerText;
    let subTitle = this.getElementsByClassName("sub_title")[0].getElementsByClassName("sub_title")[0].innerText;
    if (subTitle) {
        subTitle = "「" + subTitle + "」";
    }
    const subTitleLink = this.getElementsByClassName("sub_title")[0].querySelector("a").getAttribute("href");

    const text = title + "%20" + episodeNumber.replace("#", "#") + "%20" + subTitle + "を見ました"
            + "%0Ahttp://animetick.net" + subTitleLink;
    window.open("https://twitter.com/intent/tweet?text=" + text);
}

まずは簡単に取得できる「作品タイトル」「話数」「サブタイトル」「animetickの各話数ごとのページへのリンク(こういうの: http://animetick.net/ticket/6390/1 )」を反映させます。

これらはすべて各ticket要素の中に含まれているので、そこから取ってきましょう。

function buttonClick()にticketを渡す必要がありますが、これはaddEventListenerの第二引数で.bind(ticket)してやると、関数内でthisとして受け取れるようです。 あとはそこから各情報を取り出すだけです。
(getElementsByClassName()やらinnerTextやらでゴリ押しで取り出してますが、正直もうちょっとスマートな書き方あると思います…)

サブタイトルについては「」で囲ってあげるのが丁寧ですが、たまにサブタイトルが存在しないアニメもあるので、一応subTitleが存在しているかどうかで判定して存在する時のみ付けています。


ツイート投稿画面への反映は、クエリパラメータのtextとして付与させるだけでOKです。

ここで、話数についている#は、そのままだとURL上ではアンカーリンクと認識されてしまって挙動がおかしくなるので、エスケープさせる必要があります。
(最初は原因がわからず、だいぶ詰まりました…)

ただし半角の#としてエスケープさせても結局ハッシュタグ扱いになってしまうので、ここは単純に全角のに置換させました。




ここまで実装すれば機能としては成立していますが、「ツイート連携するかどうかのチェックボックスが飾りと化している」「視聴済み状態から未視聴に戻す時も投稿画面が開いてしまう」などの問題点も残っています。

この2点を一応解消しておきます。

const tickets = document.getElementsByClassName("ticket_relative ticket_mouse");
for(let ticket of tickets) {
    let button = ticket.querySelector("button");
    if (button != null) {
        // ボタンが存在する(=放送済みの作品)のみイベントを設置
        button.addEventListener('click', buttonClick.bind(ticket), false);
    }
};

function buttonClick() {
    let title = this.getElementsByClassName("title")[0].querySelector("span").innerText;
    let episodeNumber = this.getElementsByClassName("sub_title")[0].getElementsByClassName("count")[0].innerText;
    let subTitle = this.getElementsByClassName("sub_title")[0].getElementsByClassName("sub_title")[0].innerText;
    if (subTitle) {
        subTitle = "「" + subTitle + "」";
    }
    const subTitleLink = this.getElementsByClassName("sub_title")[0].querySelector("a").getAttribute("href");

    const isChecked = this.querySelector("input").checked;
    const isWatched = this.querySelector("button").className.includes("enable");

    const text = title + "%20" + episodeNumber.replace("#", "#") + "%20" + subTitle + "を見ました"
            + "%0Ahttp://animetick.net" + subTitleLink;
    if (isChecked && !isWatched) {
        // チェックが入っている かつ 視聴済みでない場合のみ実行
        window.open("https://twitter.com/intent/tweet?text=" + text);
    }
}

「チェックが入っているかどうか」は単純にinput要素が checkedであるかどうかの情報を取ればOKです。

「視聴済みであるかどうか」ですが、これに関しては視聴済みであればbutton要素にenableというクラスが追加されるので、 これが含まれているかどうかをincludes()で判定できます。

一旦、これで完成としました。

(まだまだ直した方が良い箇所やもっとスマートに書ける箇所などあるとは思いますが、まぁ個人で使う分にはこんなもんでええやろの精神) (コメントなどで指摘いただければ、気が向いたら直すかもしれません)




動作確認

では実際に動かしてみましょう。

Googleアカウントログイン状態のChromeブラウザで「拡張機能を管理」の画面( chrome://extensions/ ) を開き、ページ右上の「デベロッパーモード」をONにします。

作成したmanifest.jsonとcontent.jsのファイルを置いたディレクトリを作成し、「パッケージ化されていない拡張機能を読み込む」ボタンからそのディレクトリを読み込ませます。

これで実装した拡張機能が適用されている状態になります。




審査・公開

動作確認ができたら、 Chromeウェブストアに公開するための手続きを進めていきます。

まず、https://chrome.google.com/webstore/devconsole よりデベロッパーアカウント(有料)を作成する必要があります。

登録できたら、manifest.jsonとcontent.jsのファイルを置いたディレクトリをzipに圧縮し、「+新しいアイテム」ボタンより追加します。

説明やらカテゴリやらを入力する必要があるので、適当に書きます。

個人的に苦労したのがショップアイコンの設定です。

正直アイコンはなんでもよかったので、animetickの頭文字である「A」という文字を画像にしただけのものをアイコンにしようとしたのですが、 「128x128ピクセル」の画像でなければならないらしく、このサイズにするのにめちゃくちゃ苦戦しました(なんならJavaScriptの実装より苦しんだまである)。

macのプレビューツールとか使ってどうにかこうにか作ったんですが、これ今調べたら普通に変換してくれそうなwebサイトありますね…あの苦労は何やったんや…


他にもプライバシー設定やら販売地域やら諸々設定し、すべて終わったら「審査のため送信」で提出です。

別に大した機能があるわけでもない & 個人情報の収集も行っていないからか、数日程度で審査通りました。

この辺の一連の流れは公式ドキュメントにも説明があるので、詳細はそちらを参照すると良いでしょう。

support.google.com


最後に、直接的にサービス影響を与えるものではないとはいえ、自分以外の人が作ったものに適用させる拡張機能ですので、 開発者の方に公開して問題ないかの確認を行いました。

↑ありがたいことに許可いただけたので、無事公開に至りました。


https://chromewebstore.google.com/detail/animetick-tweet/kedglhmfpphikkmoocimnalnnnkjimecchromewebstore.google.com

(現在公開されてるバージョンでは今回の記事で説明した機能の他に、ハッシュタグの取得や作品個別ページでWatchした場合の投稿機能なども追加しています。またいつかそれらについて書くかもしれません)

ソースコード

github.com