はじめに
「いいね」や「ストック」は通知でわかりますが、View数ってわからないですよね。
私は「テストが気になった人が検索して引っかけて読んでくれるといいなー」と願って書いています。
なので、ちょっとずつView数が伸びていってほしいんですね。
でもそれはQiita上でパッとわからないので、自分の書いた記事のView数を集計してグラフ化してみます。
もちろんAIが。
完成
ということでAI君に、
「Qiitaの執筆した全記事のView数を毎日取得してきて、GSSにでも記録してグラフ化するGASちょーだい」
して、ちょちょっと手を加えたものがこちらです。
/***************
* 設定
***************/
const QIITA_TOKEN = 'hogehoge';
const SHEET_RAW = 'raw';
const SHEET_LATEST = 'latest';
const SHEET_SUMMARY = 'summary';
/**
* 初回セットアップ
* - シート作成
* - ヘッダー作成
* - トリガー作成
*/
function setup() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const raw = getOrCreateSheet_(ss, SHEET_RAW);
const latest = getOrCreateSheet_(ss, SHEET_LATEST);
const summary = getOrCreateSheet_(ss, SHEET_SUMMARY);
if (raw.getLastRow() === 0) {
raw.appendRow([
'記録日', '記事ID', 'タイトル', 'URL',
'いいね数', 'Stock数', 'View数', '更新日時'
]);
}
if (latest.getLastRow() === 0) {
latest.appendRow([
'記事ID', 'タイトル', 'URL',
'いいね数', 'Stock数', 'View数', '更新日時'
]);
}
if (summary.getLastRow() === 0) {
summary.appendRow([
'記事ID', 'タイトル', 'URL',
'いいね数', 'Stock数', 'View数', '更新日時'
]);
}
setupDailyTrigger_();
Logger.log('Setup complete.');
}
/**
* 毎日実行する本体
* - Qiita自分の記事一覧を取得
* - rawに追記
* - latestを更新
* - summaryを更新
*/
function fetchQiitaMetrics() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const rawSheet = getOrCreateSheet_(ss, SHEET_RAW);
const latestSheet = getOrCreateSheet_(ss, SHEET_LATEST);
const summarySheet = getOrCreateSheet_(ss, SHEET_SUMMARY);
ensureHeaders_(rawSheet, [
'記録日', '記事ID', 'タイトル', 'URL',
'いいね数', 'Stock数', 'View数', '更新日時'
]);
ensureHeaders_(latestSheet, [
'記事ID', 'タイトル', 'URL',
'いいね数', 'Stock数', 'View数', '更新日時'
]);
ensureHeaders_(summarySheet, [
'記事ID', 'タイトル', 'URL',
'いいね数', 'Stock数', 'View数', '更新日時'
]);
const items = fetchAllMyQiitaItems_();
const today = Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy-MM-dd');
if (!items.length) {
Logger.log('No items found.');
return;
}
// rawに追記
const rawRows = items.map(item => [
today,
item.id || '',
item.title || '',
item.url || '',
item.likes_count ?? '',
item.stocks_count ?? '',
item.page_views_count ?? '',
item.updated_at || ''
]);
rawSheet
.getRange(rawSheet.getLastRow() + 1, 1, rawRows.length, rawRows[0].length)
.setValues(rawRows);
// latest / summary を更新
const latestRows = items.map(item => [
item.id || '',
item.title || '',
item.url || '',
item.likes_count ?? '',
item.stocks_count ?? '',
item.page_views_count ?? '',
item.updated_at || ''
]);
replaceSheetData_(latestSheet, latestRows);
replaceSheetData_(summarySheet, latestRows);
Logger.log(`Fetched ${items.length} items.`);
}
/**
* Qiitaの自分の記事を全件取得
*/
function fetchAllMyQiitaItems_() {
const perPage = 100;
let page = 1;
let allItems = [];
while (true) {
const url = `https://qiita.com/api/v2/authenticated_user/items?per_page=${perPage}&page=${page}`;
const response = UrlFetchApp.fetch(url, {
method: 'get',
headers: {
Authorization: `Bearer ${QIITA_TOKEN}`
},
muteHttpExceptions: true
});
const code = response.getResponseCode();
if (code !== 200) {
throw new Error(`Qiita API error: ${code} ${response.getContentText()}`);
}
const items = JSON.parse(response.getContentText());
if (!items || items.length === 0) break;
allItems = allItems.concat(items);
if (items.length < perPage) break;
page++;
}
return allItems;
}
/**
* 毎日1回のトリガーをセット
*/
function setupDailyTrigger_() {
// 既存の同名トリガーを削除
ScriptApp.getProjectTriggers().forEach(trigger => {
if (trigger.getHandlerFunction() === 'fetchQiitaMetrics') {
ScriptApp.deleteTrigger(trigger);
}
});
ScriptApp.getProjectTriggers().forEach(trigger => {
if (trigger.getHandlerFunction() === 'createViewsTimelineChartFromRaw') {
ScriptApp.deleteTrigger(trigger);
}
});
// 毎日朝6時前後に実行
ScriptApp.newTrigger('fetchQiitaMetrics')
.timeBased()
.everyDays(1)
.atHour(6)
.create();
// 毎日朝7時前後に実行
ScriptApp.newTrigger('createViewsTimelineChartFromRaw')
.timeBased()
.everyDays(1)
.atHour(7)
.create();
}
/**
* シートがなければ作る
*/
function getOrCreateSheet_(ss, name) {
return ss.getSheetByName(name) || ss.insertSheet(name);
}
/**
* ヘッダーがなければ設定
*/
function ensureHeaders_(sheet, headers) {
if (sheet.getLastRow() === 0) {
sheet.appendRow(headers);
}
}
/**
* シートのデータ部を全置換
* 1行目はヘッダー前提
*/
function replaceSheetData_(sheet, rows) {
const lastRow = sheet.getLastRow();
if (lastRow > 1) {
sheet.getRange(2, 1, lastRow - 1, sheet.getLastColumn()).clearContent();
}
if (rows.length > 0) {
sheet.getRange(2, 1, rows.length, rows[0].length).setValues(rows);
}
}
/**
* 手動実行用
*/
function testFetch() {
fetchQiitaMetrics();
createViewsTimelineChartFromRaw();
}
function createViewsTimelineChartFromRaw() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const rawSheet = ss.getSheetByName('raw');
if (!rawSheet) throw new Error('raw シートが見つかりません');
const pivotSheetName = 'views_pivot';
const chartSheetName = 'views_chart';
const pivotSheet = getOrCreateSheet_(ss, pivotSheetName);
const chartSheet = getOrCreateSheet_(ss, chartSheetName);
// rawのデータ取得
const values = rawSheet.getDataRange().getValues();
if (values.length < 2) throw new Error('raw シートにデータがありません');
const header = values[0];
const dateIdx = header.indexOf('記録日');
const titleIdx = header.indexOf('タイトル');
const viewsIdx = header.indexOf('View数');
if (dateIdx === -1 || titleIdx === -1 || viewsIdx === -1) {
throw new Error('raw シートのヘッダーに「記録日」「タイトル」「View数」が必要です');
}
// date -> title -> view
const dateMap = new Map();
const titleSet = new Set();
for (let i = 1; i < values.length; i++) {
const row = values[i];
const date = row[dateIdx];
const title = row[titleIdx];
const views = row[viewsIdx];
if (!date || !title || views === '') continue;
const dateStr = normalizeDate_(date);
titleSet.add(title);
if (!dateMap.has(dateStr)) {
dateMap.set(dateStr, new Map());
}
dateMap.get(dateStr).set(title, Number(views));
}
const titles = Array.from(titleSet).sort();
const dates = Array.from(dateMap.keys()).sort();
// ピボットデータ生成
const pivot = [];
pivot.push(['記録日', ...titles]);
dates.forEach(date => {
const row = [date];
const titleMap = dateMap.get(date);
titles.forEach(title => {
row.push(titleMap.has(title) ? titleMap.get(title) : '');
});
pivot.push(row);
});
// pivotシートに出力
pivotSheet.clear();
pivotSheet.getRange(1, 1, pivot.length, pivot[0].length).setValues(pivot);
pivotSheet.setFrozenRows(1);
// chartシートをクリアしてグラフ配置
chartSheet.clear();
// 既存チャート削除
chartSheet.getCharts().forEach(chart => chartSheet.removeChart(chart));
const chartRange = pivotSheet.getRange(1, 1, pivot.length, pivot[0].length);
const chart = chartSheet.newChart()
.setChartType(Charts.ChartType.LINE)
.addRange(chartRange)
.setPosition(1, 1, 0, 0)
.setOption('title', 'Qiita記事ごとの View数推移')
.setOption('hAxis', { title: '記録日' })
.setOption('vAxis', { title: 'View数' })
.setOption('legend', { position: 'right' })
.setOption('curveType', 'none')
.setOption('width', 1200)
.setOption('height', 700)
.build();
chartSheet.insertChart(chart);
}
// 日付を yyyy-MM-dd に揃える
function normalizeDate_(d) {
if (Object.prototype.toString.call(d) === '[object Date]' && !isNaN(d)) {
return Utilities.formatDate(d, 'Asia/Tokyo', 'yyyy-MM-dd');
}
return String(d).slice(0, 10);
}
GSS作ってGAS開いて上記コードをコピペで張り付けて、
const QIITA_TOKEN = 'hogehoge';
のところに自分のアクセストークン張り付けて、プロジェクトを保存。
アクセストークンの取得方法はこちらを参考にどうぞ。
https://qiita.com/koki_develop/items/57f86a1abc332ed2185d
「setup」を実行すれば準備完了、日次トリガーも設定されます。
「testFetch」を実行して動いていれば大丈夫かと思います。
で、数日経ったものがこちらです。
経過日数短いし、激しく増えたもの無いから一本線に見えるとか、
横軸の日付は無限に増えるのかとか、縦軸調整した方がいいんじゃないかとか、
記事の本数多くて線の色多すぎたり薄すぎだろうとか、
そこまでView数増えないから増加数だけを見た方がいいだろうとか、
これ新記事増えた時ちゃんと動くのかとか、
いろいろと気になる点はありますが、個人の自己満足で見る分には一旦これでいいかなと思います。
追加
GSSについてるGemini君は表の取り扱いに強いということなので、
「元データがあるから増減を表にしてみてよ」を指示してみたら作ってくれた。

一桁レベルの数字を取り扱うから、こっちの方がわかりやすいかもしれない。
追記
記事が増えた時にちゃんと動作するのか心配でしたが、この記事自体が増えてもちゃんと動作していることが確認できました。よかったよかった。


