37
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Qiita APIを使ってアドベントカレンダーを盛り上げてみた 2025年Claude Codeを使って

Last updated at Posted at 2025-12-07

手作業の時代は終わりかな?

ふと、そんなことを思いました。

昨年、私はPythonを駆使して記事を執筆しました。当時は「どう書くか」を自分の頭で構築することに面白みを感じていました。

しかし、この1年で状況は一変しました。2025年、もはやAIを無視して技術を語ることはできません。

今年はAIの時代。ならば、コードもAIに書かせるべきではないか?

そんな仮説のもと、今年はすべてをAIに委ねてみることにしました。

こんな感じで指示をだしてみました。

QiitaのAPIキーも取得して、Qittaのアドベントカレンダーで、https://qiita.com/advent-calendar/2025/systemimember
このURLを元に、記事URLとタイトルと書いた人といいね数と作成日をCSVで出したい
その際、CSVはいいね数の多い順に並べる
これをSlackのWorkflowで動かして
メッセージは統計情報として、公開記事数と、合計いいね数と、いいね少ない10の記事URLと、記事タイトルを記載する(いいねお願い記事として投稿する)
そのスニペットとして、CSVを添付する
Google Workspaceが使えるので、Google Apps Scriptも使える

ある程度は、うまくいくのですが、微妙にうまく行かないとこもありました。

今回は、AI開発で直面した「微妙にうまくいかない2つの壁」について共有します。

「空気」を読まない日付問題

まずつまずいたのが、地味ですが 「日付の表示」 です。

Qiita APIから返ってくる記事データの日付は、当然ながらISO 8601形式(例:2023-12-07T10:00:00+09:00)です。 AIに「取得したデータを表示して」とコードを書かせると、AIはこれをそのまま画面に出力する実装をしてきました。

もし人間がコーディングしていたらどうでしょう? 「さすがにISO形式そのままじゃユーザーが見にくいから、+9はやめて、YYYY/MM/DD HH:MI に変換しておこう」と気を利かせたり、あるいは仕様を決める段階で「表示フォーマットはどうしましょうか?」と確認したりするはずです。

しかし、AIにはその「阿吽の呼吸」や「行間を読む」という配慮はまだありませんでした。データはあくまでデータとして忠実に扱われます。 結局、こちらから 「表示フォーマットは YYYY/MM/DD HH:MI に整形して」 と具体的に指示を出すことで解決しました。

「指示さえすれば完璧にこなすが、指示しない気配りはしない」。これがAIコーディングの第一の特徴でした。

Slack APIの「古い記憶」

次にハマったのが、Slackへのスニペット投稿機能の実装です。

AIは自信満々にコードを生成してくれましたが、実行してみるとエラーが発生して失敗します。 「コードは合っているはずなのに……」とデバッグしてみると、原因は 「AIが参照していたSlack APIの仕様が古かった」 ことでした。

ご存知の通り、Web APIの仕様変更や廃止は頻繁に起こります。しかし、AIの学習データは過去のインターネットの情報がベースになっているため、最新のAPI仕様(メソッドの変更や推奨されるライブラリの更新など)に追いついていないことが多々あります。

このケースでは、「今はそのメソッドは非推奨で、こっちを使う必要があるんだよ」と、人間側がこと細かく説明し、ドキュメントに基づいた修正指示を出す必要がありました。

前回と同様、Qiita APIのドキュメントは構成が整理され読みやすい一方、Slack APIの資料は依然として難解です。1年経ってもこの状況に変化はなく、その読みやすさの格差は、もはやAIも認識するほどです。単なる個人の主観ではなく、構造的な複雑さが解消されていない証拠とも言えるでしょう。開発者の効率を左右する部分だけに、AIですら指摘するこの現状が、早く改善されることを願います。

手作業の時代は終わったか?

今回の検証を通じて感じたのは、 「0からコードを書く手作業の時間は激減したが、レビューと指示出しの重要性は激増した」 ということです。

AIは優秀なプログラマですが、同時に「空気が読めない新入社員」であり、「知識が数年前で止まっているベテラン」のような側面も持っています。 日付のフォーマットのような「人間的なコンテキスト」を補い、API仕様のような「最新の知識」を注入してあげること。

これからのエンジニアには、コードを書く力以上に、 AIの出力を正しく導く「言語化能力」と「目利き力」 が問われる時代になったのだと痛感しました。

