ワクチン予約サイトがゴミすぎる魚拓

うん。普通のクソ。そんなひどくもない。酷いサイトばっか紹介してたから、許容できるレベルのサイトも一応紹介しておく。

◆サイト

ランディングページが以下。orgドメインっすね。

杉並区 | 新型コロナワクチン接種インフォメーション

サイトの負荷軽減のため
リロードしない様にお願いいたします

いやぁ頼まれても困るわ。静的ページにできるだろがこんなもん。

Pleskっつーコンパネソフト入ってるらしいけど、サーバーレスでやれるでしょ。サーバー自体いらんわ。CDNで終わるわ。

▼HTML

<!--↓↓↓本番公開時に外す-->
<meta name="robots" content="noindex">

はずせよ?

予約サイトよりも、このランディングページのほうが技術的に腹立つわ。カスが作ってる。もう細かい指摘しないけど。

▼予約サイト自体

ログインページは以下のURL。

https://vaccines.sciseed.jp/suginami-vaccine/login

ドメインくれぇ用意したれや。Sciseedって誰だよ。

109の自治体が採用!サイシードが開発する「新型コロナウイルスワクチン接種専用予約管理システム」、人口約1515万人が対象に | PR TIMSES魚拓

<つくば市 新型コロナワクチン接種対策室|高野智史氏>
 様々な年齢・リテラシーの住民がいるので、Web/LINE/電話と広い手段を提供している点が決め手になりました。

でんわ。なるほど。それは偉いね。沢山の自治体に提供してんならまぁドメインとるのもめんでぇし、いいだろう。でも明らかに社用なのにサブドメイン使っちゃう男の人って…

だけど後々のっとりとかされる心配がないから安心っちゃ安心かもな。

話を戻す。

フロントはVueっすね。app.bundle.js魚拓)見た感じ、v2.5.17、2系だな。3系で作って頂きたかった。

っていうかさっきのページとかなにも動的な要素なさそうだった(デプロイで変更するような内容だった)けど、同じ技術スタックでポイっと作っちゃえばよかったじゃん。Pleskってなにさ。

▼bot

https://script.ai-x-supporter.com/685/bot_package.json魚拓

これ楽しげね。読んでるとなるほどなるほどと思う。ただこれ難読化するべきじゃねぇかとは思いますがね。会社の資産でしょうに。パスが予測可能なのも問題。「685」って数値を684にしたら別のデータが取れてきた。

▼UX

使い勝手?悪いよそりゃ。しゃあない。導線がないページとかあります。

気持ちわかるからあんまり強く言えねぇけどよ、せめてフィルタリングさせてくれまいか。取れない枠なんて、見えてもしょうがないじゃないですか。その日に予約可能な枠があるかどうか判断つかんから、スクロールで一番下まで行って戻ってくるって作業をしまくることになるよね。

生年月日パスワードは…まぁいいかな…どう悪用するんだって話だし…でもメアドのバリデーションやらSMS認証してないのはどうなんだろ。

最寄り駅「阿佐ヶ谷」って入れたら検索結果ゼロ件だしね。

ハンバーガーメニューで「接種会場検索」ってやって、「予約可能な会場のみ表示」ってやったら検索できるみたいね。一件もヒットしませんでしたが。

俺もワクチンポほしい。当日チェックしかないか。

◆BOT組もう(提案)

予約枠あるかどうかってポチポチ調べて更新かけてって、めんどくせ。僕は忙しいんです。自動的にキャンセルされた枠を検出して予約するプログラムを書いちまえばいいんだよ。

とはいえBANされたらどうしよ。真のワクチン難民になる。利用規約魚拓)になんか書いてあったっけ。…まぁええわ。

とりあえず、以下を開発者ツールのConsoleにぶち込む。5秒に1回「available_department」APIたたいて、予約可能な会場を見てみる。止めたくなったらclearIntervalあるいは画面をリロードしろ。

※サーバーを直叩きしてないだろうという確信の下でやっているわけですから、ほかのサービスにあまりこういうことすんなよ。

setInterval(() => fetch('https://api-cache.vaccines.sciseed.jp/public/131156/available_department/').then(x => x.json().then(j => console.log(j.department_list))), 5000);

