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?

はじめに

株式会社ビザスク - VisasQ Inc. Advent Calendar 2025slack Advent Calendar 2025 の 22日目 としての投稿です (空いてたので埋めさせていただきました🙏)

対象

  1. (表題のとおり) Slackにて任意のチャンネルから期間指定で会話エクスポートしたい方
  2. エンタープライズプランなど、Proプランなどと違い会話エクスポートを行うと"いろいろ"出力されてしまう(らしい)プランを使っているので、任意のチャンネル(BOT を招待したチャンネル)のみで会話の期間を指定してエクスポートしたい方
    1. 全チャンネルのデータを引き出すのは怖いなぁって方にもオススメ
  3. conversations.history channels:history の権限をもたせた BOT アカウントの作成ができる方
    1. もしくは既にそのような BOT アカウントをお持ちの方
    2. プライベートチャンネルでも使いたい場合は groups:history も必要
  4. (標準を除く) 依存関係なしでサクッと使えるコードが欲しい方
  5. Node.js の実行環境がある、用意できる方
  6. 機能とコードのボリュームのあるサードパーティのツールではなく無理なくコードの内容をチェックしてから使いたい方

step1:以下のコードを保存

例: slack_exporter.js

const fs = require('fs');
const path = require('path');

class SlackMessageExporter {
  constructor() {
    this.token = process.env.SLACK_BOT_TOKEN || process.env.SLACK_TOKEN;
    if (!this.token) {
      throw new Error('Slack token not found. Please set SLACK_BOT_TOKEN or SLACK_TOKEN environment variable.');
    }
  }

  /**
   * 日付文字列(yyyy-mm-dd)をUNIXタイムスタンプに変換
   * @param {string} dateString - yyyy-mm-dd形式の日付文字列
   * @returns {number} UNIXタイムスタンプ
   */
  dateToTimestamp(dateString) {
    const date = new Date(dateString + 'T00:00:00.000Z');
    return Math.floor(date.getTime() / 1000);
  }

  /**
   * Slack APIを呼び出してメッセージを取得
   * @param {string} channelId - チャンネルID
   * @param {number} oldest - 開始時間のUNIXタイムスタンプ
   * @param {number} latest - 終了時間のUNIXタイムスタンプ
   * @param {string} cursor - ページネーション用のカーソル
   * @returns {Promise<Object>} APIレスポンス
   */
  async fetchMessages(channelId, oldest, latest, cursor = null) {
    const baseUrl = 'https://slack.com/api/conversations.history';
    const params = new URLSearchParams({
      channel: channelId,
      oldest: oldest.toString(),
      latest: latest.toString(),
      // 対象のチャンネルの投稿数が1000を越えていても後述のコードにより全件取得できるよう対策済
      limit: '1000',
      inclusive: 'true'
    });

    if (cursor) {
      params.append('cursor', cursor);
    }

    const url = `${baseUrl}?${params}`;
    
    const response = await fetch(url, {
      method: 'GET',
      headers: {
        'Authorization': `Bearer ${this.token}`,
        'Content-Type': 'application/x-www-form-urlencoded'
      }
    });

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    const data = await response.json();
    
    if (!data.ok) {
      throw new Error(`Slack API error: ${data.error}`);
    }

    return data;
  }

  /**
   * 全メッセージを取得(ページネーション対応)
   * @param {string} channelId - チャンネルID
   * @param {string} startDate - 開始日(yyyy-mm-dd)
   * @param {string} endDate - 終了日(yyyy-mm-dd)
   * @returns {Promise<Array>} 全メッセージの配列
   */
  async getAllMessages(channelId, startDate, endDate) {
    const oldest = this.dateToTimestamp(startDate);
    const latest = this.dateToTimestamp(endDate) + 86400; // 終了日の23:59:59まで含める
    
    let allMessages = [];
    let cursor = null;
    let hasMore = true;

    console.log(`Fetching messages from ${startDate} to ${endDate}...`);

    while (hasMore) {
      try {
        const response = await this.fetchMessages(channelId, oldest, latest, cursor);
        
        allMessages = allMessages.concat(response.messages || []);
        
        if (response.has_more && response.response_metadata?.next_cursor) {
          cursor = response.response_metadata.next_cursor;
          console.log(`Fetched ${allMessages.length} messages so far...`);
        } else {
          hasMore = false;
        }
        
        // API rate limit対策で少し待機
        await new Promise(resolve => setTimeout(resolve, 1000));
        
      } catch (error) {
        console.error('Error fetching messages:', error.message);
        throw error;
      }
    }

    return allMessages.reverse(); // 時系列順にソート
  }

