東村山市の公共施設予約システムに Chrome 拡張で「終了日自動入力」を仕込んだ話

TL;DR

  • 自治体の施設予約サイトで、開始日を選ぶと終了日にも同じ日付が自動で入る Chrome 拡張を作った
  • 単純に input.value = ... を書き換える方式は PrimeVue Calendar の manualInput=false モードで詰む
  • 結局「カレンダーを内部的に開いて該当日のセルをクリックする」ユーザー操作模倣方式に行き着いた
  • パネルを visibility: hidden で隠しつつ、閉じない場合のフォールバックを段階的に積んで安定化

きっかけ

東村山市の公共施設予約システム (higashimurayama-yoyaku.jp) には、空き状況検索画面に「利用日付(開始)」「利用日付(終了)」という 2 つの日付入力がある。

検索の仕様は「開始日から終了日までの範囲に空きがある施設を返す」というもの。つまり「ある日 1 日の空きを見たい」場合でも、同じ日付を 2 回入れる必要がある。地味だが、施設候補を比較したい時に何度も繰り返すと操作回数が効いてくる。

「開始日を選んだら終了日にも同じ日付が入っていてほしい」という、それだけの拡張を書くつもりだった。

最初に書いた、動かなかったコード

対象画面は PrimeVue (Vue.js) の SPA。最初に書いたのは、開始日 input の値を読み取って、終了日 input に書き込むという素直な実装だ。

function setInputValue(input, value) {
  const setter = Object.getOwnPropertyDescriptor(
    HTMLInputElement.prototype, 'value'
  ).set;
  setter.call(input, value);
  input.dispatchEvent(new Event('input', { bubbles: true }));
  input.dispatchEvent(new Event('change', { bubbles: true }));
}

Vue の v-model はネイティブな input.value = ... を検知できないので、HTMLInputElement.prototype のネイティブセッターを直接呼んで input イベントを発火するのは Vue 系 SPA でよくあるパターンだ。

これでイケる、と思ったが、動かなかった。

なぜ動かないのか

DevTools で対象 input の HTML を覗くと、こんな属性が付いている。

<input
  type="text"
  role="combobox"
  class="p-inputtext p-component"
  aria-autocomplete="none"
  inputmode="none"
  ...
>

inputmode="none"aria-autocomplete="none"。要するにこの input、手で打てない前提で作られている。

PrimeVue Calendar の manualInput プロパティが false だと、コンポーネント内部で input イベントを受けて value を再パースするロジックが無効化される。表示用の DOM は更新されても、Vue の v-model は空のまま。検索ボタンを押すと「日付が入っていません」となる。

つまり、表面上の input をいくら触っても無駄だった。Calendar コンポーネントが内部で持っている state にどうやって介入するかという問題に変わる。

方針転換: ユーザー操作を模倣する

選択肢はいくつかあった。

  • Vue 内部の component instance を辿って v-model を直接書く
    Vue 3 だと DOM 要素の __vueParentComponent から辿れることがある。だが Vue や PrimeVue のバージョンに依存しすぎるし、本番ビルドだと expose されていないこともある。
  • カレンダーを開いてユーザーと同じように日付セルをクリックする
    PrimeVue 内部の date selection ロジックが正規ルートで走るので、v-model も間違いなく更新される。

後者を選んだ。Vue の private API より、UI が変わらない限り壊れにくい。

実装はこうなった (要約版)。

async function setEndDateViaCalendar(endInput, target) {
  const trigger = endInput.closest('.p-calendar')
                          .querySelector('.p-datepicker-trigger');

  trigger.click();                    // カレンダーを開く
  const panel = await waitFor(...);   // パネル DOM が現れるのを待つ

  // 目的の年月までナビゲート
  while (現在年月 !== target) {
    panel.querySelector('.p-datepicker-next').click();
    await sleep(60);
  }

  // 該当日のセルをクリック
  for (const cell of panel.querySelectorAll('td span')) {
    if (cell.textContent.trim() === String(target.day)) {
      cell.click();
      break;
    }
  }
}

実装してテストすると、確かに終了日が入るようになった。検索ボタンも通る。

ただ、あるじゃないか、見た目の問題が。

UX の壁: カレンダーが画面にチラつく

ユーザーが開始日を選んだ瞬間、終了日のカレンダーがパッと開いて閉じる。短い時間でも気持ちよくない。

「裏で勝手にやる」を実現するために、CSS で隠す方針にした。

const HIDE_CLASS = 'higashimurayama-ext-suppress-datepicker';
const style = document.createElement('style');
style.textContent = `
  body.${HIDE_CLASS} .p-datepicker { visibility: hidden !important; }
`;
document.head.appendChild(style);