ポーリングってやつですね。お前さんとこの自治体のURLがあるだろうから、これをあてにしてはいけない。department/searchのページで叩いてるやつをF12のNetworkタブで探せ。

ぼーっと眺めてるとたまに「8402」だのの数値が返ってくるんだけど、次の次くらい(10秒程度)でもう消えてる。予約が埋まってる。お前らピラニアか何か?

で、予約のページが

https://vaccines.sciseed.jp/suginami-vaccine/reservation/8383

みてぇになってるから、ケツの数値が対応しているんだろう。だから速攻でこのページ開けばいいじゃん。

setInterval(() =>
  fetch('https://api-cache.vaccines.sciseed.jp/public/131156/available_department/')
    .then(r => r.json()
      .then(json => {
        console.log(json.department_list > 0 ? `あったよ ${new Date()}` : `なかったよ ${new Date()}`);
        json.department_list.forEach(id => location.assign(`https://vaccines.sciseed.jp/suginami-vaccine/reservation/${id}`));
      })
    )
  , 5000);

awaitできるsetInterval作ってくれねぇかな…

というのでこいつを走らせておいてたんだが、動いてくれない。一瞬そのページに行こうとするんだが、トップページに戻っちゃう。

これはSPAやってる人には「はいはい」って感じの見慣れた挙動でして、(略)サーバーにちょっとした設定してやらねばならんのですね。AWSのCloudfrontでどうやって設定するんだか知らんが。

でまぁ…どうしてくれようか。まだ調査段階なんですけど。存在するであろう予約APIの、その定義が知りてぇ。

しょうがないので各予約ページを見てみますれば、予約カレンダーのCSSに「disabled」って書いてある。ので、除去した。が、クリックできず。

泣きながらNetwork見てみたら下のようなAPIを叩いていらっしゃる。

https://api-cache.vaccines.sciseed.jp/public/131156/available_date/?department_id=8383&item_id=1&year=2021&month=7

中身はふつうにJSON。以下の感じ。省略してるけど。

{
  "2021-07-16": {
    "total_cnt": 770,
    "total_cnt_limit": 770,
    "available": false
  },
  "2021-07-18": {
    "total_cnt": 900,
    "total_cnt_limit": 900,
    "available": false
  }
}

いいねぇSPAは。ハックしやすい。ハックしやすいということは見通しがよいってことであり、開発者からしてみればバグやセキュリティホールが埋まりにくいということだ。とはいえ日付をプロパティ名にしちゃうのは止めてくれ。扱いづらい。

とにかくレスポンスを偽造して「予約できるよ」を返却してみよう。「chrome response mock」とかで検索したら、それっぽい拡張機能を発見。2000人が使ってるし大丈夫でしょ!

と思いきや、5秒おきに流しているアイツが予約可能な会場を見つけたので、「!!!」シュババババして画面を一段階深堀できた。(20秒くらいで埋まったので取れなかった。こわい。)当日のタイムスケジュールダイアログを開いた時にAPI叩いてて、そのエンドポイント&レスポンスをゲット。

https://api-cache.vaccines.sciseed.jp/public/131156/reservation_frame/?department_id=8386&item_id=1&start_date_after=2021-07-18&start_date_before=2021-07-18
{
  "reservation_frame": [
    {
      "id": 2051967,
      "name": "2021年7月18日10時00分〜10時20分",
      "start_at": "2021-07-18T10:00:00+09:00",
      "end_at": "2021-07-18T10:20:00+09:00",
      "is_published": true,
      "reservation_cnt": 50,
      "department": 8386,
      "reservation_cnt_limit": 50,
      "item": 1,
      "next": null
    },
    {
      "id": 2051968,
      "name": "2021年7月18日10時20分〜10時40分",
      "start_at": "2021-07-18T10:20:00+09:00",
      "end_at": "2021-07-18T10:40:00+09:00",
      "is_published": true,
      "reservation_cnt": 50,
      "department": 8386,
      "reservation_cnt_limit": 50,
      "item": 1,
      "next": null
    }
  ]
}

はいはい。なるほど。なかなか綺麗な設計してやがる。ISO8601嬉しいじゃないかい。顔かほころぶぜ。