  /**
   * メッセージをJSONファイルに保存
   * @param {Array} messages - メッセージ配列
   * @param {string} channelId - チャンネルID
   * @param {string} startDate - 開始日
   * @param {string} endDate - 終了日
   * @returns {string} 保存されたファイルパス
   */
  saveToJson(messages, channelId, startDate, endDate) {
    const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
    const filename = `slack-messages-${channelId}-${startDate}_to_${endDate}-${timestamp}.json`;
    
    const outputData = {
      metadata: {
        channel_id: channelId,
        start_date: startDate,
        end_date: endDate,
        total_messages: messages.length,
        exported_at: new Date().toISOString()
      },
      messages: messages
    };

    try {
      fs.writeFileSync(filename, JSON.stringify(outputData, null, 2), 'utf8');
      console.log(`Messages saved to: ${path.resolve(filename)}`);
      return filename;
    } catch (error) {
      console.error('Error saving file:', error.message);
      throw error;
    }
  }

  /**
   * メイン実行関数
   * @param {string} channelId - チャンネルID
   * @param {string} startDate - 開始日(yyyy-mm-dd)
   * @param {string} endDate - 終了日(yyyy-mm-dd)
   */
  async exportMessages(channelId, startDate, endDate) {
    try {
      console.log('Starting Slack message export...');
      console.log(`Channel ID: ${channelId}`);
      console.log(`Date range: ${startDate} to ${endDate}`);

      const messages = await this.getAllMessages(channelId, startDate, endDate);
      
      if (messages.length === 0) {
        console.log('No messages found in the specified date range.');
        return;
      }

      const filename = this.saveToJson(messages, channelId, startDate, endDate);
      
      console.log(`✅ Export completed successfully!`);
      console.log(`📁 File: ${filename}`);
      console.log(`📊 Total messages: ${messages.length}`);
      
    } catch (error) {
      console.error('❌ Export failed:', error.message);
      process.exit(1);
    }
  }
}

// 使用例
async function main() {
  // 設定値(実際の値に変更してください)
  const channelId = 'C1234567890';  // チャンネルID
  const startDate = '2024-01-01';   // 開始日
  const endDate = '2024-01-31';     // 終了日

  const exporter = new SlackMessageExporter();
  await exporter.exportMessages(channelId, startDate, endDate);
}

// コマンドライン引数から値を取得する場合
if (require.main === module) {
  const args = process.argv.slice(2);
  
  if (args.length !== 3) {
    console.log('Usage: node slack_exporter.js <channelId> <startDate> <endDate>');
    console.log('Example: node slack_exporter.js C1234567890 2024-01-01 2024-01-31');
    process.exit(1);
  }

  const [channelId, startDate, endDate] = args;
  
  const exporter = new SlackMessageExporter();
  exporter.exportMessages(channelId, startDate, endDate);
}

module.exports = SlackMessageExporter;

step2:SlackBOTを対象のチャンネルへ招待

例1: チャンネル: #times-adachi
例2: BOT名: ビザテク部 (名前はご自由に設定ください(コードの動作に影響なし))

  • image.png
  • image.png

※TIPS: ↑のモーダルのAboutタブの下部でチャンネルIDをコピーできます👍️

image.png

step3:SlackAPIToken,チャンネルID,開始期間,終了期間を指定し会話エクスポート

フォーマット

[~]> SLACK_TOKEN="<slackAPIToken>" node slack_exporter.js <channelId> <startDate> <endDate>

利用例

[~]> SLACK_TOKEN="<slackAPIToken>" node slack_exporter.js C0123456789 2022-05-18 2025-12-04
Starting Slack message export...
Channel ID: C0123456789
Date range: 2022-05-18 to 2025-12-04
Fetching messages from 2022-05-18 to 2025-12-04...
Fetched 1000 messages so far...
Messages saved to: ~/slack-messages-C0123456789-2022-05-18_to_2025-12-04-2025-12-31T02-03-14-501Z.json
✅ Export completed successfully!
📁 File: slack-messages-C03FWSESWHZ-2022-05-18_to_2025-12-04-2025-12-31T02-03-14-501Z.json
📊 Total messages: 1490

