
「はてなブログ」や「Qiita」から記事を集約してインデックスを作るポータルみたいなページを作った。
2年前に一時期Gatsbyでshotanue.devドメインで技術ブログを運用していたが、Gatsbyのアップデートに失敗してめんどくさくなり捨ててしまっていた。が、後述のモチベにより新しく作り直した。
書いた記事を集約するサイトを作ったモチベ
技術記事を投稿するサイトは色々あって、上述以外にも「Zenn」「medium」「note」があったりするが、記事が書いた時に散らばっていく感じが気になっていた。全然記事書いてないけど。
このあたりの解決策は「LAPRASポートフォリオ」などあると思うが、仕事でNext.jsを触る機会があって素振りしたく、いい感じのお題を探していたのもあって自前で構築した。
(あと、この類のサイトで先人がおり、良いアイデアだなあと思っていたというのもある。)
www.komtaki.com
panda-program.com
つかったもの
以下の観点であれこれ考えた結果、このチョイスをしてみた。
- 簡単に作りたい
- AWSなりでサーバー運用はしたくない
- ガワはRemixなりAstroで何か作りたい発作が出た時に捨てやすくしておく
リソースサーバーをGASで作る
「はてなブログ」や「Qiita」から記事を集約する処理を書いた。Claspを使うとTypeScriptで書けるが、FetchしてレスポンスのXMLなりJSONを成形するだけで大した処理でもなく、簡単に作りたかったので使わなかった。実装は以下で済んでいる。
function doGet() {
const posts = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('posts').getDataRange().getValues();
return ContentService.createTextOutput(JSON.stringify({
posts: posts.map(x => ({
kind: x[0],
id: x[1],
title: x[2],
link: x[3],
publishedAt: x[4],
updatedAt: x[5],
}))
}));
}
function main() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('posts')
const posts = [
...hatena(),
...qiita(),
];
sheet.clearContents();
const range = sheet.getRange(1, 1, posts.length, 6);
range.setValues(posts);
range.sort({column: 5, ascending: false});
}
function fetchEntriesFromAtom(url, kind) {
const xml = XmlService.parse(UrlFetchApp.fetch(url).getContentText());
const atomNs = XmlService.getNamespace('http://www.w3.org/2005/Atom');
return xml.getRootElement().getChildren('entry', atomNs).map((entry) => {
const pick = (node) => entry.getChild(node, atomNs).getValue();
const pickAttribute = (node, attribute) => entry.getChild(node, atomNs).getAttribute(attribute).getValue()
return [
kind,
pick('id'),
pick('title'),
pickAttribute('link', 'href'),
pick('published'),
pick('updated'),
];
});
}
function hatena() {
return fetchEntriesFromAtom('https://shotanue.hatenablog.com/feed?exclude_body=1&size=100', 'shotanue.hatenablog.com');
}
function qiita() {
const entries = UrlFetchApp.fetch('https://qiita.com/api/v2/users/shotanue/items?page=1&per_page=100').getContentText();
return JSON.parse(entries).map((entry) => {
return [
'qiita.com',
entry['id'],
entry['title'],
entry['url'],
entry['created_at'],
entry['updated_at'],
];
});
}
接続するサービスが増えても、足していく場所は決まっているのでまあ大丈夫でしょう(多分)
const posts = [
...hatena(),
...qiita(),
];
このスクリプトのmain関数を実行すると以下のようなシートが出来上がる。

で、doGetを実装してあるので、GASをWebAppでデプロイしてAPIを立ててあげて、リソースサーバーの出来上がりということになる。
ガワをNext.jsで作る
Next.jsのGetting Started読みながら、
pnpm create next-app
を叩いて作った。
特に難しいことはしておらず、なんならhttps://github.com/igoradamenko/awsm.cssというClass less cssを使ってスタイル周りで手を抜いたおかげで、マークアップが大分単純になった。あまり中身がないというのもあるが。
<main>
<section>
<h2>Shotaro Hirukawa(@shotanue)</h2>
<p>Hello there, I am a web developer. Working in Japan.</p>
<p>Find me on</p>
<ul>
<li><a href="https://github.com/shotanue" target="_blank" rel="noreferrer">GitHub</a></li>
<li><a href="https://twitter.com/shotanue" target="_blank" rel="noreferrer">Twitter</a></li>
<li><a href="https://shotanue.hatenablog.com/" target="_blank" rel="noreferrer">Hatena Blog</a></li>
<li><a href="https://qiita.com/shotanue" target="_blank" rel="noreferrer">Qiita</a></li>
</ul>
</section>
<section>
<h2>articles</h2>
{posts.map((post) => (
<article key={post.id}>
<h3><a href={post.link} target="_blank" rel="noreferrer">{post.title}</a></h3>
<aside>
<time
dateTime={format(new Date(post.publishedAt), 'yyyy-MM-dd', {timeZone: 'Asia/Tokyo'})}
>
{format(new Date(post.publishedAt), 'yyyy-MM-dd', {timeZone: 'Asia/Tokyo'})}
</time>
<div>
<span>{post.kind}</span>
</div>
</aside>
</article>
))}
</section>
</main>
デプロイ
本当はCloudFlare Pagesを使いたかったが、慣れておらず、pnpm起因の問題などでビルドが全然上手くいかずyak shavingになってしまったのでVercelを使った。CloudFlareはまた今度時間がある時に見たいと思う。
というわけでしばらくVercel運用になる。
感想と今後
初めてGASでAPIを立てたが思ったより簡単にできたので、みんなこのへん触りながらMVP作ってるのかなと思いを馳せた。オレオレツール作るのにも便利そう。
今回のページ自体は勢いで作ったので「見た目もうちょっとどうにかしたい」「フッターがない」「GA入ってない」とか色々やることはあるが、Markdown for the component era | MDXが技術的に結構面白そうなので、shotanue.dev自体で記事をホストする方向でこの辺突っ込めないか色々企んでいる。