Claude Codeが出力したコード

以下にClaude Codeが出力したコードを添付しておきます。

qiita-advent-calendar-stats.gs
/**
 * Qiita Advent Calendar Statistics Generator
 *
 * このスクリプトは、Qiitaのアドベントカレンダーページから記事情報を取得し、
 * 統計情報をSlackに通知します。
 *
 * 必要な設定:
 * 1. Slack Bot Token(スクリプトプロパティ: SLACK_BOT_TOKEN)
 * 2. Slack Channel ID(スクリプトプロパティ: SLACK_CHANNEL_ID)
 * 3. Calendar ID(スクリプトプロパティ: CALENDAR_ID)
 * 4. Calendar Year(スクリプトプロパティ: CALENDAR_YEAR)
 */

// スクリプトプロパティから設定を取得
function getConfig() {
  const scriptProperties = PropertiesService.getScriptProperties();
  return {
    slackBotToken: scriptProperties.getProperty('SLACK_BOT_TOKEN'),
    slackChannelId: scriptProperties.getProperty('SLACK_CHANNEL_ID'),
    calendarId: scriptProperties.getProperty('CALENDAR_ID'),
    year: scriptProperties.getProperty('CALENDAR_YEAR')
  };
}

/**
 * アドベントカレンダーページから記事URLと日付を抽出
 * @param {string} calendarId - アドベントカレンダーのID(例: "systemimember")
 * @param {string} year - 年(例: "2025")
 * @return {Array} 記事情報の配列 [{url: string, date: string}]
 */
