Astro の個人サイトに Qiita の PV(閲覧数)や記事一覧を取り込み、手元のカテゴリ設定や検索順位の記録、技術ノートの重複対策(canonical)と合わせて運用するための実装コード一式です。設計編の記事では要点の抜粋にとどめたコードを、自分の Astro プロジェクトにそのまま組み込める全文の形でまとめました。
TL;DR
- やりたいこと: Qiita で発信している記事の PV・一覧と、サイト独自の技術ノートや実績データを、Astro の静的サイト上で矛盾なく管理・表示したい
-
提供するもの: ① Qiita API 同期スクリプト、② 自動取得 JSON・手動辞書・人が確認した記録の合成コード、③
qiitaUrl1項目でcanonical・案内リンク・サイトマップ除外を連動させる Astro テンプレート&スキーマ - 特徴: アクセストークンを公開ファイルに含めず、新着記事のカテゴリ付け忘れや文章中の数字の陳腐化、Qiita 記事との検索競合をコード側で防ぐ
- 手軽に使うなら: PV と記事一覧を表示するだけなら、同期スクリプトと表示コンポーネントの2ファイルで動きます(「お手軽に使う:最短2ファイル構成」を参照)
はじめに:この記事で提供するコードと全体像
設計編の記事では、個人サイト(https://akkyey.dev/)を AI エージェントで更新する中で見えた「エージェントの4つの癖」と、それを防ぐ境界の引き方を解説しました。
本記事では、その境界を実際に動かしている 7つの実装ファイル(CSS などの装飾を除いたコア実装全文) をまとめます。PV と記事一覧を表示するだけなら、最短2ファイルでも動きます(後述)。
| 章 | ファイル | 役割 |
|---|---|---|
| 第1章 | scripts/update-stats.mjs |
Qiita API から公開記事と PV を全件取得し、src/data/qiita-stats.json を更新する。前回との差分(新着記事の ID)も通知する |
| 第2章 | src/data/seo-rankings.json |
人が確認した検索順位の記録を、観測日(observedAt)つきで保持する |
| 第2章 | src/data/portfolio.ts |
自動取得した JSON、手動のカテゴリ辞書、検索順位の記録を記事 ID で結合し、合計 PV や1位件数を動的に算出する |
| 第3章 | src/content.config.ts |
技術ノート(Markdown)のフロントマターを Zod スキーマで検証する |
| 第3章 |
src/layouts/Layout.astrosrc/pages/notes/[...slug].astro
|
フロントマターに qiitaUrl があるノートは <link rel="canonical"> を Qiita に向け、本文末尾に案内リンクを出す |
| 第3章 | src/pages/sitemap.xml.ts |
下書き(draft)と qiitaUrl 指定済みのノートをサイトマップから自動除外する |
設計編では、このうち一部のコードを抜粋して紹介しました。本記事はその全文です。
Qiita の記事を Astro に取り込むパッケージとしては、astro-qiita-loader もあります。こちらは Qiita の記事をコンテンツコレクションとして取り込み、タイトルや本文を自分のサイトに表示したい場合に向いています。本記事のコードは、本人の PV を確実に取得して合計を出すこと、API が不調なときも前回のデータでビルドできること、Qiita と同じテーマのノートを qiitaUrl 1項目で重複させないことに重点を置いています。
お手軽に使う:最短2ファイル構成
「自分の Qiita の PV と記事一覧をサイトに出したいだけ」なら、次の2ファイルで動きます。
- 第1章の
scripts/update-stats.mjs(そのままコピー) - 下の
src/components/QiitaSummary.astro
---
// npm run update-stats で生成した JSON を読み、合計と PV 上位5本を表示する
import stats from '../data/qiita-stats.json';
const totalPv = stats.items.reduce((sum, i) => sum + i.pv, 0);
const topArticles = [...stats.items].sort((a, b) => b.pv - a.pv).slice(0, 5);
---
<section aria-label="Qiita の執筆実績">
<p>
Qiita 公開記事 {stats.items.length} 本 / 累計 {totalPv.toLocaleString()} PV({stats.asOfDate} 時点)
</p>
<ul>
{topArticles.map((a) => (
<li>
<a href={a.url} target="_blank" rel="noopener noreferrer">{a.title}</a>({a.pv.toLocaleString()} PV)
</li>
))}
</ul>
</section>
使い方は次の3つだけです。
- トークンを
~/.qiita_tokenに保存する(第1章) -
npm run update-statsを1回実行して、src/data/qiita-stats.jsonを作る(package.jsonへの登録は「まとめ」を参照) - 表示したいページに
<QiitaSummary />を置く
カテゴリ分けやシリーズ別の累計、検索順位の表示、Qiita 記事との重複対策まで使う場合は、以降の全体構成(7ファイル)に進んでください。
第1章:Qiita API 同期と新着差分検知(scripts/update-stats.mjs)
なぜ公開 API ではなく認証付きスクリプトが必要か
Qiita API v2 では、記事タイトルやいいね数は認証なしで取得できますが、PV(page_views_count)だけは本人のアクセストークン付きで /api/v2/authenticated_user/items を呼んだときしか返りません。
# 1. 認証なし(公開エンドポイント)→ page_views_count は常に null
$ curl -s "https://qiita.com/api/v2/users/akkyey/items?per_page=1" | jq '.[] | {title, page_views_count}'
{
"title": "Colabで再実行時の「fatal: destination path already exists」を防ぐ…",
"page_views_count": null
}
# 2. 認証あり(個人用アクセストークン付き)→ 数値が返る
$ curl -s -H "Authorization: Bearer $(cat ~/.qiita_token)" \
"https://qiita.com/api/v2/authenticated_user/items?per_page=1" | jq '.[] | {title, page_views_count}'
{
"title": "Colabで再実行時の「fatal: destination path already exists」を防ぐ…",
"page_views_count": 232
}
静的サイトの公開ファイルにトークンは含められないため、デプロイ前に手元(または CI)でこのスクリプトを実行し、JSON に書き出してからビルドします。
トークン(スコープ read_qiita)は、シェルの履歴に残らないよう次のコマンドで ~/.qiita_token に保存しておくか、環境変数 QIITA_TOKEN に設定します。
read -s QIITA_TOKEN_INPUT && printf '%s' "$QIITA_TOKEN_INPUT" > ~/.qiita_token && unset QIITA_TOKEN_INPUT
chmod 600 ~/.qiita_token
コード全文:scripts/update-stats.mjs
import { readFileSync, writeFileSync, renameSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
// 実行場所に関係なく、このスクリプトの位置を基準にパスを決める
const __dirname = dirname(fileURLToPath(import.meta.url));
const TOKEN_PATH = join(homedir(), '.qiita_token');
const STATS_PATH = resolve(__dirname, '../src/data/qiita-stats.json');
const TMP_PATH = `${STATS_PATH}.tmp`;
const PER_PAGE = 100;
// 環境変数 QIITA_TOKEN を優先し、無ければ ~/.qiita_token を読む
function loadToken() {
if (process.env.QIITA_TOKEN) return process.env.QIITA_TOKEN.trim();
if (existsSync(TOKEN_PATH)) return readFileSync(TOKEN_PATH, 'utf-8').trim();
throw new Error('Qiita トークンが見つかりません(~/.qiita_token または環境変数 QIITA_TOKEN)');
}
// 更新前の JSON から既存の記事 ID 一覧を読む(初回実行時は空)
function loadPreviousIds() {
try {
return new Set(JSON.parse(readFileSync(STATS_PATH, 'utf-8')).items.map((i) => i.itemId));
} catch {
return new Set();
}
}
// ページネーションで全件取得し、限定共有記事を除外する
async function fetchAllPublicItems(token) {
const all = [];
for (let page = 1; ; page++) {
const url = `https://qiita.com/api/v2/authenticated_user/items?page=${page}&per_page=${PER_PAGE}`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Qiita API エラー: HTTP ${res.status}`);
const items = await res.json();
all.push(...items);
if (items.length < PER_PAGE) break;
}
return all.filter((item) => !item.private);
}
async function main() {
const token = loadToken();
console.log('🔄 Qiita API から公開記事を取得中...');
const publicItems = await fetchAllPublicItems(token);
// 日本時間(JST)の YYYY-MM-DD(toISOString だと朝9時前に前日になるのを防ぐ)
const asOfDate = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Tokyo' }).format(new Date());
const items = publicItems
.map((item) => ({
itemId: item.id,
title: item.title,
url: item.url,
pv: item.page_views_count ?? 0,
likes: item.likes_count ?? 0,
stocks: item.stocks_count ?? 0,
tags: (item.tags ?? []).map((t) => t.name),
createdAt: item.created_at.slice(0, 10),
updatedAt: item.updated_at.slice(0, 10),
}))
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
// 前回との差分(新しく増えた記事・一覧から外れた記事)を検出する
const prevIds = loadPreviousIds();
const nowIds = new Set(items.map((i) => i.itemId));
const added = items.filter((i) => !prevIds.has(i.itemId));
const removed = [...prevIds].filter((id) => !nowIds.has(id));
const json = { asOfDate, items };
// 一時ファイルに書いてから置き換える(書き込み途中で止まっても壊れた JSON が残らない)
writeFileSync(TMP_PATH, JSON.stringify(json, null, 2) + '\n', 'utf-8');
renameSync(TMP_PATH, STATS_PATH);
const sum = (key) => items.reduce((s, i) => s + i[key], 0);
console.log(`🎉 更新完了(基準日 JST: ${asOfDate})`);
console.log(`- 公開記事: ${items.length} 本`);
console.log(`- 累計PV: ${sum('pv').toLocaleString()} / いいね: ${sum('likes')} / ストック: ${sum('stocks')}`);
if (prevIds.size > 0 && added.length > 0) {
console.log(`- 新しい記事: ${added.length} 本 → portfolio.ts の ARTICLE_META にカテゴリを追加してください`);
for (const i of added) console.log(` ${i.itemId} ${i.title}`);
}
if (removed.length > 0) {
console.log(`- 一覧から外れた記事(削除・限定共有化): ${removed.join(', ')}`);
}
}
main().catch((err) => {
// API エラー時は書き込みの前に止まるので、前回の JSON がそのまま残る
console.error('❌ 更新に失敗しました(ファイルは変更していません):', err.message);
process.exit(1);
});
実運用で入れた4つの処理
-
限定共有記事の除外(
!item.private): 認証付きの/authenticated_user/itemsは限定共有記事も一緒に返すため、そのまま出力すると非公開のはずの記事タイトルがサイトに載ってしまいます。 -
朝9時前の日付ズレ防止(JST 固定):
new Date().toISOString().slice(0, 10)は UTC のため、日本時間の朝9時前に実行すると前日の日付になります。Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Tokyo' })で常に日本時間のYYYY-MM-DDを出しています。 -
新着記事の ID 通知(
loadPreviousIds): JSON の全量上書きと手動のカテゴリ辞書(次章)を分けたため、新しく公開した記事があればターミナルに ID とタイトルを表示し、カテゴリ設定の追加漏れを防いでいます。 -
一時ファイル経由の置き換え(
.tmp→renameSync): API エラー時は何も書き込まずに終了し、書き込み時も一時ファイルから置き換えるため、途中の失敗で壊れた JSON が残りません(.gitignoreに*.tmpを追加しておきます)。
第2章:自動取得データ・手動辞書・人が確認した記録の合成(src/data/portfolio.ts)
設計編の第3章(前半 1・2)で解説したとおり、次の3種類のデータを別ファイルに分け、ビルド時に記事 ID で結合します。
-
機械が毎回全量上書きするデータ(
src/data/qiita-stats.json) -
人が確認した検索順位の記録(
src/data/seo-rankings.json) -
人が決めるカテゴリ辞書と集計ロジック(
src/data/portfolio.ts)
① 検索順位の記録データ:src/data/seo-rankings.json
検索順位や AI Overview の引用状況は、確認した日付(observedAt)と最上位順位(topRank)を JSON に構造化して記録します。
検索順位をサイトに載せない場合、このファイルと portfolio.ts の検索順位まわりの計算(seoTopCount・seoObservedAt など)は不要です。消しても、ほかの部分はそのまま動きます。
{
"observedAt": "2026-10-16",
"entries": [
{
"domain": "Python / 株データ基盤",
"queries": ["yfinance 429", "yfinance 日本株 欠損"],
"articleIds": ["00f5b3275749dbd820c3", "12a8df355ee895ed73d4"],
"topRank": null,
"position": "2記事同時上位ランクイン"
},
{
"domain": "Python / 高速データ処理",
"queries": ["yfinance RSI MultiIndex 高速 銘柄"],
"articleIds": ["466b4fd05cb9408920f4", "02790d25e584204909d0"],
"topRank": 1,
"position": "1位・2位独占(AI引用 [1],[2],[4],[5])"
}
]
}
② データの合成と集計:src/data/portfolio.ts
紹介文や見出しの中に「全31記事」「累計 24,145 PV」「検索1位 8件」といった数字を直書きせず、すべて JSON から計算してエクスポートします。
import qiitaStats from './qiita-stats.json';
import seoRankings from './seo-rankings.json';
export type CategoryId = 'data-pipeline' | 'gas-automation' | 'ai-mcp-llm';
export interface ArticleMeta {
category: CategoryId;
featured?: boolean;
}
export interface Article {
id: number;
itemId: string;
title: string;
url: string;
pv: number;
likes: number;
stocks: number;
tags: string[];
createdAt: string;
updatedAt: string;
category: CategoryId;
featured?: boolean;
}
// 人が決める情報は「記事ID → カテゴリ」の辞書としてだけ持つ
export const ARTICLE_META: Record<string, ArticleMeta> = {
'1214793c2c384b81fe63': { category: 'data-pipeline' },
'320aaae5305b98bc0ad5': { category: 'data-pipeline', featured: true },
// 新しい記事を公開したときは、update-stats の通知を見てここに追加する
};
// 未登録の記事があってもビルドが通るよう、デフォルト値を当てる
const DEFAULT_META: ArticleMeta = { category: 'data-pipeline' };
export const ALL_ARTICLES: Article[] = qiitaStats.items.map((item, index) => ({
id: index + 1,
...item,
...(ARTICLE_META[item.itemId] ?? DEFAULT_META),
}));
// 指定した記事 ID 群の合計 PV を算出する(特定シリーズの累計 PV 表示用)
export const sumPvByIds = (ids: string[]) =>
ALL_ARTICLES.filter((a) => ids.includes(a.itemId)).reduce((s, a) => s + a.pv, 0);
// 指定した記事群が 1位を取った検索クエリ数を算出する
export const topRankQueryCount = (ids: string[]) =>
seoRankings.entries
.filter((e) => e.topRank === 1 && e.articleIds.some((id) => ids.includes(id)))
.reduce((sum, e) => sum + e.queries.length, 0);
// サイト全体の集計値(すべてデータから計算し、定数として直書きしない)
export const SITE_STATS = {
totalArticles: ALL_ARTICLES.length,
totalPv: ALL_ARTICLES.reduce((s, i) => s + i.pv, 0),
totalLikes: ALL_ARTICLES.reduce((s, i) => s + i.likes, 0),
totalStocks: ALL_ARTICLES.reduce((s, i) => s + i.stocks, 0),
asOfDate: qiitaStats.asOfDate,
seoTopCount: seoRankings.entries.filter((e) => e.topRank === 1).length,
seoObservedAt: seoRankings.observedAt,
};
③ ページ側での表示例:src/pages/index.astro(抜粋)
ページ側では、計算済みの SITE_STATS と ALL_ARTICLES を読み込んで表示するだけです。統計の基準日(asOfDate)と検索順位の観測日(seoObservedAt)も別々に管理されているため、日付が混ざることもありません。
---
import { SITE_STATS, ALL_ARTICLES } from '../data/portfolio';
const topArticles = [...ALL_ARTICLES].sort((a, b) => b.pv - a.pv).slice(0, 5);
---
<section aria-label="執筆・検索実績">
<p>
公開記事 {SITE_STATS.totalArticles} 本 /
累計 {SITE_STATS.totalPv.toLocaleString()} PV /
{SITE_STATS.totalStocks} ストック({SITE_STATS.asOfDate} 時点)
</p>
<p>
Google 検索で1位を確認できたテーマは {SITE_STATS.seoTopCount} 件です({SITE_STATS.seoObservedAt} 時点で確認)。
</p>
</section>
<ul>
{topArticles.map((a) => (
<li>
<a href={a.url} target="_blank" rel="noopener noreferrer">{a.title}</a>
<span>({a.pv.toLocaleString()} PV / {a.category})</span>
</li>
))}
</ul>
第3章:qiitaUrl 1項目で動く重複対策とスキーマ検証
個人サイト側にも技術ノート(/notes/)を置く場合、Qiita に書いた記事と同じテーマのメモを公開すると、検索エンジン上で自分の記事同士が競合します。同じ内容のページが2つあると、Google はどちらか一方だけを検索結果に出そうとするため、上位にいる Qiita 記事がノートに置き換わって順位を落とすおそれがあります。
設計編の第3章(前半 3・後半 7)で述べたとおり、エージェントや自分が記事を追加するときに迷わないよう、フロントマターの qiitaUrl 1項目だけを入力すれば、① スキーマ検証、② canonical の切り替え、③ 案内リンクの挿入、④ サイトマップからの除外 がすべて自動で動くようにしています。
① スキーマ定義:src/content.config.ts
Astro v5 の Content Layer(コンテンツコレクション)と Zod を使い、フロントマターの型や日付形式が崩れている Markdown はビルド時にエラーで止めます。
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const notes = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/notes' }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
// 同テーマの Qiita 記事がある場合に指定する(URL 形式でない場合はビルドエラー)
qiitaUrl: z.string().url().optional(),
}),
});
export const collections = { notes };
② <head> 内の canonical 切り替え:src/layouts/Layout.astro
canonical プロパティが渡されたときだけ外部 URL(Qiita)を正規 URL とし、省略時は自分のサイトの URL を出力します(astro.config.mjs に site: 'https://自分のドメイン' の設定が必要です)。
---
interface Props {
title: string;
description: string;
/** 正規 URL。省略時はこのページ自身(Qiita と同テーマのノートは Qiita 記事の URL を渡す) */
canonical?: string;
}
const { title, description, canonical } = Astro.props;
const canonicalUrl = canonical ?? new URL(Astro.url.pathname, Astro.site).href;
---
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonicalUrl} />
<link rel="sitemap" href="/sitemap.xml" />
</head>
<body>
<slot />
</body>
</html>
③ ノート詳細ページ:src/pages/notes/[...slug].astro
note.data.qiitaUrl をそのまま Layout の canonical に渡し、同じ値を使って本文末尾の案内リンクも出し分けます。
---
import { getCollection, render } from 'astro:content';
import Layout from '../../layouts/Layout.astro';
export async function getStaticPaths() {
// 本番ビルドでは下書き(draft: true)のページを生成しない
const notes = await getCollection('notes', ({ data }) => (import.meta.env.PROD ? !data.draft : true));
return notes.map((note) => ({
params: { slug: note.id },
props: { note },
}));
}
const { note } = Astro.props;
const { Content } = await render(note);
---
<Layout
title={`${note.data.title} | 技術ノート`}
description={note.data.description}
canonical={note.data.qiitaUrl}
>
<article>
<h1>{note.data.title}</h1>
<Content />
{note.data.qiitaUrl && (
<p class="qiita-ref">
このノートの詳しい解説は、Qiita の記事にまとめています:
<a href={note.data.qiitaUrl} target="_blank" rel="noopener noreferrer">Qiita で読む ↗</a>
</p>
)}
</article>
</Layout>
④ サイトマップの動的生成:src/pages/sitemap.xml.ts
canonical を別 URL(Qiita)に向けているページを sitemap.xml に載せると検索エンジンに矛盾したシグナルを送ることになるため、!n.data.qiitaUrl でサイトマップから自動的に外します。
import type { APIRoute } from 'astro';
import { getCollection } from 'astro:content';
export const GET: APIRoute = async ({ site }) => {
// astro.config.mjs の site に自分のサイトの URL を設定しておく
const base = site ?? new URL('https://example.com');
// 下書き(draft)と、Qiita 側に正規 URL を向けているノート(qiitaUrl あり)は除外
const notes = (await getCollection('notes')).filter((n) => !n.data.draft && !n.data.qiitaUrl);
const urls = [
{ loc: new URL('/', base).href },
// ノート一覧ページ(src/pages/notes/index.astro)がある場合は、次の行も加える
// { loc: new URL('/notes/', base).href },
...notes.map((n) => ({
loc: new URL(`/notes/${n.id}/`, base).href,
lastmod: (n.data.updatedDate ?? n.data.pubDate).toISOString().slice(0, 10),
})),
];
const body = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls
.map((u) => ` <url><loc>${u.loc}</loc>${'lastmod' in u && u.lastmod ? `<lastmod>${u.lastmod}</lastmod>` : ''}</url>`)
.join('\n')}
</urlset>
`;
return new Response(body, { headers: { 'Content-Type': 'application/xml; charset=utf-8' } });
};
まとめ:コマンド登録と日々の実行
package.json に次のスクリプトを登録しておきます。
{
"scripts": {
"update-stats": "node scripts/update-stats.mjs",
"build": "astro build"
}
}
最初の astro build より前に、必ず npm run update-stats を1回実行して src/data/qiita-stats.json を生成してください(生成された JSON を Git にコミットしておけば、以降はトークンのない環境でもビルドできます)。
つまずきやすい点
| 症状 | 原因と対処 |
|---|---|
Qiita API エラー: HTTP 401 |
トークンが間違っているか、失効しています。Qiita で発行し直し、~/.qiita_token を上書きしてください |
Qiita トークンが見つかりません |
~/.qiita_token も環境変数 QIITA_TOKEN もありません。第1章の手順でトークンを保存してください |
astro build で qiita-stats.json が見つからない |
npm run update-stats を一度も実行していません。先に実行して JSON を作ってください |
日々の更新は、次のコマンドを実行するだけです。
npm run update-stats; npm run build
&& ではなく ; でつないでおくと、万が一 Qiita API が一時的なエラーで落ちていた場合でも、前回の qiita-stats.json を維持したままビルドとデプロイまで進めることができます。
この仕組みの上で、AI エージェントにどうやって日々の記事追加や更新を任せているか(運用手順書テンプレートと確認フロー)は、次の運用編にまとめています。
関連記事(全3本)
-
『AIエージェントをCMSの代わりにする静的サイトの設計 ― Astro × Qiita API で考える、エージェントとプログラムの境界の引き方』(設計編)
-
本記事: QiitaのPVをAstroで表示し、自サイトとの重複も防ぐ実装一式(コード一式)
-
『Astroサイトの運用をAIエージェントに任せる手順 ― 持ち帰れる運用手順書テンプレートと、作業・確認・判断の分担』(運用編)
-
公開サイト: https://akkyey.dev/
-
リポジトリ: akkyey/akkyey-portfolio
ライセンス
本記事のコードは MIT ライセンスです。自由にコピー・改変・商用利用していただいて構いません(無保証です)。