これidありゃ勝てそうですね。URLパラメーターに日付指定できるうえに、適当な日付範囲でも取得できる。つまり「available_date」APIの情報は不要だ。「reservation_frame」APIからリクエストしちゃってOKだろう。

と思いきや、またもや会場見つかったので瞬時に開いた。そして予約申請のリクエストまで押せたぜ!負けたけど。10秒ほどで埋まった。どうなってやがる。いま23時だぞ。

とにかく、予約のエンドポイントが以下っすね。POSTでした。

https://api.vaccines.sciseed.jp/public/131156/reservation/

ドメインが「api-cache」じゃなくて「api」になってんね。うんうん。

レスポンスは400 BadRequestで、早押し競争に負けたことを物語っていた。

{"non_field_errors":"No more reservation available"}

あ、はい。どうも。お疲れ様です。ダブルブッキング対策は万全っぽいすね…よかった…

ほいで、気になるリクエストのBodyは如何様であろうか。

{"reservation_frame_id":2052000}

勝ったッ!第3部完!

「reservation_frame」APIのidそのまま使えそうだわぁ。綺麗なデータ設計だぁ(恍惚)。ってんで、コードかきまひょ。

負っけないからねぇ(ドラコケンタウロス)

の前に、試しにfetchで予約APIを叩いたら401 Unauthorizedが返ってきました。なんで?アタイ、credentialsをincludeにしましたですけど。と思ってヘッダー眺めてましたら

authorization: Bearer [ひみつ]

んおぉ💕!!(ビクンビクン)

最新のベアラートークンは、開発者ツールのApplicationタブのlocalStorageをみたら普通に置いてあった。accessってキーのやつでしょうね。素直なアプリケーションだ。こういう普通のシステムを見習えよてめぇら!

よーしじゃあパパ今日はコード書いちゃうぞぉ。

~10秒後~

※7/20追記:自治体IDの指定いらんかったわ。localStorageに普通に入ってた。もはや日付の指定だけでいいんだな。

'use strict';

// 摂取できる日付範囲を設定しろ
const start_date_after = '2021-07-21';
const start_date_before = '2021-08-31';
// オプション:会場IDの指定。指定があった場合、それ以外の会場が無視される。
const department_id_target = []; // (e.g. ⇒ const department_id_target = [8539, 8384, 8385];
// オプション:含めたくない会場ID。全部含めたい場合は空配列にしてくれろ。
const department_id_to_ignore = [8383, 8387];
// NOTE: 1回目の接種だった場合は、配列に「'2回目専用予約枠'」って入れておけばいいかも。わからん。自治体によるかも。
const frame_names_to_ignore = [];

// 実処理
let intervalFunctionId = 0;
const siteId = localStorage.getItem('lastLoginPartition');
const unko = async () => {
  // 会場特定
  const r = await fetch(`https://api-cache.vaccines.sciseed.jp/public/${siteId}/available_department/`);
  const availableDepartment = await r.json();

  const firstId = availableDepartment.department_list.find((x) => {
    if (department_id_target?.length > 0) return department_id_target.includes(x);
    if (department_id_to_ignore?.length > 0) return !department_id_to_ignore.includes(x);
    return x;
  });
  if (!firstId) {
    console.log(`${(new Date()).toLocaleTimeString()} なかったんだぜ 結果:${JSON.stringify(availableDepartment.department_list)}`);
    return;
  } else {
    console.log(`あったんだぜ 結果:${JSON.stringify(availableDepartment.department_list)}`);
  };

  // 予約枠特定
  const r2 = await fetch(`https://api-cache.vaccines.sciseed.jp/public/${siteId}/reservation_frame/?department_id=${firstId}&start_date_after=${start_date_after}&start_date_before=${start_date_before}`);
  const reservationFrame = await r2.json();
  for (const frame of reservationFrame.reservation_frame) {
    if (frame.reservation_cnt_limit === frame.reservation_cnt) continue;
    if (frame_names_to_ignore.includes(frame.name)) continue;

    console.log(frame);

    const reserve = await fetch(
      `https://api.vaccines.sciseed.jp/public/${siteId}/reservation/`,
      {
        method: 'POST',
        credentials: 'include',
        headers: {
          'Content-Type': 'application/json',
          authorization: `Bearer ${localStorage.getItem('access')}`
        },
        body: `{"reservation_frame_id":${frame.id}}`
      });

    if (!reserve.ok) continue;

    console.log('%c 登 録 成 功 し た ぜ !', 'color:red;font-weigt:bold;font-size:20px;');
    clearInterval(intervalFunctionId);
    return;
  }

  // ハズレだったやつ、あるいは、登録失敗したやつをログる
  {
    const r3 = await fetch(`https://api-cache.vaccines.sciseed.jp/public/${siteId}/reservation_frame/?department_id=${firstId}&start_date_after=${start_date_before}&start_date_before=2022-07-31`);
    (await r3.json()).reservation_frame
      .filter(f => f.reservation_cnt_limit > f.reservation_cnt)
      .forEach(f => console.log(`はずした ${JSON.stringify(f)}`));
  }
};

