はじめに
こんばんは!☕
自称、駆け出しフルスタックエンジニアのココアです!
みなさんは、「寝る前にスマホを見ないぞ!」と決めたのに、気づいたらTwitterやブラウザを開いて技術記事を読み漁ってしまう...そんな経験はありませんか?
私もエンジニアになって、この習慣に悩んでいましたが、ふと「Kindle端末なら目に優しいし、通知も来ない。記事をKindleに送れば解決するのでは?」と思いつきました。
そこで、ZennとQiitaの記事を自動でKindle端末に配信するGoogle Apps Scriptを作成しました。
実際に運用してみたところ、寝る前の読書習慣が劇的に改善したので、その仕組みを共有します。
実現したこと
- ZennとQiitaから興味のあるキーワードの記事を自動収集
- 毎日朝5時と夜8時の2回、HTMLファイルとしてKindleに送信
- 一度送った記事は送らない重複排除機能
- キーワード指定なしで全体トレンドを取得することも可能
実現できてないこと(のびしろ)
- pdf形式で送信する
- 1日経ったら送信したpdf or htmlを自動で消す
- オフラインで読む
なぜKindleなのか
- E-Inkディスプレイで目に優しい - ブルーライトが少なく、就寝前でも目が疲れにくい
- 通知が来ない - スマホと違って気が散る要素がない
- 読書に集中できる - SNSやブラウザの誘惑から完全に切り離される
- Send to Kindle機能が便利 - メール送信だけでコンテンツが届く
- うちにはkindleの端末が2台もある!
仕組みの概要
Google Apps Script (定期実行)
↓
ZennとQiitaからRSSまたはAPIで記事取得
↓
HTML形式に整形
↓
Gmailから自分のKindleアドレスに送信
↓
Kindle端末に自動配信
今回お世話になった send to kindle email版
↓↓ 実際に届くとこんな感じ
URLを開いて記事を読める!(ネットワーク環境が必要)
セットアップ手順
1. Kindleの送信先アドレスを確認
- Amazonコンテンツと端末の管理にアクセス
- 「設定」→「パーソナル・ドキュメント設定」
- 「承認済みEメールアドレス一覧」に自分のGmailアドレスを追加
- 「Send-to-Kindle Eメールアドレス」に表示されている
@kindle.comのアドレスをメモ
2. Google Apps Scriptの準備
- Google Apps Scriptを開く
- 新しいプロジェクトを作成
- 以下のコードを貼り付け
3. 設定のカスタマイズ
コード冒頭の設定エリアを自分用に書き換えます。
// ★あなたのKindleアドレス (@kindle.com)
const KINDLE_EMAIL = 'あなたのKindleアドレス@kindle.com';
// ★あなたのGoogleアドレス (送信元として使用)
const MY_EMAIL = 'あなたのGoogleアドレス@gmail.com';
// ★検索キーワード設定
const TARGET_KEYWORDS = ['個人開発', 'CLI', '業務効率化', 'バイブコーディング', 'AWS'];
キーワードについて:
- 空配列
[]にすると、全体のトレンド記事を取得します - 特定のタグで絞りたい場合は配列に文字列を入れます
4. トリガーの設定
毎日自動実行するためのトリガーを設定します。
トリガー設定手順
-
トリガー画面を開く
GASエディタ左側メニューの時計マーク(トリガー)をクリック -
トリガーを追加
右下の「+ トリガーを追加」ボタンをクリック -
朝5時用の設定
- 実行する関数:
main - 実行するデプロイ:
Head - イベントのソース:
時間主導型 - 時間ベースのトリガーのタイプ:
日付ベースのタイマー - 時刻:
午前 5時 〜 6時 - 保存をクリック
- 実行する関数:
-
夜8時用の設定
もう一度「+ トリガーを追加」で同様に設定- 時刻のみ
午後 8時 〜 9時に変更
- 時刻のみ
5. 初回実行と権限の承認
- エディタ上部の「main」関数を選択
- 「実行」ボタンをクリック
- 権限の確認画面が出るので「権限を確認」→自分のアカウントを選択
- 「詳細」→「(プロジェクト名)に移動」→「許可」
コードの解説
主要な処理フロー
function main() {
// 1. 過去に送信済みのURLを取得(重複防止)
const sentUrls = getSentUrls();
// 2. キーワードの有無で処理を分岐
if (TARGET_KEYWORDS.length === 0) {
// 全体トレンドモード
} else {
// キーワード指定モード
}
// 3. HTML形式に整形
// 4. Kindleにメール送信
// 5. 送信済みURLを保存
}
工夫したポイント
1. 重複送信の防止
function getSentUrls() {
const prop = PropertiesService.getScriptProperties().getProperty('SENT_URLS');
return prop ? JSON.parse(prop) : [];
}
Google Apps ScriptのPropertiesServiceを使って、過去に送信したURLを最大300件まで保存しています。
2. QiitaはAPIとRSSの併用
-
キーワード指定時: タグ別RSS (
https://qiita.com/tags/{keyword}/feed) - 全体トレンド時: Qiita API v2で「ストック数10以上」の記事を取得
const query = 'stocks:>10';
const url = `https://qiita.com/api/v2/items?per_page=20&query=${encodeURIComponent(query)}`;
3. Kindle用のHTML最適化
Kindleで読みやすいシンプルなHTMLを生成しています。
htmlBody += `
<h2>${article.title}</h2>
<p><strong>Source:</strong> ${article.source} | <strong>Date:</strong> ${article.date}</p>
<p>${article.description}</p>
<p><a href="${article.link}">記事全文を読む (リンク)</a></p>
`;
フッターには削除用リンクも追加。読み終わった記事はAmazonの管理画面から削除できます。
実際に使ってみて
良かった点
-
寝る前のスマホ時間が激減
Kindle端末に記事が届いているので、スマホを開かなくなった。あちちー🔥 -
読書の質が向上
E-Inkディスプレイは目が疲れにくく、読書で負荷がかからない。 -
情報収集が効率化
ランダムで記事が届くので、興味以外の事も知れる。
※キーワードの設定をもう少し考えてもいいかもしれない
とはいえ、「どの記事を読むか」の選定ができるだけでも十分価値があります。
まとめ
このスクリプトを作ってから、寝る前の習慣が劇的に改善しました。
- スマホ → Kindle端末への移行で目の疲れ軽減
- 通知のない環境で読書に集中できる
- 自動配信で情報収集の手間がゼロに
「寝る前のスマホをやめたいけど技術記事は読みたい」という方は、ぜひ試してみてください。
参考リンク
- Send to Kindle - Amazon
- Qiita API v2 ドキュメント
- データ変換ロジックをコードから追い出す技術 "Transform Rules"
- BunでCLIツールを作成してシングルバイナリ化するメモ
- 個人開発者として実際にアプリを運営していく中で感じた個人開発の現実を言語化したいと思います。
完全なコードは以下に掲載しています。
// ==========================================
// 設定エリア
// ==========================================
// ★あなたのKindleアドレス (@kindle.com)
const KINDLE_EMAIL = 'あなたのKindleアドレス@kindle.com';
// ★あなたのGoogleアドレス (送信元として使用)
const MY_EMAIL = 'あなたのGoogleアドレス@gmail.com';
// ★検索キーワード設定
// 空っぽ「[]」にすると、キーワード指定なし(サイト全体のトレンド/新着)モードになります。
// 特定のタグで絞りたい場合は ['Obsidian', 'Python'] のように入れてください。
const TARGET_KEYWORDS = ['個人開発', 'CLI', '業務効率化', 'バイブコーディング', 'AWS'];
// ==========================================
// メイン処理
// ==========================================
function main() {
const sentUrls = getSentUrls(); // 過去に送信済みのURLを取得
const articlesToSend = [];
let qiitaItems = [];
let zennItems = [];
// --- 1. 記事の取得プロセス ---
if (TARGET_KEYWORDS.length === 0) {
// 【A】 キーワード指定なし(全体トレンドモード)
console.log('キーワード指定なし: 全体トレンドを取得します');
qiitaItems = fetchQiitaAll(2, sentUrls);
articlesToSend.push(...qiitaItems);
zennItems = fetchZennRss('https://zenn.dev/feed', 2, sentUrls);
articlesToSend.push(...zennItems);
} else {
// 【B】 キーワード指定ありモード
console.log('キーワード指定あり: ' + TARGET_KEYWORDS.join(', '));
qiitaItems = fetchArticlesWithKeywords('Qiita', TARGET_KEYWORDS, sentUrls, 2);
zennItems = fetchArticlesWithKeywords('Zenn', TARGET_KEYWORDS, sentUrls, 2);
articlesToSend.push(...qiitaItems, ...zennItems);
}
// --- 2. 記事がなければ終了 ---
if (articlesToSend.length === 0) {
console.log('送信すべき新しい記事がありませんでした。');
return;
}
// --- 3. HTML作成(Kindle送信用) ---
let htmlBody = `
<html>
<head>
<meta charset="UTF-8">
<title>Daily Digest</title>
</head>
<body>
<h1>本日のピックアップ (${new Date().toLocaleDateString()})</h1>
<hr>
`;
articlesToSend.forEach(article => {
htmlBody += `
<h2>${article.title}</h2>
<p><strong>Source:</strong> ${article.source} | <strong>Date:</strong> ${article.date}</p>
<p>${article.description}</p>
<p><a href="${article.link}">記事全文を読む (リンク)</a></p>
<br><hr><br>
`;
});
htmlBody += `
<p style="text-align:center; color:gray; font-size:small; margin-top:50px;">
---<br>
このドキュメントを削除するには<br>
<a href="https://www.amazon.co.jp/hz/mycd/digital-console/contentlist/pdocs">Amazon コンテンツと端末の管理</a><br>
へアクセスしてください。
</p>
</body></html>
`;
// --- 4. メール送信 ---
const fileName = `digest_${Utilities.formatDate(new Date(), 'JST', 'yyyyMMdd_HHmm')}.html`;
const blob = Utilities.newBlob(htmlBody, 'text/html', fileName);
try {
GmailApp.sendEmail(KINDLE_EMAIL, `Daily Digest ${new Date().toLocaleDateString()}`, '記事を送付します。', {
from: MY_EMAIL,
attachments: [blob]
});
const newUrls = articlesToSend.map(a => a.link);
saveSentUrls(newUrls);
console.log(`送信完了: 計 ${articlesToSend.length} 件 (Qiita:${qiitaItems.length}, Zenn:${zennItems.length})`);
} catch (e) {
console.error('メール送信エラー: ' + e.toString());
}
}
// ==========================================
// サブ関数エリア
// ==========================================
function fetchArticlesWithKeywords(source, keywords, sentUrls, limit) {
let candidates = [];
keywords.forEach(keyword => {
let items = [];
if (source === 'Qiita') {
items = parseRss(`https://qiita.com/tags/${encodeURIComponent(keyword)}/feed`, source);
} else {
items = parseRss(`https://zenn.dev/topics/${encodeURIComponent(keyword.toLowerCase())}/feed`, source);
}
candidates = candidates.concat(items);
});
return filterAndSort(candidates, sentUrls, limit);
}
function fetchZennRss(url, limit, sentUrls) {
const items = parseRss(url, 'Zenn');
return filterAndSort(items, sentUrls, limit);
}
function fetchQiitaAll(limit, sentUrls) {
const results = [];
const query = 'stocks:>10';
const url = `https://qiita.com/api/v2/items?per_page=20&query=${encodeURIComponent(query)}`;
try {
const response = UrlFetchApp.fetch(url, { muteHttpExceptions: true });
if (response.getResponseCode() === 200) {
const data = JSON.parse(response.getContentText());
data.forEach(item => {
results.push({
title: item.title,
link: item.url,
date: item.created_at.substring(0, 10),
description: item.body ? item.body.substring(0, 150) + '...' : '',
source: 'Qiita'
});
});
} else {
console.warn('Qiita API Response Code: ' + response.getResponseCode());
}
} catch (e) {
console.warn('Qiita API Error: ' + e);
}
return filterAndSort(results, sentUrls, limit);
}
function filterAndSort(candidates, sentUrls, limit) {
const unique = [];
const seen = new Set();
candidates.sort((a, b) => new Date(b.date) - new Date(a.date));
for (const item of candidates) {
if (!sentUrls.includes(item.link) && !seen.has(item.link)) {
unique.push(item);
seen.add(item.link);
if (unique.length >= limit) break;
}
}
return unique;
}
function parseRss(url, source) {
const results = [];
try {
const response = UrlFetchApp.fetch(url, { muteHttpExceptions: true });
if (response.getResponseCode() !== 200) return [];
const xml = XmlService.parse(response.getContentText());
const root = xml.getRootElement();
const namespace = root.getNamespace();
let entries = [];
if (root.getName() === 'feed') {
entries = root.getChildren('entry', namespace);
} else {
const channel = root.getChild('channel');
if (channel) entries = channel.getChildren('item');
}
entries.forEach(entry => {
const title = entry.getChild('title', namespace).getText();
let link = '';
const linkNode = entry.getChild('link', namespace);
if (linkNode) {
link = linkNode.getAttribute('href') ? linkNode.getAttribute('href').getValue() : linkNode.getText();
}
let dateStr = '';
const published = entry.getChild('published', namespace) || entry.getChild('pubDate');
if (published) dateStr = published.getText();
let desc = '';
const summary = entry.getChild('summary', namespace) || entry.getChild('description');
if (summary) desc = summary.getText().substring(0, 150) + '...';
results.push({ title, link, date: dateStr, description: desc, source });
});
} catch (e) {
console.warn(`Feed取得エラー (${url}): ${e.toString()}`);
}
return results;
}
function getSentUrls() {
const prop = PropertiesService.getScriptProperties().getProperty('SENT_URLS');
return prop ? JSON.parse(prop) : [];
}
function saveSentUrls(newUrls) {
let current = getSentUrls();
current = current.concat(newUrls);
if (current.length > 300) {
current = current.slice(current.length - 300);
}
PropertiesService.getScriptProperties().setProperty('SENT_URLS', JSON.stringify(current));
}
謝辞
今回のコードと記事はGemini様とClaude様でペアプロで作成しました。
いつもありがとうございます。