操作中だけ body にこのクラスを付け、終わったら外す。display: none ではなく visibility: hidden にしたのは理由がある。

  • display: none だとレイアウトから外れる上、PrimeVue の側でクリックイベントが届かないリスクがある
  • visibility: hidden ならレイアウトとイベントは生きたまま、見た目だけ消える

これで、開始日を選ぶと裏でカレンダーが開閉して、ユーザーには終了日が「最初から入っていた」かのように見える状態になった。

…はずだった。

安定化: 閉じないカレンダー問題

実際に何度も試していると、ときどき終了日のカレンダーが開きっぱなしになる。

タイミング依存の問題だ。月をまたぐ操作のときに次月送りボタンの click が間に合わずに現在月の判定がブレたり、日セルへの click が PrimeVue の outside-click 検知と競合して閉じる/閉じないが不安定になったりしているらしい。

一発で確実に閉じる方法がないなら、閉じない時のフォールバックを階段状に重ねる アプローチに切り替えた。

// 1. 自然に閉じるのを 600ms 待つ
let closed = await waitForPanelClosed(endInput, 600);

if (!closed) {
  // 2. trigger をもう一度クリック (PrimeVue は toggle なので閉じる)
  trigger.click();
  closed = await waitForPanelClosed(endInput, 400);
}

if (!closed) {
  // 3. ESC キー + document クリックで強制クローズ
  document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', ... }));
  document.body.click();
  await waitForPanelClosed(endInput, 400);
}

「うまくいけば 1 で終わる、ダメなら 2、それでもダメなら 3」というレイヤー構成にしたら、開きっぱなしになるケースを潰せた。

ついでに、前段でも安全策を 2 つ。

// 既に何らかの理由でパネルが開いていたら、まず閉じてから開き直す
if (getPanelForInput(endInput)) {
  trigger.click();
  await waitForPanelClosed(endInput, 300);
}

// 多重実行防止フラグ
if (working) return;
working = true;

学んだこと

1. SPA 拡張機能では「セッターを呼べば動く」が通用しないことがある

普通の Vue アプリなら native value setter + input イベントで v-model が更新される。だが PrimeVue Calendar のように manualInput=false で input をユーザーが直接打てないモードのコンポーネントは、入力イベントを受け取らない設計になっている。

ライブラリの内部仕様に踏み込まずに済む方法を最初に検討するのは正しいが、そのライブラリがどんな前提でユーザー入力を受けているのか を確認しないと数時間溶かす。今回は inputmode="none" が決定的なヒントだった。

2. ユーザー操作模倣は「見た目」と「タイミング」の両方が課題になる

UI 経由で値を入れるのはロジック的には正攻法だが、ユーザーから見ると「勝手にカレンダーが開いた」となる。visibility: hidden で隠せばいいのだが、その間に内部状態が安定するまで適切に待つ必要がある。

非同期的に進む処理を「期待結果が満たされるまで待つ」スタイル (waitFor) で書くと、待ち時間を固定値で決め打ちするより安定する。

3. 閉じない時の段階的フォールバック

タイミング依存の不安定さは、ひとつの完璧な手順を探すよりも、「失敗したらこっち」を階段状に重ねる方が現実的に堅牢になることが多い。

今回は trigger 再クリック → ESC → document クリック の三段。実運用で 1 段目で終わらないケースに気付いた時、「2 段目を足す」ことが容易だった。

4. ポーリングは恥ずかしい技術ではない

「500ms ポーリングで input の value 変化を見る」というと牧歌的に聞こえるが、SPA で対象 DOM が再生成され得る環境では MutationObserver を特定ノードに張るより堅い。実装も短く、何が起きているか一目で分かる。

成果物

実装は約 300 行のバニラ JS、依存ライブラリなし、ビルド工程なし。Manifest V3 の content script を 1 ファイル書くだけ。

要件定義書と設計書もあわせて整理してある。今回得た「manualInput=false の壁」「visibility:hidden 戦略」「段階的フォールバック」のような知見は、別の自治体サイトや別の PrimeVue 製サイトを拡張するときも素直に流用できそうだと思っている。

おわりに

たかが「日付を 1 個コピーする」だけの拡張で、3 段階くらい設計を書き直した。

最初の実装で動かなかった時、「Vue だから setter で書けばいいんでしょ」という思い込みでハマっていたら、答えは違う層にあった。inputmode="none" という一行が見えていれば、最初から方針を変えられたかもしれない。

DOM の小さな属性は嘘をつかない。困ったらまずブラウザで対象要素を眺める、というのは結局いつも正しい。

コメント

タイトルとURLをコピーしました