1
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?

Teamsチャネルに投稿されたテキストをブックマークレットでスクレイピングする(代理投稿)

1
Last updated at Posted at 2025-11-04

はじめに

Teamsチャットをもう見たくない、という思いから本コードを開発してもらいました。プロジェクトのコミュニケーションツールとして、Teamsを使っています。最近、プロジェクトを管理する立場になり、個人やチームへ適宜チャット・チャネルへの投稿し、社外への連絡も含め多くの時間をチャットを読み込む必要がでてきてしまいました。すぐチャットが埋もれてしまい「あの時議論したチャットどこだっけな」が定期的に発生し、悪循環でした。

そんな中で、「TeamsのAPIが使えないけどテキストだけでもスクレイピングできないかなー」 と思っていたところ、あるプロジェクトで一緒になった某社Mさんから「できるで(関西弁)」 と言っていただき、あれよあれよと実装していただきました。

本来、Mさん自身によってQiita投稿をしていただきたいところですが、諸事情により難しいとのことでした。とはいえ、人づてでこのコードが広がっていった際に、彼の功績が埋もれてしまうのは避けたいと思い、いったん私のほうで本記事を投稿させていた次第です。
Mさんご本人には了解済みですが、取り巻く事情・状況が変わりましたら是非ご連絡ください。ご自身で投稿いただける状況になることをお待ちしております。

ブックマークレットとは

ブックマークから実行できるJavaScriptプログラムのことです。ブラウザのブックマークバーに登録し、お気に入りのURLを開く操作からプログラムを実行できます。

使い方

  1. コードをコピーしておく
  2. お気に入りに登録した、適当なリンクのURL部分にコードを貼って保存する
  3. リンクの名前を適当に変更する
  4. Teamsをブラウザ版から開く
  5. スクレイピングしたいTeamsチャネルを開く
  6. 編集したお気に入りを選択し、ブックマークレットを実行する
  7. スクレイピングが開始される。省エネモードなどで、ログアウトしないようにする
  8. スクレイピング結果がCSVで出力される

コード