// 5000ミリ秒ごとにunkoの実行をエンキュー
intervalFunctionId = setInterval(unko, 5000);

コピペしたいときは「Extern」ボタン押してなんとかしてくれ。

TypeScriptで書きたかった…(疲弊)

こんなんでどうでしょ。日付範囲は書き換えてあげてくれ。ワクチン予約サイトにログインしたらF12押してConsoleのタブ開いてコピペしてEnter押したら後は寝てればいい。ただ、あまりにも長時間動かし続けてるとベアラートークンがExpireされて登録に失敗するっぽい。たまにF5で画面更新してから再度実行して差し上げろ。Consoleで↑キーを押せば前回実行したスクリプトをまた呼び出せる。出てきたらEnter押せ。

オプションとして、会場の指定/除外もできるようになってる。会場のIDを特定してなんとかしろ。

他には例えば時間帯指定したいだとか思ったら、if文書いてやる必要あるでしょうね。「reservation_frame」の時間読んでフィルタしてやれ。

ワクチンの指定もできるね。これまたframeのitemプロパティで絞ってあげればよい。あるいは「reservation_frame」APIのURLパラメーターに「&item=1」とか指定してやれ。ワクチンの種類はlocalStorageに入ってる。

{
  "data": {
    "item": [
      {
        "id": 1,
        "name": "ファイザー社ワクチン",
        "interval": 1814400,
        "information": {
          "message": "2回目の予約は3週間後(3週間後の同じ曜日)以降で予約が可能です。※3週間後以降でも予約枠の空きがない場合は予約できません。",
          "displayed_name_kana": "ファイザーシャ",
          "vaccine_manufacturer": "ファイザー"
        }
      },
      {
        "id": 2,
        "name": "アストラゼネカ社ワクチン",
        "interval": 2419200,
        "information": {
          "message": "2回目の予約は4週間後(4週間後の同じ曜日)以降で予約が可能です。※4週間後以降でも予約枠の空きがない場合は予約できません。",
          "displayed_name_kana": "アストラゼネカシャ",
          "vaccine_manufacturer": "アストラゼネカ"
        }
      },
      {
        "id": 3,
        "name": "モデルナ社ワクチン",
        "interval": 2419200,
        "information": {
          "message": "2回目の予約は4週間後(4週間後の同じ曜日)以降で予約が可能です。※4週間後以降でも予約枠の空きがない場合は予約できません。",
          "displayed_name_kana": "タケダ、モデルナシャ",
          "vaccine_manufacturer": "武田/モデルナ"
        }
      }
    ]
  },
  "exp": 1626366253840
}

2回目の摂取はワクチン合わせる必要ある()から、id指定してやらなきゃいかんのかもね。予約失敗してくれるんであればあのコードそのまま流用できるけど。

あ、動作の保証はしません。もちろんしません。お前がしろ(意味不明)。実行は自己責任で。

無事に予約できましたらば、クポーンを忘れず携えてワクチン接種会場で僕と握手!(濃厚接触)

◆結

そんなこんなで無事に1回目ワクチンの予約を明日&近場に設定できたわけです。なんだ。使い易くて良いサイトじゃないか。イェイイェイ。