【chatGPT-meum】
ChatGPTの構造が変わっていて修正した

  • macOSアプリの中でChatGPTを開き、会話全文をコピーする機能を直していたら、原因が何度も形を変えて出てきました。
  • 最初は、ChatGPT側のDOM構造がまるごと変わっていたことが原因でした。
  • 直したはずが、今度はスクロールの向きを取り違えていたこと、さらにその前に応急処置として入れた仕組み自体が新しい不具合の種になっていたことが分かりました。
  • 実機のログを見せてもらいながら、仮説を一つずつ壊していく形で進めた話です。

関連記事

1. 発話が、ある日一件も見つからなくなった

DOM構造の変更で発話ノードの属性名が入れ替わり、同じ日でも複数の構造が混在していたことを示す図

1.1. 属性名が、ごっそり入れ替わっていた

ある日、自作ツールでChatGPTのセッションをコピーしようとしたら、「コピーできる発話が見つかりませんでした」というエラーが表示されるようになりました。
それまで動いていたコードは、ChatGPTの発話ノードをdata-message-author-roleという属性で探していました1。
ところが、HTMLを見ると、その属性がどこにもありません。

代わりに使われていたのは、data-chatgpt-search-unit-keyという別の属性でした。
どうも、ChatGPT側が、発話を表すDOMの作り方そのものを変えていたのです。
そこで、セレクタを新しい属性に合わせて書き直しました。

このとき、発話が見つからなかった場合の診断ログも足しておきました。
実際にページ上に存在するdata-から始まる属性名を、値や本文は含めずに全部書き出すだけのログです。

ただ、それでも解決しません。
改めて、ブラウザからChatGPTのページを開いて構造を調べると、data-scroll-rootという、また別の属性が使われていました。
セレクタをそちらに合わせて直しました。

2. 動きはするのに、向きを取り違えていた

scrollTopの向きは通常と逆になる会話があり、決め打ちせず開始時に方向を確認してから進む設計を示す図

もう一つ問題がありました。
それは、発話は取れるようになっても、会話を仮想化された画面の一番上まで自動でスクロールする処理が、ずっと安定しなかったことです2。
1回のスクロール量を計算する式で、上限を決めるためのMath.minと、下限を決めるためのMath.maxを取り違えていたことがありました。
上限のつもりが下限になっていたので、設定した幅が広いときほど一度に大きく動いてしまい、間にある発話が一度も画面に現れないまま素通りしていたのです。

直したのは、こういう計算でした。

// 直す前: 120pxが上限のつもりが下限になっていた
const stepSize = Math.max(target.clientHeight * (stepRatio / 100), 120);

// 直したあと: 120pxを上限、20pxを下限にする
const stepSize = Math.max(
  Math.min(target.clientHeight * (stepRatio / 100), 120),
  20
);Code language: JavaScript (javascript)

もう一つ、もっと厄介だったのは、スクロール位置を表す数値の向きでした。
スクロール位置は、一番上が0で、下に行くほど数値が大きくなります3。
ところが、セッションを開いたときの会話のスクロール領域は、最新の発話が0で、古い発話に向かうほど負の方向に伸びる作りでした4。

実際に入れたのは、こういう関数です。

const probeUpSign = async (el) => {
  const probeStep = Math.max(Math.min(el.clientHeight * 0.1, 40), 10);
  const before = el.scrollTop;

  el.scrollTop = before - probeStep;
  await wait(80);
  if (Math.abs(el.scrollTop - before) > 1) {
    el.scrollTop = before;
    return -1;
  }

  el.scrollTop = before + probeStep;
  await wait(80);
  if (Math.abs(el.scrollTop - before) > 1) {
    el.scrollTop = before;
    return 1;
  }

  el.scrollTop = before;
  return -1;
};Code language: JavaScript (javascript)

3. 応急処置が、次の不具合の種になっていた

動かないたびにホイール入力で押していた設計から、静観して高さや件数が増えたかどうかだけを見る設計へ変えたことを示す図

3.1. 「動かないなら押す」という設計にしていた

自動スクロールの調査を始めた当初、scrollTopに直接値を書き込んでも位置が動かないことがありました。
原因を調べると、同じ属性を持つ、しぶとい複製要素が画面外にもう一つ隠れていて、そちらを掴んでしまうことがあったためでした。
表示されている方の要素を優先して選ぶように直した上で、それでも動かない場合の保険として、実際のマウスホイール入力を送る仕組みを足しました5。

この時点では、これは妥当な判断だったと思います。
直接の書き込みが効かない場面が実際にあり、ホイール入力ならブラウザの本物の入力として扱われるからです。
ただ、この保険が、あとで別の不具合の主犯になりました。