qiita.rb
javascript:(function(){

  /* 繰り返し処理の確認サイクル(ミリ秒指定) */
  const LOOP_INTERVAL = 200;

  /* 繰り返し処理の確認最大回数 */
  const LOOP_MAX_CHECK_COUNT = 16;

  /* 上スクロール */
  const SCROLL_DISTANCE_UP = -10000;

  /* 下スクロール */
  const SCROLL_DISTANCE_DOWN = 400;

  /* CSV用エスケープ処理 */
  function escapeCSV(value) {
    return `"${(value || "").replace(/"/g, "''")}"`;
  }

  /* 時間文字列編集処理 */
  function formatTime(timeStr) {
    if (timeStr.includes("昨日の")) {
      /* 「昨日の99:99」という表記の場合(昨日日付の場合)の編集処理 */
      const match = timeStr.match(/昨日の\s+(\d{1,2}):(\d{2})/);
      if (match) {
        const [, hour, minute] = match;
        const date = new Date();
        date.setDate(date.getDate() - 1);
        const year = date.getFullYear();
        const month = String(date.getMonth() + 1).padStart(2, "0");
        const day = String(date.getDate()).padStart(2, "0");
        return `${year}/${month}/${day} ${hour.padStart(2, "0")}:${minute}`;
      }
    } else if (timeStr.includes("") && timeStr.includes("") && timeStr.includes("")) {
      /* 「9999年99月99日 99:99」という表記の場合(昨日日付より前の日付の場合)の編集処理 */
      const match = timeStr.match(/(\d{4})(\d{1,2})(\d{1,2})\s+(\d{1,2}):(\d{2})/);
      if (match) {
        const [, year, month, day, hour, minute] = match;
        return `${year}/${month.padStart(2, "0")}/${day.padStart(2, "0")} ${hour.padStart(2, "0")}:${minute}`;
      }
    } else {
      /* 「99:99」という表記の場合(本日日付の場合)の編集処理 */
      const match = timeStr.match(/(\d{1,2}):(\d{2})/);
      if (match) {
        const [, hour, minute] = match;
        const date = new Date();
        const year = date.getFullYear();
        const month = String(date.getMonth() + 1).padStart(2, "0");
        const day = String(date.getDate()).padStart(2, "0");
        return `${year}/${month}/${day} ${hour.padStart(2, "0")}:${minute}`;
      }
    }
    return timeStr;
  }

  /* 上スクロール繰り返し処理 */
  async function upScrollUntilContentStops(selecter) {
    let lastContent = "";
    let checkCount = 0;
    while (true) {
      let scrollTarget = await document.querySelector(selecter);
      if (scrollTarget) {
        /* 上へのスクロール処理(変化がなければ処理終了) */
        checkCount += 1;
        scrollTarget.scrollTop += SCROLL_DISTANCE_UP;
        const currentContent = scrollTarget.innerText.slice(0, 10000);
        if (currentContent !== lastContent) {
          lastContent = currentContent;
          checkCount = 0;
        }
        if (checkCount > LOOP_MAX_CHECK_COUNT) {
          break;
        }

        /* 待機処理 */
        await new Promise(resolve => setTimeout(resolve, LOOP_INTERVAL));
      }
    }
  }

  /* チャネルの対象サブジェクトの情報を取得 */
  async function channelSubjectDetail(subjectId, rows, messageIdList) {
    /* 直前の投稿日時,編集日時,投稿者,サブジェクト名を保持するための領域 */
    let bk_postTime = "";
    let bk_editTime = "";
    let bk_speaker = "";
    let bk_subjectTitle = "";

    let lastContent = "";
    let checkCount = 0;
    while (true) {
      let scrollTarget = document.querySelector("div[data-tid='channel-replies-viewport']");
      if (scrollTarget) {
        let checkCount = 0;
        while (true) {
          /* 各メッセージ領域のエリアをリスト形式で取得 */
          let messageBodyElements = document.querySelectorAll("div[id^='message-body-']");

          /* 各メッセージ領域のエリアを一つずつ処理 */
          messageBodyElements.forEach(messageBodyElement => {
            /* 取得したメッセージIDが未処理である場合のみCSV出力処理 */
            messageId = messageBodyElement.getAttribute("id").trim();
            if (!messageIdList.includes(messageId)) {
              /* 投稿日時を取得 */
              let postTimeElement = messageBodyElement.querySelector("time");
              let postTimeTmp = postTimeElement ? postTimeElement.getAttribute("aria-label") || "" : "";
              let postTime = formatTime(postTimeTmp);

              /* 編集日時を取得 */
              let editTimeElement = messageBodyElement.querySelector("span[id^='edited-']");
              let editTimeTmp = editTimeElement ? editTimeElement.getAttribute("title").trim().replace(/\s+/g, " ") : "";
              let editTime = formatTime(editTimeTmp);

              /* 投稿者を取得 */
              let speaker = "";
              if (postTimeElement) {
                speaker = postTimeElement.parentElement.querySelector("span[id^='author-']").textContent.trim();
              }

              /* サブジェクト名,投稿メッセージ,URLを取得 */
              let subjectTitle = "";
              let message = "";
              let messageUrl = "";
              let messageBodyInnerElement = messageBodyElement.querySelector("div[data-testid='message-body-flex-wrapper']");
              if (messageBodyInnerElement) {
                /* サブジェクト名を取得 */
                let subjectElement = messageBodyInnerElement.querySelector(":scope > h2");
                subjectTitle = subjectElement ? subjectElement.textContent.trim().replace(/\s+/g, " ") : "";

                /* 投稿メッセージを取得 */
                let messageElement = messageBodyElement.querySelector("div[data-testid='message-body-flex-wrapper']").querySelector("div");
                message = messageElement ? messageElement.textContent.trim().replace(/\s+/g, " ") : "";

                /* URL加工用に親メッセージIDを取得 */ 
                let parentMessageId = messageBodyInnerElement.getAttribute("data-reply-chain-id");

                /* URL加工用にメッセージIDを取得 */ 
                let urlMessageId = messageId.replace("message-body-","");

                /* URL加工用にチャネルIDを取得 */ 
                let channelId = document.querySelector("button[data-tid='sendMessageCommands-send']").getAttribute("data-track-thread-id");

                /* URLを取得 */ 
                messageUrl = "https://teams.microsoft.com/l/message/" + channelId + "/" + urlMessageId + "?parentMessageId=" + parentMessageId;
              }

              /* 投稿日時,投稿者,サブジェクト名が設定されていない場合(連続投稿されている場合やメッセージ削除された場合)、直前の投稿日時,投稿者,サブジェクト名を上書き設定 */
              if (!postTime.trim()) {
                postTime = bk_postTime;
              }
              if (!speaker.trim()) {
                speaker = bk_speaker;
              }
              if (!subjectTitle.trim()) {
                subjectTitle = bk_subjectTitle;
              }

              /* サブジェクト名,投稿日時,編集日時,投稿者,メッセージ,URL\をCSV出力 */
              rows.push([escapeCSV(subjectTitle), escapeCSV(postTime), escapeCSV(editTime), escapeCSV(speaker), escapeCSV(message), escapeCSV(messageUrl)].join(","));

              /* 投稿日時,編集日時,投稿者,サブジェクト名を退避 */
              bk_postTime = postTime;
              bk_editTime = editTime;
              bk_speaker = speaker;
              bk_subjectTitle = subjectTitle;

              /* 今回処理したメッセージIDを取得済のメッセージIDリストに追加 */
              messageIdList.push(messageId);
            }
          });

          /* 下へのスクロール処理(変化がなければ処理終了) */
          checkCount += 1;
          scrollTarget.scrollTop += SCROLL_DISTANCE_DOWN;
          const currentContent = scrollTarget.innerText.slice(scrollTarget.innerText.length - 10000);
          if (currentContent !== lastContent) {
            lastContent = currentContent;
            checkCount = 0;
          }
          if (checkCount > LOOP_MAX_CHECK_COUNT) {
            break;
          }

          /* 待機処理 */
          await new Promise(resolve => setTimeout(resolve, LOOP_INTERVAL));
        }

        /* 「チャネルに移動」ボタンをクリック(チャネル画面に戻る) */
        await new Promise(resolve => setTimeout(resolve, LOOP_INTERVAL));
        const buttonElements = document.querySelectorAll("button");
        const channelReturnElemet = Array.from(buttonElements).find(btn => btn.textContent.includes("チャネルに移動"));
        if (channelReturnElemet) {
          channelReturnElemet.click();

          /* チャネル画面に戻り、元のサブジェクトが表示されている位置に戻ったことを確認 */
          while (true) {
            /* 待機処理 */
            await new Promise(resolve => setTimeout(resolve, LOOP_INTERVAL));
            const targetElement = document.querySelector("div[data-mid='" + subjectId + "']");
            if (targetElement) {
              break;
            }
          }

          break;
        }
      }
    }
  }

  /* Teasmチャット画面のCSVダウンロード処理 */
  async function chatDownloadCSV() {
    /* 重複取得防止のために取得済のメッセージIDを管理 */
    let messageIdList = [];

    /* CSV出力用領域 */
    let rows = [];

    /* 一番上から少しずつ下にスクロールしながら情報抽出 */
    let lastContent = "";
    let checkCount = 0;
    while (true) {
      /* 各メッセージ領域のエリアをリスト形式で取得 */
      let messageWrapperElements = document.querySelectorAll("div[class='fui-Primitive'][data-testid='message-wrapper']");

      /* 各メッセージ領域のエリアを一つずつ処理 */
      for (const messageWrapperElement of messageWrapperElements) {
        let messageBodyElement = messageWrapperElement.querySelector("div[id^='message-body-']");

        /* 取得したメッセージIDが未処理である場合のみCSV出力処理 */
        messageId = messageBodyElement.getAttribute("id").trim();
        if (!messageIdList.includes(messageId)) {
          /* 投稿日時を取得 */
          let postTimeElement = messageWrapperElement.querySelector("time");
          let postTimeTmp = postTimeElement ? postTimeElement.getAttribute("aria-label") || "" : "";
          let postTime = formatTime(postTimeTmp);

          /* 編集日時を取得 */
          let editTimeElement = messageWrapperElement.querySelector("span[id^='edited-']");
          let editTimeTmp = editTimeElement ? editTimeElement.getAttribute("title").trim().replace(/\s+/g, " ") : "";
          let editTime = formatTime(editTimeTmp);

          /* 投稿者を取得 */
          let speaker = "";
          if (postTimeElement) {
            speaker = postTimeElement.parentElement.querySelector("span[data-tid='message-author-name']").textContent.trim();
          }

          /* 投稿メッセージを取得 */
          let messageElement = messageWrapperElement.querySelector("div[id^='content-']");
          let message = messageElement ? messageElement.textContent.trim().replace(/\s+/g, " ") : "";

          /* 投稿日時,編集日時,投稿者,メッセージ\をCSV出力 */
          rows.push([escapeCSV(postTime), escapeCSV(editTime), escapeCSV(speaker), escapeCSV(message)].join(","));

          /* 今回処理したメッセージIDを取得済のメッセージIDリストに追加 */
          messageIdList.push(messageId);
        }
      }

      /* 下へのスクロール処理(変化がなければ処理終了) */
      checkCount += 1;
      let scrollTarget = document.querySelector("div[data-tid='message-pane-list-viewport']") || document.querySelector("div[data-tid='channel-pane-viewport']");
      scrollTarget.scrollTop += SCROLL_DISTANCE_DOWN;
      const currentContent = scrollTarget.innerText.slice(scrollTarget.innerText.length - 10000);
      if (currentContent !== lastContent) {
        lastContent = currentContent;
        checkCount = 0;
      }
      if (checkCount > LOOP_MAX_CHECK_COUNT) {
        break;
      }

      /* 待機処理 */
      await new Promise(resolve => setTimeout(resolve, LOOP_INTERVAL));
    }

    if (rows.length === 0) {
      alert("Teamsチャットの投稿が1件も見つかりませんでした。");
      return;
    }

    /* CSV出力処理 */
    let csvContent = "\uFEFF" + "投稿日時,編集日時,投稿者,メッセージ,URL\n" + rows.join("\n");
    let blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
    let url = URL.createObjectURL(blob);

    let a = document.createElement("a");
    a.href = url;
    let tempStr = "";
    let participantElement = document.querySelector("ul[aria-label='チャット参加者']");
    if ( participantElement && participantElement.textContent != "") {
      let chatMemberElements = participantElement.querySelectorAll("span[id^='chat-topic-person-']");
      tempStr = Array.from(chatMemberElements)
        .map(chatMemberElement => chatMemberElement.textContent.trim())
        .filter(chatMember => chatMember !== "")
        .join("&");
    } else {
      let chatTileElement = document.querySelector("h2[data-tid='chat-title']");
      tempStr = chatTileElement ? chatTileElement.textContent.trim().replace(/\s+/g, " ") : "";
    }
    a.download = "チャット内容(" + tempStr + ").csv";
    a.style.display = "none";
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
  }

  /* Teasmチャネル画面のCSVダウンロード処理 */
  async function channelAndDownloadCSV() {

    /* 重複取得防止のために取得済のサブジェクトIDを管理 */
    let subjectIdList = [];

    /* 重複取得防止のために取得済のメッセージIDを管理 */
    let messageIdList = [];

    /* CSV出力用領域 */
    let rows = [];

    /* 直前の投稿日時,編集日時,投稿者,サブジェクト名を保持するための領域 */
    let bk_postTime = "";
    let bk_editTime = "";
    let bk_speaker = "";
    let bk_subjectTitle = "";

    /* 一番上から少しずつ下にスクロールしながら情報抽出 */
    let lastContent = "";
    let checkCount = 0;
    while (true) {
      /* 各メッセージ領域のエリアをリスト形式で取得 */
      let messageAreaElements = await document.querySelectorAll("div[data-tid='channel-pane-message']");

      /* 各メッセージ領域のエリアを一つずつ処理 */
      for (const messageAreaElement of messageAreaElements) {

        /* サブジェクトIDを取得 */
        let subjectId = messageAreaElement.querySelector("h2[data-tid='subject-line']").parentElement.getAttribute("data-mid").trim();

        /* 取得したサブジェクトIDが未処理である場合のみ処理 */
        if (!subjectIdList.includes(subjectId)) {

          /* 今回処理したサブジェクトIDを取得済のサブジェクトIDリストに追加 */
          subjectIdList.push(subjectId);

          /* 省略リンク要素を取得 */
          let summaryElement = messageAreaElement.querySelector("button[data-tid='response-summary-button']");

          /* 省略リンク要素の有無で処理を分岐 */
          if (summaryElement) {

            /* 省略リンクをクリック */
            await summaryElement.click();

            /* 上スクロール繰り返し処理を呼び出す */
            await upScrollUntilContentStops("div[data-tid='channel-replies-viewport']");

            /* 対象サブジェクトのチャネル情報を取得 */
            await channelSubjectDetail(subjectId, rows, messageIdList);

            break;
          } else {
            /* 各メッセージ領域のエリアをリスト形式で取得 */
            let messageBodyElements = messageAreaElement.querySelectorAll("div[id^='message-body-']");

            /* 各メッセージ領域のエリアを一つずつ処理 */
            messageBodyElements.forEach(messageBodyElement => {

              /* 取得したメッセージIDが未処理である場合のみCSV出力処理 */
              messageId = messageBodyElement.getAttribute("id").trim();
              if (!messageIdList.includes(messageId)) {
                /* 投稿日時を取得 */
                let postTimeElement = messageBodyElement.querySelector("time");
                let postTimeTmp = postTimeElement ? postTimeElement.getAttribute("aria-label") || "" : "";
                let postTime = formatTime(postTimeTmp);

                /* 編集日時を取得 */
                let editTimeElement = messageBodyElement.querySelector("span[id^='edited-']");
                let editTimeTmp = editTimeElement ? editTimeElement.getAttribute("title").trim().replace(/\s+/g, " ") : "";
                let editTime = formatTime(editTimeTmp);

                /* 投稿者を取得 */
                let speaker = "";
                if (postTimeElement) {
                  speaker = postTimeElement.parentElement.querySelector("span[id^='author-']").textContent.trim();
                }

                /* サブジェクト名,投稿メッセージ,URLを取得 */
                let subjectTitle = "";
                let message = "";
                let messageUrl = "";
                let messageBodyInnerElement = messageBodyElement.querySelector("div[data-testid='message-body-flex-wrapper']");
                if (messageBodyInnerElement) {
                  /* サブジェクト名を取得 */
                  let subjectElement = messageBodyInnerElement.querySelector(":scope > h2");
                  subjectTitle = subjectElement ? subjectElement.textContent.trim().replace(/\s+/g, " ") : "";

                  /* 投稿メッセージを取得 */
                  let messageElement = messageBodyElement.querySelector("div[data-testid='message-body-flex-wrapper']").querySelector("div");
                  message = messageElement ? messageElement.textContent.trim().replace(/\s+/g, " ") : "";

                  /* URL加工用に親メッセージIDを取得 */ 
                  let parentMessageId = messageBodyInnerElement.getAttribute("data-reply-chain-id");

                  /* URL加工用にメッセージIDを取得 */ 
                  let urlMessageId = messageId.replace("message-body-","");

                  /* URL加工用にチャネルIDを取得 */ 
                  let channelId = document.querySelector("div[data-tid='response-surface']").getAttribute("id").replace("response-surface-", "");

                  /* URLを取得 */ 
                  messageUrl = "https://teams.microsoft.com/l/message/" + channelId + "/" + urlMessageId + "?parentMessageId=" + parentMessageId;
                }

                /* 投稿日時,投稿者,サブジェクト名が設定されていない場合(連続投稿されている場合やメッセージ削除された場合)、直前の投稿日時,投稿者,サブジェクト名を上書き設定 */
                if (!postTime.trim()) {
                  postTime = bk_postTime;
                }
                if (!speaker.trim()) {
                  speaker = bk_speaker;
                }
                if (!subjectTitle.trim()) {
                  subjectTitle = bk_subjectTitle;
                }

                /* サブジェクト名,投稿日時,編集日時,投稿者,メッセージ,URL\をCSV出力 */
                rows.push([escapeCSV(subjectTitle), escapeCSV(postTime), escapeCSV(editTime), escapeCSV(speaker), escapeCSV(message), escapeCSV(messageUrl)].join(","));

                /* 投稿日時,編集日時,投稿者,サブジェクト名を退避 */
                bk_postTime = postTime;
                bk_editTime = editTime;
                bk_speaker = speaker;
                bk_subjectTitle = subjectTitle;
              }
            });
          }
        }
      }

      /* 下へのスクロール処理(変化がなければ処理終了) */
      checkCount += 1;
      let scrollTarget = document.querySelector("div[data-tid='message-pane-list-viewport']") || document.querySelector("div[data-tid='channel-pane-viewport']");
      scrollTarget.scrollTop += SCROLL_DISTANCE_DOWN;
      const currentContent = scrollTarget.innerText.slice(scrollTarget.innerText.length - 10000);
      if (currentContent !== lastContent) {
        lastContent = currentContent;
        checkCount = 0;
      }
      if (checkCount > LOOP_MAX_CHECK_COUNT) {
        break;
      }

      /* 待機処理 */
      await new Promise(resolve => setTimeout(resolve, LOOP_INTERVAL));
    }

    if (rows.length === 0) {
      alert("Teamsチャネルの投稿が1件も見つかりませんでした。");
      return;
    }

    /* CSV出力処理 */
    let csvContent = "\uFEFF" + "サブジェクト名,投稿日時,編集日時,投稿者,メッセージ,URL\n" + rows.join("\n");
    let blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
    let url = URL.createObjectURL(blob);
    let a = document.createElement("a");
    a.href = url;
    let channelTitleElement = document.querySelector("h2[data-tid='channelTitle-text']");
    let channelTitle = channelTitleElement ? channelTitleElement.textContent.trim().replace(/\s+/g, " ") : "";
    a.download = "チャネル内容(" + channelTitle + ").csv";
    a.style.display = "none";
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
  }

  /* メイン処理 */
  (async () => {
    alert("CSVファイルのダウンロード処理を開始します。\n処理中はブラウザを操作しないようにしてください。\nスリーブ状態にならないようにしてください。");

    /* Teamsのチャット画面もしくはチャネル画面の場合で処理を分岐 */
    if (document.querySelector("div[data-tid='message-pane-list-viewport']")) {
      /* Teamsのチャット画面の場合 */

      /* 上スクロール繰り返し処理を呼び出す */
      await upScrollUntilContentStops("div[data-tid='message-pane-list-viewport']");

      /* Teamsチャット画面の場合、Teasmチャット画面のCSVダウンロード処理を呼び出す */
      await chatDownloadCSV();
    } else if (document.querySelector("div[data-tid='channel-pane-viewport']")) {
      /* Teamsのチャネル画面の場合 */

      /* 上スクロール繰り返し処理を呼び出す */
      await upScrollUntilContentStops("div[data-tid='channel-pane-viewport']");

      /* Teamsチャネル画面の場合、Teasmチャネル画面のCSVダウンロード処理を呼び出す */
      await channelAndDownloadCSV();
    } else {
      alert("スクロール対象の領域が見つかりませんでした。Teams画面を開いてから再実行してください。");
      return;
    }

    alert("CSVファイルのダウンロードが完了しました。");
  })();

})();