function scrapeAdventCalendarArticles(calendarId, year) {
  const url = `https://qiita.com/advent-calendar/${year}/${calendarId}`;

  try {
    const response = UrlFetchApp.fetch(url, {muteHttpExceptions: true});
    const statusCode = response.getResponseCode();

    if (statusCode !== 200) {
      Logger.log(`Scraping Error: Status ${statusCode}`);
      throw new Error(`Failed to fetch advent calendar page: ${statusCode}`);
    }

    const html = response.getContentText();
    const articles = [];
    const urlToDate = {};

    Logger.log('HTML length: ' + html.length);

    // 全ての記事URLを抽出
    const urlPattern = /https:\/\/qiita\.com\/[^\/"\s]+\/items\/[a-f0-9]+/g;
    const allUrls = html.match(urlPattern);

    if (allUrls) {
      Logger.log(`Total URLs found in HTML: ${allUrls.length}`);
    } else {
      Logger.log('No URLs found in HTML');
      return [];
    }

    // より柔軟なパターンで日付とURLを関連付け
    // Qiitaのアドベントカレンダーは様々なHTML構造を持つ可能性があるため、複数のアプローチを試す

    // アプローチ1: calendar-item や similar クラス/属性を探す
    const itemPattern = /<[^>]*(?:calendar-item|adventCalendarItem|item)[^>]*>([\s\S]*?)<\/[^>]+>/gi;
    let itemMatch;
    let itemIndex = 0;

    while ((itemMatch = itemPattern.exec(html)) !== null) {
      const itemHtml = itemMatch[1];
      const urlMatch = itemHtml.match(urlPattern);

      if (urlMatch) {
        const articleUrl = urlMatch[0];
        // 日付情報を探す
        const dayMatch = itemHtml.match(/(\d{1,2})(?:日|\/\d{1,2})/);
        if (dayMatch) {
          const day = parseInt(dayMatch[1]);
          if (day >= 1 && day <= 25) {
            const date = `${year}-12-${String(day).padStart(2, '0')}`;
            urlToDate[articleUrl] = date;
          }
        }
      }
      itemIndex++;
    }

    Logger.log(`Approach 1 found ${Object.keys(urlToDate).length} URLs with dates`);

    // アプローチ2: 単純に順番で割り当て(日付が取れない場合のフォールバック)
    if (Object.keys(urlToDate).length === 0 && allUrls.length > 0) {
      Logger.log('Falling back to sequential date assignment');
      const uniqueUrls = [...new Set(allUrls)];
      uniqueUrls.forEach((url, index) => {
        if (index < 25) {
          const day = index + 1;
          const date = `${year}-12-${String(day).padStart(2, '0')}`;
          urlToDate[url] = date;
        }
      });
    }

    // 結果を配列に変換
    for (const [url, date] of Object.entries(urlToDate)) {
      articles.push({ url: url, date: date });
    }

    // 日付順にソート
    articles.sort((a, b) => a.date.localeCompare(b.date));

    Logger.log(`Found ${articles.length} unique articles with dates`);

    return articles;
  } catch (error) {
    Logger.log(`Error scraping advent calendar: ${error}`);
    throw error;
  }
}

/**
 * QiitaのURLから記事IDを抽出
 * @param {string} url - 記事URL
 * @return {string} 記事ID
 */
function extractArticleId(url) {
  const match = url.match(/\/items\/([a-f0-9]+)/);
  return match ? match[1] : null;
}

/**
 * Qiita APIから記事の詳細情報を取得
 * @param {string} articleId - 記事ID
 * @return {Object} 記事の詳細情報
 */
function fetchArticleDetails(articleId) {
  const url = `https://qiita.com/api/v2/items/${articleId}`;

  const options = {
    method: 'get',
    headers: {
      'Content-Type': 'application/json'
    },
    muteHttpExceptions: true
  };

  try {
    const response = UrlFetchApp.fetch(url, options);
    const statusCode = response.getResponseCode();

    if (statusCode !== 200) {
      Logger.log(`API Error for article ${articleId}: Status ${statusCode}, Response: ${response.getContentText()}`);
      return null; // エラーの場合はnullを返す
    }

    return JSON.parse(response.getContentText());
  } catch (error) {
    Logger.log(`Error fetching article ${articleId}: ${error}`);
    return null;
  }
}

/**
 * 記事情報リストから詳細情報を取得して整形
 * @param {Array} articleInfos - 記事情報の配列 [{url: string, date: string}]
 * @return {Array} 記事データの配列
 */
function extractArticleData(articleInfos) {
  const articles = [];

  Logger.log(`Fetching details for ${articleInfos.length} articles...`);

  articleInfos.forEach((info, index) => {
    const articleId = extractArticleId(info.url);

    if (!articleId) {
      Logger.log(`Could not extract article ID from: ${info.url}`);
      return;
    }

    // API制限を考慮して少し待機
    if (index > 0 && index % 10 === 0) {
      Utilities.sleep(1000); // 10件ごとに1秒待機
    }

    const articleData = fetchArticleDetails(articleId);

    if (articleData) {
      articles.push({
        url: articleData.url,
        title: articleData.title,
        author: articleData.user ? articleData.user.id : 'Unknown',
        likes: articleData.likes_count || 0,
        createdAt: articleData.created_at,
        updatedAt: articleData.updated_at
      });
      Logger.log(`[${index + 1}/${articleInfos.length}] Fetched: ${articleData.title}`);
    } else {
      Logger.log(`[${index + 1}/${articleInfos.length}] Failed to fetch article: ${info.url}`);
    }
  });

  // いいね数の多い順にソート
  articles.sort((a, b) => b.likes - a.likes);

  Logger.log(`Successfully fetched ${articles.length} articles`);

  return articles;
}

/**
 * CSVデータを生成
 * @param {Array} articles - 記事データの配列
 * @return {string} CSV形式のデータ
 */
function generateCSV(articles) {
  const headers = ['記事URL', 'タイトル', '著者', 'いいね数', '作成日'];
  const rows = [headers.join(',')];

  articles.forEach(article => {
    // ISO 8601形式の日付を日本時間に変換
    const createdDate = new Date(article.createdAt);
    const jstDateString = Utilities.formatDate(createdDate, 'Asia/Tokyo', 'yyyy-MM-dd HH:mm:ss');

    const row = [
      `"${article.url}"`,
      `"${article.title.replace(/"/g, '""')}"`, // CSVエスケープ
      `"${article.author}"`,
      article.likes,
      `"${jstDateString}"`
    ];
    rows.push(row.join(','));
  });

  return rows.join('\n');
}

/**
 * 統計情報を計算
 * @param {Array} articles - 記事データの配列
 * @return {Object} 統計情報
 */
function calculateStatistics(articles) {
  const totalArticles = articles.length;
  const totalLikes = articles.reduce((sum, article) => sum + article.likes, 0);

  // ワースト10(いいね数が少ない順)
  const worst10 = [...articles]
    .sort((a, b) => a.likes - b.likes)
    .slice(0, Math.min(10, articles.length));

  return {
    totalArticles,
    totalLikes,
    worst10
  };
}

/**
 * Slackメッセージを作成(テキスト形式)
 * @param {Object} stats - 統計情報
 * @param {string} calendarUrl - アドベントカレンダーのURL
 * @return {Object} Slack用のメッセージペイロード
 */
function createSlackMessage(stats, calendarUrl) {
  const worst10Text = stats.worst10
    .map((article, index) => `${index + 1}. ${article.title} (${article.likes} いいね)\n   ${article.url}`)
    .join('\n\n');

  // テキスト形式のメッセージを作成
  const messageText = `
━━━━━━━━━━━━━━━━━━━━
📊 いいねお願い記事
━━━━━━━━━━━━━━━━━━━━

🔗 アドベントカレンダー
${calendarUrl}

📈 統計情報
• 公開記事数: ${stats.totalArticles}件
• 合計いいね数: ${stats.totalLikes}件

━━━━━━━━━━━━━━━━━━━━
👍 いいねお願い記事(TOP10)
━━━━━━━━━━━━━━━━━━━━

${worst10Text || 'データなし'}

━━━━━━━━━━━━━━━━━━━━
📋 詳細なデータはCSVファイルを参照してください
`.trim();

  return {
    text: messageText
  };
}

/**
 * メッセージとCSVファイルを一緒にSlackに送信
 * @param {string} messageText - メッセージテキスト
 * @param {string} csv - CSVデータ
 * @param {string} filename - ファイル名
 * @param {string} channelId - SlackチャンネルID
 */
function sendMessageWithCSVToSlack(messageText, csv, filename, channelId) {
  const config = getConfig();

  if (!config.slackBotToken) {
    Logger.log('Slack Bot Token not configured.');
    throw new Error('Slack Bot Token not configured');
  }

  if (!channelId) {
    Logger.log('Slack Channel ID not configured.');
    throw new Error('Slack Channel ID not configured');
  }

  try {
    // ステップ1: ファイルアップロードURLを取得
    const getUploadUrlEndpoint = 'https://slack.com/api/files.getUploadURLExternal';

    const fileBytes = Utilities.newBlob(csv, 'text/csv', filename).getBytes();
    const fileLength = fileBytes.length;

    const getUploadUrlPayload =
      'filename=' + encodeURIComponent(filename) +
      '&length=' + fileLength;

    const getUploadUrlOptions = {
      method: 'post',
      headers: {
        'Authorization': `Bearer ${config.slackBotToken}`,
        'Content-Type': 'application/x-www-form-urlencoded'
      },
      payload: getUploadUrlPayload,
      muteHttpExceptions: true
    };

    const uploadUrlResponse = UrlFetchApp.fetch(getUploadUrlEndpoint, getUploadUrlOptions);
    const uploadUrlData = JSON.parse(uploadUrlResponse.getContentText());

    if (!uploadUrlData.ok) {
      Logger.log(`Slack Get Upload URL Error: ${uploadUrlData.error || 'Unknown error'}`);
      throw new Error(`Failed to get upload URL: ${uploadUrlData.error}`);
    }

    const uploadUrl = uploadUrlData.upload_url;
    const fileId = uploadUrlData.file_id;

    // ステップ2: ファイルをアップロード
    const uploadOptions = {
      method: 'post',
      payload: fileBytes,
      muteHttpExceptions: true
    };

    const uploadResponse = UrlFetchApp.fetch(uploadUrl, uploadOptions);

    if (uploadResponse.getResponseCode() !== 200) {
      throw new Error(`Failed to upload file: ${uploadResponse.getResponseCode()}`);
    }

    // ステップ3: ファイルを完了してメッセージと一緒にチャンネルに送信
    const completeEndpoint = 'https://slack.com/api/files.completeUploadExternal';

    const completePayload =
      'files=' + encodeURIComponent(JSON.stringify([{ id: fileId, title: filename }])) +
      '&channel_id=' + encodeURIComponent(channelId) +
      '&initial_comment=' + encodeURIComponent(messageText);

    const completeOptions = {
      method: 'post',
      headers: {
        'Authorization': `Bearer ${config.slackBotToken}`,
        'Content-Type': 'application/x-www-form-urlencoded'
      },
      payload: completePayload,
      muteHttpExceptions: true
    };

    const completeResponse = UrlFetchApp.fetch(completeEndpoint, completeOptions);
    const completeData = JSON.parse(completeResponse.getContentText());

    if (!completeData.ok) {
      Logger.log(`Slack Complete Upload Error: ${completeData.error || 'Unknown error'}`);
      throw new Error(`Failed to complete upload: ${completeData.error}`);
    }

    Logger.log('Successfully sent message with CSV to Slack');
  } catch (error) {
    Logger.log(`Error sending message with CSV to Slack: ${error}`);
    throw error;
  }
}

/**
 * メイン処理
 * この関数を手動実行するか、トリガーで定期実行します
 */
function main() {
  try {
    Logger.log('Starting advent calendar statistics generation...');

    const config = getConfig();
    const calendarId = config.calendarId;
    const year = config.year;
    const calendarUrl = `https://qiita.com/advent-calendar/${year}/${calendarId}`;

    // 1. アドベントカレンダーページから記事URLと日付を抽出
    Logger.log('Scraping advent calendar page...');
    const articleInfos = scrapeAdventCalendarArticles(calendarId, year);

    if (articleInfos.length === 0) {
      Logger.log('No articles found');
      return;
    }

    // 2. 各記事の詳細情報を取得して整形
    Logger.log('Fetching article details...');
    const articles = extractArticleData(articleInfos);

    if (articles.length === 0) {
      Logger.log('No articles found');
      return;
    }

    // 3. CSV生成
    Logger.log('Generating CSV...');
    const csv = generateCSV(articles);

    // 4. 統計情報を計算
    Logger.log('Calculating statistics...');
    const stats = calculateStatistics(articles);

    // 5. Slackメッセージを作成
    Logger.log('Creating Slack message...');
    const message = createSlackMessage(stats, calendarUrl);

    // 6. メッセージとCSVファイルを一緒にSlackに送信
    const timestamp = Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyyMMdd_HHmmss');
    const filename = `qiita_advent_calendar_${calendarId}_${timestamp}.csv`;
    Logger.log('Sending message with CSV to Slack...');
    sendMessageWithCSVToSlack(message.text, csv, filename, config.slackChannelId);

    Logger.log('Successfully completed!');

  } catch (error) {
    Logger.log(`Error in main: ${error}`);
    throw error;
  }
}

/**
 * 設定をセットアップする関数(初回実行用)
 * この関数を実行して、必要な認証情報を設定してください
 */
function setupConfig() {
  const scriptProperties = PropertiesService.getScriptProperties();

  // 以下の値を実際の値に置き換えて実行してください
  scriptProperties.setProperty('SLACK_BOT_TOKEN', 'YOUR_SLACK_BOT_TOKEN_HERE'); // 例: xoxb-...
  scriptProperties.setProperty('SLACK_CHANNEL_ID', 'YOUR_SLACK_CHANNEL_ID_HERE'); // 例: C1234567890
  scriptProperties.setProperty('CALENDAR_ID', 'CALENDAR_ID'); // アドベントカレンダーのID
  scriptProperties.setProperty('CALENDAR_YEAR', 'CALENDAR_YEAR'); // 年

  Logger.log('Configuration saved successfully');
}
README.md
# Qiita Advent Calendar Statistics Generator

Qiitaのアドベントカレンダーの統計情報を自動取得し、Slackに通知するGoogle Apps Scriptです。

## 概要

このツールは以下を自動で実行します:

1. Qiitaアドベントカレンダーページから記事情報をスクレイピング
2. Qiita APIで各記事の詳細(いいね数、著者など)を取得
3. いいね数が少ない記事TOP10をリストアップ
4. 統計情報とCSVファイルをSlackに投稿

## 主な機能

- ✅ アドベントカレンダーの全記事を自動収集
- ✅ いいね数の少ない記事TOP10を表示
- ✅ 記事の詳細情報をCSVで出力
- ✅ Slackに統計情報とCSVを1つのメッセージで投稿
- ✅ 日本時間で日付表示
- ✅ 定期実行可能(トリガー設定)

## セットアップ

### 前提条件

- Googleアカウント
- Slackワークスペースの管理者権限

---

### ステップ1: Slack Appの作成

#### 1-1. アプリを作成

1. [Slack API](https://api.slack.com/apps) にアクセス
2. 「Create New App」をクリック
3. 「From scratch」を選択
4. App Name: `Qiita Stats`(任意)
5. ワークスペースを選択
6. 「Create App」をクリック

#### 1-2. Bot Token Scopesを追加

1. 左サイドバー「OAuth & Permissions」をクリック
2. 「Scopes」セクションまでスクロール
3. 「Bot Token Scopes」で以下を追加:
   - **`files:write`** - CSVファイルをアップロード
   - **`chat:write`** - メッセージを送信

#### 1-3. Appをインストール

1. ページ上部「Install to Workspace」をクリック
2. 「許可する」をクリック
3. **Bot User OAuth Token** をコピー
   - 形式: `xoxb-...`
   - 後で使用するので保存しておく

#### 1-4. チャンネルIDを取得

1. Slackで通知先チャンネルを開く
2. チャンネル名をクリック
3. 下部「チャンネルIDをコピー」をクリック
   - 形式: `C1234567890`
   - 後で使用するので保存しておく

#### 1-5. Appをチャンネルに追加

1. 通知先チャンネルで以下を入力:
   
   /invite @Qiita Stats
   
2. Enterキーで実行

**✅ ステップ1完了 - 以下を控えておく:**
- Bot User OAuth Token (`xoxb-...`)
- Channel ID (`C1234567890`)

---

### ステップ2: Google Apps Scriptの設定

#### 2-1. プロジェクト作成

1. [Google Apps Script](https://script.google.com/) にアクセス
2. 「新しいプロジェクト」をクリック
3. プロジェクト名を入力(例: `Qiita Stats`#### 2-2. コードをコピー

1. このリポジトリの `qiita-advent-calendar-stats.gs` を開く
2. 全てコピー(Ctrl+A → Ctrl+C)
3. Google Apps Scriptエディタに貼り付け
4. Ctrl+S で保存

#### 2-3. Slack設定を構成

1. エディタ下部の `setupConfig()` 関数を探す
2. 以下のように編集:

function setupConfig() {
  const scriptProperties = PropertiesService.getScriptProperties();

  // ステップ1-3で取得したBot Token
  scriptProperties.setProperty('SLACK_BOT_TOKEN', 'xoxb-YOUR-TOKEN-HERE');

  // ステップ1-4で取得したChannel ID
  scriptProperties.setProperty('SLACK_CHANNEL_ID', 'C1234567890');

  Logger.log('Configuration saved successfully');
}

3. Ctrl+S で保存

#### 2-4. setupConfigを実行

1. 関数ドロップダウンで `setupConfig` を選択
2. 「実行」ボタン(▶️)をクリック
3. 初回のみ権限承認が必要:
   - 「権限を確認」→ アカウント選択
   - 「詳細」→ 「(プロジェクト名)に移動」
   - 「許可」
4. 実行ログに `Configuration saved successfully` と表示されればOK

---

### ステップ3: テスト実行

#### 3-1. main関数を実行

1. 関数ドロップダウンで `main` を選択
2. 「実行」をクリック
3. 実行ログを確認(15〜30秒程度):
   情報: Starting advent calendar statistics generation...
   情報: Scraping advent calendar page...
   情報: Found 7 unique articles with dates
   情報: Fetching details for 7 articles...
   情報: [1/7] Fetched: 記事タイトル
   ...
   情報: Generating CSV...
   情報: Calculating statistics...
   情報: Creating Slack message...
   情報: Sending message with CSV to Slack...
   情報: Successfully sent message with CSV to Slack
   情報: Successfully completed!

#### 3-2. Slackで確認

指定したチャンネルに以下が投稿されます:

**📊 投稿内容:**
━━━━━━━━━━━━━━━━━━━━
📊 いいねお願い記事
━━━━━━━━━━━━━━━━━━━━

🔗 アドベントカレンダー
https://qiita.com/advent-calendar/2025/systemimember

📈 統計情報
• 公開記事数: 7件
• 合計いいね数: 42件

━━━━━━━━━━━━━━━━━━━━
👍 いいねお願い記事(TOP10)
━━━━━━━━━━━━━━━━━━━━

1. 記事タイトル1 (3 いいね)
   https://qiita.com/...

2. 記事タイトル2 (4 いいね)
   https://qiita.com/...

...


**📎 添付ファイル:**
- `qiita_advent_calendar_systemimember_20251207_221708.csv`
- 全記事の詳細データ(URL、タイトル、著者、いいね数、作成日)

---

## 使い方

### 手動実行

上記ステップ3と同じ手順でいつでも実行できます。

### 定期実行(自動化)

毎日自動で統計を送信したい場合:

1. 左サイドバー「トリガー」(⏰)をクリック
2. 「トリガーを追加」をクリック
3. 設定:
   - 実行する関数: `main`
   - イベントのソース: `時間主導型`
   - トリガーのタイプ: `日付ベースのタイマー`
   - 時刻: `午前9時〜10時`(任意)
4. 「保存」

これで毎日自動的にSlackに統計が送信されます。

---

## カスタマイズ

### TOP10の件数を変更

`calculateStatistics()` 関数内を編集:

const worst10 = [...articles]
  .sort((a, b) => a.likes - b.likes)
  .slice(0, Math.min(10, articles.length)); // ← 10を変更

例: TOP5にする → `10``5` に変更

---

## トラブルシューティング

### エラー: "Slack Bot Token not configured"

- `setupConfig()` 関数でBot Tokenを設定したか確認
- Tokenが `xoxb-` で始まっているか確認

### エラー: "Slack Channel ID not configured"

- `setupConfig()` 関数でChannel IDを設定したか確認
- Channel IDが `C` で始まっているか確認

### エラー: "Failed to get upload URL: missing_scope"

Bot Token Scopesに `files:write``chat:write` が追加されているか確認:
1. [Slack API Apps](https://api.slack.com/apps) を開く
2. 作成したAppを選択
3. 「OAuth & Permissions」→「Scopes」を確認

### エラー: "Failed to complete upload: not_in_channel"

Appがチャンネルに追加されていません:
1. Slackで通知先チャンネルを開く
2. `/invite @Qiita Stats` を実行

### 記事が0件になる

- アドベントカレンダーのIDと年が正しいか確認
- URLをブラウザで開いて存在確認:
  `https://qiita.com/advent-calendar/2025/systemimember`

### メッセージは届くがCSVがない

- 実行ログで `Successfully sent message with CSV to Slack` が表示されているか確認
- Bot Token Scopesに `files:write` があるか確認
- Appがチャンネルに追加されているか確認

---

## 技術仕様

### データ取得フロー

1. **HTMLスクレイピング**
   - `https://qiita.com/advent-calendar/{year}/{calendar_id}` にアクセス
   - 正規表現で記事URL(`https://qiita.com/*/items/*`)を抽出

2. **Qiita API**
   - エンドポイント: `GET /api/v2/items/{item_id}`
   - 認証: 不要(公開記事)
   - レート制限対策: 10件ごとに1秒待機

3. **Slack API**
   - `files.getUploadURLExternal` - アップロードURL取得
   - ファイルアップロード
   - `files.completeUploadExternal` - 完了とメッセージ投稿

### CSV形式

記事URL,タイトル,著者,いいね数,作成日
"https://qiita.com/...","記事タイトル","author",10,"2025-12-01 09:00:00"

- 日付: 日本時間(Asia/Tokyo)
- エンコード: UTF-8
- ダブルクォート: `""` にエスケープ

### API制限

- Qiita API: 認証なし(60リクエスト/時間)
- Slack API: Tier 2(20リクエスト/分)
- 本スクリプト: 25記事で約3〜4秒

---

## よくある質問

### Q: Qiita APIトークンは必要ですか?

A: 不要です。公開記事の取得は認証なしで可能です。

### Q: 他のアドベントカレンダーにも使えますか?

A: はい。`calendarId` を変更するだけで使えます。

### Q: 複数のカレンダーを監視できますか?

A: `main()` 関数を複数のカレンダーIDで呼び出すように改造すれば可能です。

### Q: Incoming Webhookは使いますか?

A: いいえ。Slack APIのBot Tokenのみで動作します。

### Q: Slackの無料プランで使えますか?

A: はい、問題なく使えます。

---

## ライセンス

このスクリプトは自由に使用・改変できます。

## 参考リンク

- [Qiita API v2 ドキュメント](https://qiita.com/api/v2/docs)
- [Slack API ドキュメント](https://api.slack.com/)
- [Google Apps Script ガイド](https://developers.google.com/apps-script)

---

## 更新履歴

- 2025-12-07: 初版リリース
  - Slack APIでメッセージとCSVを1つの投稿に統合
  - いいね数少ない順TOP10表示
  - 日本時間対応
37
1
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
37
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?