手作業の時代は終わりかな?
ふと、そんなことを思いました。
昨年、私は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 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');
}
# 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表示
- 日本時間対応