Qiita では、かつては毎週投稿すると (デジタル) バッジが貰えていた。
しかし、連続投稿が100週を超えると、バッジが貰えなくなってしまった。
それでも毎週投稿を続けているつもりだが、バッジが貰えないので本当に毎週投稿できているかはっきりとわからないし、何週連続で投稿しているのかもわからなくなってきた。
そこで、Qiita API v2 を用いて指定したユーザの投稿の情報を取得し、何週連続で投稿しているのかを数えてみた。
今回は、Node.js を用いて API の呼び出しと計算を行った。
「週」の定義
単に「週」というだけでは
- 日曜始まりか月曜始まりか?
- 切り替わる時刻は?
- そもそも暦じゃなくて「投稿間隔が7日 (168時間) 以内」かも?
- Temporal「そもそも週は7日とは限らないよ?」
などの疑問が浮かび、定義が定まらない。
これまで Qiita に投稿してきた経験から推測した判定方法に基づき、この記事では「UTC+9 で、月曜日0時から日曜日24時の直前まで」を1個の週とみなす。
そして、「ある週に投稿した」とは、「その週の中で作成された記事が1個以上ある」と定義する。
これはあくまで経験に基づく推測であり、実際の Qiita の毎週投稿の判定がこの方法で行われているかはわからない。
また、毎週投稿の判定方法は変更される可能性もある。
記事の作成時刻リストの取得
GET /api/v2/users/:user_id/items API を用いることで、指定したユーザが投稿した記事のリストを作成日時の降順で取得できる。
パス内の :user_id の部分に、記事のリストを取得するユーザのIDを入れる。
また、URL のクエリ文字列で以下のパラメータを指定することで、取得するリストの範囲を指定できる。
-
page:何セット目のデータを取得するかを、1~100 の整数で指定する -
per_page:1セットとして何件のデータを取得するかを、1~100 の整数で指定する
成功すると、記事を表すオブジェクトの配列が JSON で返る。
このオブジェクトの created_at メンバが、記事の作成日時を表す。
さらに、Qiita API v2 の概要から、以下のことがわかる。
- この API は、指定したユーザが作成した記事の数を
Total-CountHTTP ヘッダで返す - 認証を行わない場合、API を使えるのは 1 時間に 60 回までである
これらの性質から、この API では記事を 10,000 件までしか取得できないことがわかる。
さらに、60 回までという制限も考慮すると、6,000 件までしか取得できないということになる。(この記事では認証は扱わない)
以下のコードで、この API を用いて指定したユーザの投稿リストを取得し、作成日時のリストに変換した。
ユーザは、コマンドライン引数でユーザIDを指定することにより指定する。
取得した配列の各要素から created_at を抽出た配列を、配列に格納しておく。
最後に flat() を用いて、これを1個の配列にまとめる。
また、取得を行うごとに Total-Count の値と取得したはずの件数を比較し、全件取得できたはずであれば取得を終了する。
const userId = process.argv[2];
if (typeof userId !== "string") {
process.stderr.write("Usage: node qiita-get-streak.js user_id\n");
process.exit(1);
}
// 投稿日時リストを取得する
const createdDatesRaw = [];
for (let page = 1; page <= 100; page++) {
const res = await fetch(`https://qiita.com/api/v2/users/${encodeURIComponent(userId)}/items?page=${page}&per_page=100`);
if (!res.ok) {
throw new Error(`fetch of page ${page} failed with status code ${res.status}`);
}
const resJson = await res.json();
createdDatesRaw.push(resJson.map((e) => new Date(e.created_at)));
const totalCount = parseInt(res.headers.get("total-count"), 10);
if (!isNaN(totalCount) && page * 100 >= totalCount) break;
}
const createdDates = createdDatesRaw.flat();
情報の取得中に記事の作成や削除などが発生すると、ページの区切りがずれ、正常に情報を取得できない恐れがある。
Total-Count が変化していないことを確認することである程度は検出できそうだが、作成と削除がほぼ同時に行われた場合などは、これだけでは検出できなそうだ。
今回は、この件への対策は行っていない。
今回のプログラムでは、API の仕様
- 記事のリストが作成日時の降順で取得できる
- 常に
per_page個以下の要素が取得できる - 最終ページ以外ではちょうど
per_page個の要素が取得できる
を信頼し、これらのチェックを行わずに処理を行っている。
チェックを追加することで、より安全にできるだろう。
投稿時刻から投稿された週を求める
今回は、Date オブジェクトを用いて以下の処理を行うことで、投稿時刻から投稿された週を求める。
週は、週の始まりに対応する Date オブジェクトで表す。
- UTC での時刻に 9 時間を足し、UTC の時刻として「UTC+9」の時刻を格納する
- 分・秒・ミリ秒を 0 に設定する
- 曜日を取得し、その情報を用いて直前の月曜日に戻す (当日が月曜日の場合は変わらない)
- 時を 0 に設定する
曜日は日曜日が 0、月曜日が 1、…と表されるので、6 を足して 7 で割った余りをとることで月曜日が 0、火曜日が 1、…に変換する。
これが、週の始まりの月曜日に戻すために引くべき日数となる。
// 投稿日時を週に変換する関数
function toWeekStart(date) {
const dateOffset9 = new Date(date);
dateOffset9.setUTCHours(dateOffset9.getUTCHours() + 9);
dateOffset9.setUTCMinutes(0);
dateOffset9.setUTCSeconds(0);
dateOffset9.setUTCMilliseconds(0);
dateOffset9.setTime(dateOffset9.getTime() - 86400000 * ((dateOffset9.getUTCDay() + 6) % 7));
dateOffset9.setUTCHours(0);
return dateOffset9;
}
ローカル時間として処理せず、UTC として「UTC+9」の時刻を格納して処理することで、サマータイムを含むかもしれないローカル時間の影響を受けずに処理を行うことを狙う。
何週連続で投稿したかを調べる
記事の作成日時が降順で取得できていると仮定し、配列を逆順に走査することで昇順に処理する。
前回の投稿の週と今回の投稿の週を比較し、以下の処理を行う。
- 同じ週の場合は、状態を変えない
- 1 週前 (すなわち、週の始まりが 7 日前) の場合は、連続投稿週数を 1 増やす
- それより前の場合、直前の連続投稿週数を出力し、連続投稿週数を 1 にリセットする
さらに、最後の連続投稿週数、そして最大の連続投稿週数も出力する。
// 週streakを調べる
let prevCreatedWeek = null;
let currentStreak = 0;
let maxStreak = 0;
let lastPrinted = true;
for (let i = createdDates.length - 1; i >= 0; i--) {
const createdWeek = toWeekStart(createdDates[i]);
lastPrinted = false;
if (prevCreatedWeek === null) {
currentStreak = 1;
} else if (prevCreatedWeek.getTime() + 86400000 * 7 === createdWeek.getTime()) {
currentStreak++;
} else if (prevCreatedWeek.getTime() !== createdWeek.getTime()) {
console.log(`${createdDates[i + 1].toLocaleString()}:${currentStreak} weeks`);
lastPrinted = true;
currentStreak = 1;
}
if (currentStreak > maxStreak) maxStreak = currentStreak;
prevCreatedWeek = createdWeek;
}
if (!lastPrinted) {
console.log(`${createdDates[0].toLocaleString()}:${currentStreak} weeks`);
}
console.log(`max streak: ${maxStreak} weeks`);
実行する
これまで紹介したプログラムをまとめると、以下のようになる。
実際は逆で、これを分割したものがこれまで紹介したプログラムである。
const userId = process.argv[2];
if (typeof userId !== "string") {
process.stderr.write("Usage: node qiita-get-streak.js user_id\n");
process.exit(1);
}
// 投稿日時リストを取得する
const createdDatesRaw = [];
for (let page = 1; page <= 100; page++) {
const res = await fetch(`https://qiita.com/api/v2/users/${encodeURIComponent(userId)}/items?page=${page}&per_page=100`);
if (!res.ok) {
throw new Error(`fetch of page ${page} failed with status code ${res.status}`);
}
const resJson = await res.json();
createdDatesRaw.push(resJson.map((e) => new Date(e.created_at)));
const totalCount = parseInt(res.headers.get("total-count"), 10);
if (!isNaN(totalCount) && page * 100 >= totalCount) break;
}
const createdDates = createdDatesRaw.flat();
// 投稿日時を週に変換する関数
function toWeekStart(date) {
const dateOffset9 = new Date(date);
dateOffset9.setUTCHours(dateOffset9.getUTCHours() + 9);
dateOffset9.setUTCMinutes(0);
dateOffset9.setUTCSeconds(0);
dateOffset9.setUTCMilliseconds(0);
dateOffset9.setTime(dateOffset9.getTime() - 86400000 * ((dateOffset9.getUTCDay() + 6) % 7));
dateOffset9.setUTCHours(0);
return dateOffset9;
}
// 週streakを調べる
let prevCreatedWeek = null;
let currentStreak = 0;
let maxStreak = 0;
let lastPrinted = true;
for (let i = createdDates.length - 1; i >= 0; i--) {
const createdWeek = toWeekStart(createdDates[i]);
lastPrinted = false;
if (prevCreatedWeek === null) {
currentStreak = 1;
} else if (prevCreatedWeek.getTime() + 86400000 * 7 === createdWeek.getTime()) {
currentStreak++;
} else if (prevCreatedWeek.getTime() !== createdWeek.getTime()) {
console.log(`${createdDates[i + 1].toLocaleString()}:${currentStreak} weeks`);
lastPrinted = true;
currentStreak = 1;
}
if (currentStreak > maxStreak) maxStreak = currentStreak;
prevCreatedWeek = createdWeek;
}
if (!lastPrinted) {
console.log(`${createdDates[0].toLocaleString()}:${currentStreak} weeks`);
}
console.log(`max streak: ${maxStreak} weeks`);
これを Node.js 24.20.0 で実行した。
node qiita-get-streak.js mikecat_mixc
すると、以下の結果が得られた。
2015/11/22 22:17:54:2 weeks
2015/12/25 22:51:54:1 weeks
2016/1/17 23:12:36:1 weeks
2016/2/10 1:12:52:1 weeks
2016/3/7 0:21:45:3 weeks
2016/4/3 16:48:41:1 weeks
2018/4/16 23:15:47:1 weeks
2018/7/29 15:46:00:1 weeks
2019/6/9 23:43:42:2 weeks
2019/8/13 4:11:42:1 weeks
2019/9/8 22:25:53:1 weeks
2019/12/31 3:20:17:4 weeks
2020/5/1 1:26:22:1 weeks
2020/5/24 17:54:59:1 weeks
2020/7/28 22:25:37:1 weeks
2020/9/7 0:39:42:1 weeks
2020/10/11 18:26:12:1 weeks
2020/12/27 16:38:45:3 weeks
2021/1/26 0:56:18:1 weeks
2021/2/12 22:19:49:1 weeks
2021/8/19 23:15:34:17 weeks
2021/11/9 5:26:38:1 weeks
2021/12/24 9:28:25:4 weeks
2022/1/7 23:04:41:1 weeks
2022/2/22 1:56:49:1 weeks
2022/5/20 22:58:22:1 weeks
2022/10/4 20:13:18:1 weeks
2022/12/27 22:46:59:5 weeks
2023/1/23 0:08:58:1 weeks
2023/7/6 23:50:29:1 weeks
2023/7/30 18:56:39:1 weeks
2023/8/7 8:11:02:1 weeks
2023/9/22 1:39:43:1 weeks
2026/9/13 23:44:56:146 weeks
max streak: 146 weeks
各連続投稿が切れたタイミングで、切れる前の最後に投稿された記事の作成日時と連続投稿週数を出力した。
2019 年から 2022 年には、毎年 12 月に連続投稿週数が長めになっており、アドベントカレンダーに参加しているらしいことがうかがえる。
そして、2023 年から今まで、146 週連続で投稿しているらしい。
すなわち、この記事で 147 週目となるはずである。
とはいえ、繰り返しになるが、これはあくまで独自に定義した判定基準による週カウントであり、Qiita の基準と一致しているとは限らない。
おわりに
今回は、Qiita API v2 を用いて指定ユーザの投稿した記事のリストを取得し、何週連続で投稿しているかを調べた。
また毎週投稿でバッジが貰えるようになるといいな……
1 週ごとだとバッジが増え過ぎて困るのだったら、たとえば 10 週ごとにするとか……
とはいえ、実は Qiita の基準ではどこかのタイミングで連続投稿記録が切れている可能性も……?