コードの解説

解説はAIメインで出力しています。細かい点は諦め、概要のみのご紹介です。

全体的な目的

このスクリプトは、TeamsのWebインターフェース上で実行されることを想定しており、現在の画面がチャット画面かチャネル画面かを判断し、それぞれの画面からメッセージの投稿日時、編集日時、投稿者、メッセージ内容、およびURLを抽出し、CSVファイルとしてダウンロードします。

  1. upScrollUntilContentStops(selecter):
    Teamsのチャットやチャネルでは、スクロールすることで過去のメッセージが動的に読み込まれるため、この関数を使って一番古いメッセージまで遡ります
    • LOOP_INTERVALLOOP_MAX_CHECK_COUNT を使用して、無限ループにならないように制御しています。コンテンツが一定回数変化しない場合、スクロールが終了したと判断します

主な処理について紹介

  1. channelSubjectDetail(subjectId, rows, messageIdList):

    • チャネル内の特定のサブジェクト(スレッド)の詳細情報を取得し、CSVデータに追加する非同期関数です
    • subjectId: 処理対象のサブジェクトのID
    • rows: CSVデータが格納される配列
    • messageIdList: 既に処理済みのメッセージIDを追跡するための配列(重複防止)
    • この関数は、サブジェクト内の各メッセージを繰り返し処理し、投稿日時、編集日時、投稿者、サブジェクト名、メッセージ内容、メッセージURLを抽出します
    • 連続投稿やメッセージ削除などで情報が欠落している場合、直前のメッセージの情報を引き継ぐロジックが含まれています
    • メッセージのURLは、Teamsの内部的なID(channelId, urlMessageId, parentMessageId)を組み合わせて生成されます
    • すべてのメッセージを処理した後、「チャネルに移動」ボタンをクリックして元のチャネル画面に戻ります
  2. chatDownloadCSV():

    • Teamsのチャット画面からメッセージをダウンロードする非同期関数です
    • messageIdList: 既に処理済みのメッセージIDを追跡するための配列(重複防止)
    • rows: CSVデータが格納される配列
    • 画面を下にスクロールしながら、表示されるメッセージを順次取得します
    • 各メッセージから、投稿日時、編集日時、投稿者、メッセージ内容を抽出し、rows に追加します
    • LOOP_INTERVALLOOP_MAX_CHECK_COUNT を使用して、スクロールが終了したと判断します
    • 最後に、収集したデータからCSVファイルを生成し、ダウンロードします。ファイル名にはチャットの参加者名またはチャットタイトルが含まれます
  3. channelAndDownloadCSV():

    • Teamsのチャネル画面からメッセージをダウンロードする非同期関数です
    • subjectIdList: 既に処理済みのサブジェクトIDを追跡するための配列(重複防止)
    • messageIdList: 既に処理済みのメッセージIDを追跡するための配列(重複防止)
    • rows: CSVデータが格納される配列
    • 画面を下にスクロールしながら、表示される各サブジェクト(スレッド)を処理します
    • 省略リンク(response-summary-button)がある場合:
      • 省略リンクをクリックしてサブジェクトの詳細画面に遷移します
      • upScrollUntilContentStops を呼び出して、サブジェクト内のすべてのメッセージを読み込みます
      • channelSubjectDetail を呼び出して、サブジェクト内のメッセージ情報を取得します
    • 省略リンクがない場合:
      • サブジェクト内の各メッセージを直接処理し、投稿日時、編集日時、投稿者、サブジェクト名、メッセージ内容、メッセージURLを抽出します
    • chatDownloadCSV と同様に、収集したデータからCSVファイルを生成し、ダウンロードします。ファイル名にはチャネルのタイトルが含まれます