3.2. マウスなら、上端で自然に止まるはず

スクロール方向を直したあと、実機で試すと「上には動くけれど、下りで上下運動を繰り返す」という問題が出てきました。
25ステップごとに経過をログへ出すようにして調べると、ある位置で収集件数がまったく増えないまま、ホイール入力の回数だけが際限なく増えていくことが分かりました。

直接の書き込みが効かなくなること自体は、境界に達した正しい合図のはずです。
それなのにコードでは、動かないたびに毎回ホイール入力へエスカレーションしていて、境界を一時的に超えては弾性的に戻る動きを、自分で繰り返し引き起こしていました6。

3.3. 静観して、増えたかどうかだけを見る

そこで、設計を見直しました。
ChatGPTを開いたときは最新の発話から表示されていて、上へスクロールすると過去ログを追加で読み込みながら遡り、下りに戻りながらテキストを回収する、という動作を前提に据え直します。
直接の書き込みが効かなくなったら、もう無理に押し進めず、スクロール操作を完全に止めて数秒静観し、高さや件数が実際に増えるかどうかだけを見るようにしました7。

境界に達したときに呼ぶ関数は、こうなりました。

const dwellForMoreContent = async (el, { totalMs = 6000, pollMs = 400 } = {}) => {
  const startHeight = el.scrollHeight;
  const startCount = state.order.length;
  const start = Date.now();

  while (Date.now() - start < totalMs) {
    await wait(pollMs);
    capture();
    if (el.scrollHeight !== startHeight || state.order.length !== startCount) {
      return true;
    }
  }
  return false;
};Code language: JavaScript (javascript)

増えていれば読み込み中とみなして続け、増えなければ本当に最上部だと判断して終えます。
合成ホイール入力の仕組みは、どこからも呼ばれなくなったので、コードごと削除しました。
実機で試すと、上りが66ステップ・13.6秒、下りが65ステップ・8.3秒で、震えも往復もなく完走しました。

4. 一つ直すごとに、次の実験を仕込んだ

実機ログを見ながらDOM構造・スクロール方向・応急処置の副作用という3つの仮説を一つずつ壊していったことを示す図

振り返ると、今回の一連の不具合は、原因が一つではありませんでした。
ChatGPT側の構造変化、スクロール方向の決め打ち、応急処置そのものが生んだ不具合と、少なくとも三つが重なっていて、一つを直すたびに次の層が見えてくるという進み方になりました8。

役に立ったのは、推測だけで直さず、実機のログを見せてもらいながら仮説を一つずつ壊していくやり方でした。
25ステップごとの経過ログや、動きの推移を50ミリ秒刻みで記録するログは、最初から仕込んでいたわけではありません。
行き詰まるたびに、「次はここを見たい」と足していったものです。

自分が普段マウスでスクロールしたときの感覚を思い出したことも、最後の決め手になりました。
コードの中だけで考えていると、境界で自然に止まるという当たり前の挙動を、いつの間にか忘れてしまう。

  1. data-から始まる属性は、HTMLの仕様が用意している独自データ属性の仕組みで、開発者が自由に情報を持たせられます。 – data-* HTML global attributes – MDN Web Docs
  2. 画面内に見えている分だけをDOMとして描画し、スクロールに応じて要素を入れ替える手法はリスト仮想化と呼ばれ、大量の項目を扱うUIでよく使われます。 – Virtualize large lists with react-window | web.dev
  3. scrollTopは要素の先頭からどれだけスクロールしたかをピクセル単位で表すプロパティで、範囲外の値を設定すると最小値または最大値に丸められます。 – Element: scrollTop property – MDN Web Docs
  4. flex-direction: column-reverseで最新の内容を下に固定するチャットUIの実装では、scrollTopが0から負の方向に伸びる作りになることが報告されています。 – flex column reverse with scrollbar work difference · Issue #24700 · RocketChat/Rocket.Chat
  5. Electronのwebcontents.sendInputEvent()は、mouseWheelを含む種類のイベントを本物の入力として送り込める仕組みです。 – webContents | Electron
  6. スクロール領域の端を超えようとしたときに弾むような動きを見せる挙動は一般にラバーバンド効果と呼ばれ、CSSのoverscroll-behaviorで制御できます。 – overscroll-behavior – CSS | MDN
  7. 要素が画面に入ったかどうかを検知する標準の仕組みとしてIntersection Observer APIがあり、無限スクロールの追加読み込みの合図によく使われます。 – Intersection Observer API – Web APIs | MDN
  8. 急場をしのぐための簡易な実装が積み重なり、あとから手直しのコストとして表面化する状態は技術的負債と呼ばれます。 – 技術的負債 – Wikipedia