出力されるファイルの内容

TypeScript の型で表現するとだいたいこんな感じの JSON ファイルが取得されます

// Slack Export JSON Structure Types

export interface SlackExportData {
  metadata: ExportMetadata;
  messages: SlackMessage[];
}

export interface ExportMetadata {
  channel_id: string;
  start_date: string; // yyyy-mm-dd format
  end_date: string;   // yyyy-mm-dd format
  total_messages: number;
  exported_at: string; // ISO datetime string
}

export interface SlackMessage {
  type: string; // typically "message"
  ts: string;   // timestamp
  user?: string; // user ID
  
  // Message content
  text?: string;
  client_msg_id?: string;
  team?: string;
  
  // Message variations
  subtype?: string; // e.g., "channel_join", "channel_purpose", "thread_broadcast"
  purpose?: string; // for channel_purpose subtype
  
  // Edit information
  edited?: {
    user: string;
    ts: string;
  };
  
  // Thread information
  thread_ts?: string;
  reply_count?: number;
  reply_users_count?: number;
  latest_reply?: string;
  reply_users?: string[];
  is_locked?: boolean;
  subscribed?: boolean;
  
  // Pin information
  pinned_to?: string[];
  pinned_info?: {
    channel: string;
    pinned_by: string;
    pinned_ts: number;
  };
  
  // Reactions
  reactions?: Reaction[];
  
  // Rich content
  attachments?: Attachment[];
  blocks?: Block[];
  
  // Bot messages
  username?: string;
  bot_id?: string;
  icons?: {
    emoji?: string;
    image_36?: string;
    image_48?: string;
    image_72?: string;
  };
}

export interface Reaction {
  name: string;    // emoji name without colons
  users: string[]; // array of user IDs
  count: number;
}

export interface Attachment {
  id?: number;
  from_url?: string;
  original_url?: string;
  fallback?: string;
  text?: string;
  title?: string;
  title_link?: string;
  service_name?: string;
  service_url?: string;
  service_icon?: string;
  
  // Images
  image_url?: string;
  image_width?: number;
  image_height?: number;
  image_bytes?: number;
  
  // Author info
  author_name?: string;
  author_link?: string;
  author_icon?: string;
  author_subname?: string;
  
  // Footer
  footer?: string;
  footer_icon?: string;
  
  // Styling
  color?: string;
  indent?: boolean;
  
  // Timestamps
  ts?: number;
  
  // For Reacji Channeler or similar
  channel_id?: string;
  author_id?: string;
}

export interface Block {
  type: string;
  block_id?: string;
  elements?: BlockElement[];
}

export interface BlockElement {
  type: string;
  elements?: BlockElement[];
  text?: string;
  style?: string;
  indent?: number;
  border?: number;
  url?: string;
  range?: string;
  user_id?: string;
  channel_id?: string;
  name?: string; // for emoji
  unicode?: string;
  skin_tone?: number;
}

// Utility types for common patterns
export type MessageSubtype = 
  | 'channel_join' 
  | 'channel_purpose' 
  | 'thread_broadcast' 
  | 'channel_leave'
  | 'channel_topic'
  | 'channel_name'
  | 'channel_archive'
  | 'channel_unarchive';

export type BlockType = 
  | 'rich_text' 
  | 'rich_text_section' 
  | 'rich_text_list'
  | 'rich_text_preformatted'
  | 'rich_text_quote';

export type BlockElementType = 
  | 'text' 
  | 'emoji' 
  | 'user' 
  | 'channel' 
  | 'link'
  | 'rich_text_section'
  | 'rich_text_list';

さいごに

冒頭の「対象」のとおり、筆者としては強すぎない権限の Token で指定の範囲内で会話データを取得できる軽量なツールがほしかったので作成できて良かったです!(ご参加までに!)
エクスポートデータを LLM で活用させたり、マルコフ連鎖で遊んでみたり実務からホビーまでいろいろできたらなと思います 💪
ここまでご覧くださりありがとうございました!

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?