メイン処理 ((async () => { ... })();)

  • ブックマークレットが実行されると、まずユーザーに処理開始の警告を表示します。
  • 現在のTeamsの画面がチャット画面 (div[data-tid='message-pane-list-viewport'] が存在するか) かチャネル画面 (div[data-tid='channel-pane-viewport'] が存在するか) を判別します。
  • それぞれの画面に応じて、upScrollUntilContentStops で一番上までスクロールした後、chatDownloadCSV または channelAndDownloadCSV を呼び出します
  • どちらの画面でもない場合は、エラーメッセージを表示します
  • 処理完了後、ユーザーに完了メッセージを表示します

注意点

  • TeamsのUI変更: Microsoft TeamsのWeb UIは頻繁に更新される可能性があります。セレクタ(data-tid, id^=, class= など)が変更されると、このスクリプトは正しく動作しなくなる可能性があります。
  • パフォーマンス: 大量のメッセージがある場合、処理に時間がかかることがあります。
  • ブラウザの操作: 処理中はブラウザを操作しないように指示されています。これは、スクリプトがDOMを操作している最中にユーザーがUIを変更すると、予期せぬエラーが発生する可能性があるためです。
  • スリーブ状態: PCがスリープ状態になるとJavaScriptの実行が一時停止するため、処理が中断される可能性があります。

おわりに

APIが利用できない環境にもかかわらず、多大なご尽力と工夫をいただき、本当にありがとうございました。
現在、このスクレイピング結果をAIによる分析に活用することで、タスク管理の効率化など、業務改善が順調に進んでおります。
M様ご自身の状況が好転され、再びご本人による情報発信が可能になること、そして、その過程で多くの新たな成果が生まれることを心より楽しみにしております。

1
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
1
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?