0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

仮想スクロールのDOMを制する

背景

障害対応のためお客様主催のオンライン会議に参加する機会がありました。
現地の通訳者がいたので最低限のやり取りはできたものの、現地のメンバ同士で会話している内容は聞き取れず。
通訳者がエンジニアではないため気持ちが伝わらない禅問答(を 2週間で170H以上(駄目

録画は共有してもらえました。
でも会話の大半が中国語、何度聞いても内容を追いきれないし頭に入ってこない。
英語ではなく中国語。
※個人で中華携帯使ってますが、全然話せません。

「せめてトランスクリプトをテキストに出力できれば翻訳ツールにかけられるのに・・」

Teamsのトランスクリプトパネルを開くと沢山の文字が表示されてます。

ああ、住んでないと思います。ああ、ちょっと確認します。はい。なんかパン屋じゃ、常州関西。ええ、Ji Chan when the mi Chan Chan Chan Chan Chan Chan Chan Chan.

少なくともパン屋の話はしていない(笑
ていうかこれを翻訳したら役に立つのか本当に

ダウンロードボタンがグレーアウト。
お客様主催会議のため、ダウンロード権限が付与されていない模様。

image.png

あきらめなければ何かできるかも。だって私にできないことはない(嘘

免責事項
本手法は、自分自身が会議参加者であり、かつ会議主催者・参加者全員の同意を得た場合にのみ使用してください。
組織のセキュリティポリシーや Microsoft Teams 利用規約 に従った利用が前提です。
権限を意図的に迂回する目的での使用は禁止します。本記事の内容を利用したことによる一切の損害について、筆者は責任を負いません。

まずGraph APIを試みた

エンジニアとして最初に思いついたのは Microsoft Graph API の Get callTranscript
プログラムからトランスクリプトを取得できるAPIが公式に提供されてます。

しかし調査を進めて即座に挫折。
このAPIを呼び出すには以下の条件をすべて満たす必要がある。

条件 内容
① Graph APIスコープ OnlineMeetingTranscript.Read.All の付与が必要
② テナント管理者設定 顧客テナント側で「トランスクリプトへのGraph APIアクセス」がONである必要あり
③ アプリアクセスポリシー アプリケーション権限の場合、テナント管理者がポリシーを作成・付与している必要あり

顧客テナントでダウンロードが制限されている環境では、テナント管理者設定レベルでアクセス遮断。
APIを叩いても 403 Forbidden / GraphAccessToTranscriptsDisabled が返るだけ。
公式ドキュメントにも「要求側の回避策はありません」と明記されている。

つまり UIでダウンロードできない環境ではGraph APIも同様に使えない

それなら、画面のテキストを直接取ればいい

あきらめずに発想を転換。

「APIがダメならブラウザに表示されているDOMから直接テキストを取ればいいのでは?」

試行1:Ctrl+A → Ctrl+C

まず試したのは一番シンプルな方法。
トランスクリプトパネルをクリックして Ctrl+ACtrl+C → メモ帳に貼り付け。

取れた。でも、おかしい

1.5時間の会議のはずなのに、貼り付けたテキストは10分しかない。
パネルをスクロールすると下にまだまだ続いているのに。

期待: 1.5時間のトランスクリプト全文
実際: 画面に表示されていた約10分くらいの文字

「なんで!?スクロールしたら全部あるのに!!」

試行2:querySelectorAll でセレクタ指定

「じゃあDevToolsのコンソールからDOMを直接取ればいいじゃん」と思った。
トランスクリプト系のセレクタを片っ端から試した。

// 試したセレクタたち
document.querySelectorAll('[data-tid="closed-captions-renderer"] [class*="entry"]');
document.querySelectorAll('.ts-transcript-item');
document.querySelectorAll('[class*="transcript"]');

全部 空配列

TeamsのDOMはビルドごとにクラス名が変わるので、ネットで拾ったセレクタは当然効かない。

試行3:document.body.innerText で全体コピー

「セレクタが効かないなら、ページ全体のテキストを取ればいいじゃん」。

copy(document.body.innerText);

取れた。でも、また10分しかない

Ctrl+Aと同じ結果。
innerText はDOMに存在する要素のテキストしか取れない。
存在しない要素は取れない。

ここで初めて 仮想スクロール という壁の存在に気づいた。

仮想スクロールとは

通常のリスト表示では、全件分のDOM要素が一度に生成される。
件数が多い場合、これはパフォーマンスの問題を引き起こす。

仮想スクロール(Virtual Scroll) はこれを解決する技術。
画面に表示されている部分のDOM要素だけを生成し、スクロールに合わせて動的に差し替える仕組み。

image.png

Teamsのトランスクリプトパネルもこの仮想スクロールを採用している。
だから Ctrl+A で全選択しても、画面内の数件しか取れなかった。

アプローチ:段階スクロールで全件蓄積

解決策はシンプル。

  1. スクロールコンテナを少しずつ下にスクロールする
  2. スクロールのたびに新しく描画されたDOM要素を収集する
  3. 末尾に到達したら収集完了

ポイントは「すでに取得済みの要素を重複して登録しない」仕組み。
Teamsのトランスクリプトエントリには aria-posinset という順序番号が付与されている。
これをMapのキーとして使うことで重複を排除できる。

const result = new Map(); // key: aria-posinset(順序番号), value: テキスト

DOM構造の調査

実装前に、対象のDOM構造を把握する必要がある。
以下のスニペットで、既知のテキストを含む要素とその親構造を特定できる。

// トランスクリプトパネルを開いた状態で実行
// 画面に見えている発言テキストの一部を keyword に入れる。サンプルは「うん」
// ※ 2回目以降実行する場合はページをリロードしてから実行すること(const再宣言エラー回避)
{
  const keyword = 'うん';
  const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
  const hits = [];
  while (walker.nextNode()) {
    if (walker.currentNode.textContent.includes(keyword)) {
      const el = walker.currentNode.parentElement;
      hits.push({
        tag: el.tagName,
        class: el.className.substring(0, 80),
        parent: el.parentElement?.className.substring(0, 80)
      });
    }
  }
  console.log(JSON.stringify(hits, null, 2));
}

調査の結果、2026年6月時点のTeamsでは以下の構造だった。

役割 セレクタ
スクロールコンテナ [class*="focusZoneWithSearchBox"]
各エントリ [class*="baseEntry"]
発言テキスト [role="listitem"]
話者・時間 [class*="screenReaderFriendlyHiddenTag"]

クラス名に *= の部分一致を使っているのは、Teamsがビルドごとにクラス名にハッシュを付与するため。

実装のポイント

スクロール幅と待ち時間

sc.scrollTop += 600;
await new Promise(r => setTimeout(r, 500));

スクロール幅が小さすぎると新要素が描画されないことがある。
待ち時間が短すぎるとDOMの更新が間に合わない。
試行錯誤の結果、600px・500ms が安定していた。

安定判定で終了を検知

let stable = 0, prev = 0;
// ...
if (result.size !== prev) {
  stable = 0;
  prev = result.size;
} else {
  stable++;
  if (stable > 15) break; // 15回連続で増加なし → 末尾到達
}

scrollHeight に到達したかどうかではなく、「新規取得件数がN回連続でゼロ」を終了条件にしている。
スクロール速度のばらつきに対してロバストな実装になる。

クリップボードへのコピー

navigator.clipboard.writeText() はユーザーの操作(クリックなど)を起点としないと使えない。
コンソールから非同期で呼び出しても動作しない。
なのでDevTools組み込みの copy() 関数を使う。

ただし async 関数内から copy() を呼ぶと効かない場合がある。
結果を window._t に格納しておき、スクリプト完了後に手動で copy(window._t) を実行する設計にした。

全文取得スクリプト

コピーしてコンソールに貼り付けて実行。

(async () => {
  const sc = document.querySelector('[class*="focusZoneWithSearchBox"]');
  const result = new Map();
  sc.scrollTop = 0;
  await new Promise(r => setTimeout(r, 800));

  let stable = 0, prev = 0;

  while (true) {
    document.querySelectorAll('[class*="baseEntry"]').forEach(entry => {
      const item = entry.querySelector('[role="listitem"]');
      if (!item) return;
      const pos = item.getAttribute('aria-posinset');
      if (result.has(pos)) return;
      const label = entry.querySelector('[class*="screenReaderFriendlyHiddenTag"]')?.textContent?.trim() || '';
      const txt = item.textContent?.trim() || '';
      result.set(pos, `${label}: ${txt}`);
    });

    if (result.size !== prev) {
      if (result.size % 50 === 0) console.log(`取得中... ${result.size}件`);
      stable = 0;
      prev = result.size;
    } else {
      stable++;
      if (stable > 15) break;
    }

    sc.scrollTop += 600;
    await new Promise(r => setTimeout(r, 500));
  }

  const sorted = [...result.entries()]
    .sort((a, b) => Number(a[0]) - Number(b[0]))
    .map(e => e[1])
    .join('\n');

  window._t = sorted;
  console.log(`完了! ${result.size}セグメント, ${sorted.length}文字`);
  console.log('次のコマンドをコンソールに入力してコピー: copy(window._t)');
})();

image.png

じわじわ開始。トランスクリプトの画面を見ていると
ちょっとずつスクロールする地道な活動が分かります。

image.png

やった! 53927文字もゲット!

クリップボードの抜粋

Aさん: 行こう。
Bさん: ええ、まあ、うん。あのどこ。
山田 : ああ、クッキーの設定。
Bさん: クッキーの設定ですか
山田 : あの。そうです。
Aさん: ポッキーの設定。
Bさん: うん。ちょっとin a good。
Aさん: クッキーの設定。
Bさん: In because a performance Japan。
Aさん: パフォーマンスからクッキー。クッキー。
Bさん: Now I want shy in Khan.
Aさん: 名誉を。
Bさん: So,so,so,so,can you watch the chip in
Cさん: And then like a privacy and security,shaming,like about to leave that.
Aさん: ああ。そやな。
Aさん: A Thui Thua Thui Thu ta Thu ta Thu ta Thua Thua Thua Thua Thua Thua Thua Thua Thua Thua Thua Thu Kh
Bさん: ウォウォウ、マイさん、ウォマイさんけ。

なんだこりゃ。
無駄骨疑惑。ウォウウォウウォウwww

トランスクリプトから生成した議事録(抜粋

開発者ツール確認結果;
Applicationタブ確認時、Cookieが設定されていない。
Local Storageに必要情報が作成されていないことを確認。
Networkログ上でも正常な画面遷移処理が実行されていない状況を確認。

結論;
検証環境では必要なログ取得が完了した。一方、開発環境では信頼済みサイト設定やCookie設定の確認を行ったものの、ログイン後に白画面となる事象は解消されず、取得したHARファイルおよびログをもとに継続調査することとなった。
出席者全員の確認のうえ、本日の調査会は終了した。アクションアイテムは下記参照。

No 担当 内容
1 Aさん 取得したHARファイルを共有
2 山田 取得ログの詳細解析
3 Bさん 検証環境の接続先・遷移処理の調査
4 関係者 Cookie/信頼済みサイト設定と接続要件の再確認

まじか。
あんな意味不明の文章からどうやって・・笑

もはや人間いらない疑惑
でもトランスクリプトを取得できたら議事録作れたので目標達成です.


コピー手順(非エンジニアの方向け)

1.Teamsでトランスクリプトパネルを開く
2.ブラウザで F12 キーを押してDevToolsを開く
3.Console タブをクリック
4.上記スクリプトをコピーして貼り付け、Enter で実行
5.「完了!」が表示されたら、コンソールに以下を入力して Enter

copy(window._t)

6.クリップボードにテキストが入るので、メモ帳やWordに貼り付ける

copy() が効かない場合はファイルとしてダウンロードできる。

const blob = new Blob([window._t], {type:'text/plain'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'transcript.txt';
a.click();

トラブルシュート

セレクタが変わって取得できない

TeamsのUIアップデートでクラス名が変わった場合は、前述のDOM調査スニペットで再特定する。

取得件数が少ない

スクロールが末尾まで到達していない可能性がある。スクリプト実行後に以下で確認できる。

const sc = document.querySelector('[class*="focusZoneWithSearchBox"]');
console.log(`scrollTop: ${sc.scrollTop}, scrollHeight: ${sc.scrollHeight}`);

scrollTopscrollHeight に近い値になっていない場合は、スクロール幅(600)や待ち時間(500)を大きくして再試行。

const 再宣言エラーが出る

コンソールで複数回実行すると発生する。ページをリロードしてから再実行。

応用:他の仮想スクロールUIへの転用

今回のアプローチは以下の要素で構成されている。

  • 段階スクロール でDOMを順次描画させる
  • 一意キー(今回は aria-posinset)で重複排除しながら蓄積
  • 安定判定で末尾到達を検知

この構造はTeams固有ではない。
仮想スクロールを採用しているあらゆるWebアプリに応用できる。
対象アプリに合わせてセレクタと一意キーを変えるだけ。

まとめ

課題 解決策
UIでダウンロード権限がない Graph APIも同じ制約を受ける。別のアプローチが必要
仮想スクロールで全件DOMが存在しない 段階スクロールで順次描画・収集
重複取得 aria-posinset をMapキーに使い排除
スクロール終端の検知 新規取得件数ゼロが続いたら終了
コンソールからのクリップボードコピー window._t に格納 → copy() で取得

仮想スクロールは現代のWebアプリで広く使われている技術。
その仕組みを理解することで、今回のような「見えているのに取れない」問題をエレガントに解決できる。

それにしてもちょっとずつスクロールすればいいのにって
人間がやってることを機械で再現するだけ。
でも暗号のような文字列を議事録にするのは人間では無理。

さて。私たちの居場所はどこに・・・。いつも心に太陽を!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?