Windows PowerShell — 山内陽@portfolio\focus-areas\04
PS C:\Users\yamauchi\portfolio\focus-areas\04>

#

実際に使われ続けているサイトと、毎日のブラウザ操作を片付ける拡張機能。公開したあとに止まらないこと、使う人を待たせないことを考えて作りました。

§01

boyscout-tajimi

PS> Get-Item .\boyscout-tajimi | Format-List *

ボーイスカウト多治見第一団の公式サイトと、Webに詳しくない指導者でも更新を続けられる仕組み。

ボーイスカウト多治見第一団の公式サイトのトップページ
公開中のトップページ
4種類の投稿フォーム
10ヶ月開発期間
265コミット
  • 2025.05〜2026.03
  • 個人開発・運用中
  • Node.js / Express 5 / EJS / PostgreSQL

1使っている画面

B-2ニュースを投稿するGoogleフォームの入力画面静止画 PNG → images/works/boyscout/01-form.png
活動報告を投稿するGoogleフォーム。いつもの手順で書いて送ります
B-3左のフォームから投稿した記事が、公開ページに載っている画面静止画 PNG → images/works/boyscout/02-published.png
送った内容が、サイトの記事になる。写真つきで公開されます
boyscout-tajimiの技術的な詳細を読む(設計・つまずき・コード)
管理画面のサイト設定。トップの画像を選んで差し替えられる
写真や文言は、管理画面から差し替えられます
B-4スマホで見たトップページ(縦長の画面)静止画 PNG → images/works/boyscout/03-mobile.png
スマホで見たトップページ
B-5デプロイが終わったときのDiscordの通知静止画 PNG → images/works/boyscout/04-discord.png
反映が終わると、Discordに知らせが届きます

2作品の詳細

外から届く要求を、疑ってかかる

Webhookは誰でも叩けるURLなので、送り主がApps Scriptであることを署名で確かめます。送る側は「時刻.本文」にHMAC-SHA256で署名し、受ける側は同じ計算をして比べます。

  • 時刻が5分以上ずれていたら受け付けません。盗み見た要求を後から送り直されても通りません(リプレイ攻撃への対策)
  • 署名の照合には、かかる時間が中身によって変わらない関数を使います(タイミング攻撃への対策)
  • 署名は生の本文に対して検証するので、JSONとして読むのはそのあと
入口回数の上限
Webhook1分に10回
ログイン15分に10回
お問い合わせ1分に3回、1時間に10回

管理画面での操作は、誰が・いつ・どこから・何をしたかを監査ログに残します。

問い合わせフォームは、ボットに気づかせない

入力欄の中に、人には見えない欄を1つ混ぜています(ハニーポット)。そこが埋まっていたり、フォームを開いてから3秒たたずに送られてきたりしたら、ボットと見なします。ボットには「ありがとうございます」と成功したように返し、何も送りません。通った問い合わせだけが、管理者にメールで届きます。

公開しているお問い合わせフォーム
公開しているお問い合わせフォーム
管理者に届いたお問い合わせのメール
通ったものだけが、管理者にメールで届きます

公開したあとに、止めない

mainにpushすると、GitHub ActionsがVPSへ反映します。途中で失敗しても、サイトが落ちたままにならないようにしました。

  1. ビルドと構文チェック通らなければ、ここで止めてDiscordに知らせます
  2. サーバーをmainの最新に合わせるVPSにSSHで入り、サーバー上のファイルをmainの最新にそろえます
  3. 依存パッケージを入れ直す失敗したとき、依存の定義が変わっていれば直前のコミットに戻します。変わっていなければ、今動いているパッケージのまま続けて、サイトを止めません
  4. 再起動して、実際にアクセスして確かめるpm2で再起動し、HTTPで応答を確認します。だめならログを出します
  5. 結果をDiscordに知らせる

3こだわったこと

Webに詳しくない人が、更新を続けられる

やりたかったこと

活動を続けている指導者が、自分の手で記事を出し続けられること。更新が止まったサイトは、かえって団体の印象を悪くしてしまいます。

ぶつかった壁

更新するのは、Web制作に慣れていない人たち。管理画面にログインして、操作を覚えてもらうだけでも負担になります。

どう作ったか

管理画面に加えて、ふだん使っているGoogleフォームからも投稿できるようにしました。フォームの内容は、Google Apps Scriptが署名をつけてサイトに送ります。写真もドライブから取り込み、表示用の大きさに縮めて保存します。

指導者 管理画面 ログインして書く Googleフォーム ニュース・活動報告 Apps Script 署名をつけて送る ドライブの写真 1280×720以内のWebPに サーバー Express 5 公開ページ SSRで返す Webhook
フォームは、ニュースと活動報告のそれぞれに「簡単」と「Googleドキュメント連携」の2種類があります。
どうなったか

フォームに書いて送るだけで、写真つきの記事が公開されます。サイトの仕組みを知らなくても、いつもの手順で更新できます。

つまずいたところ日本語が入った記録だけ、署名が合わない
起きたこと

Googleフォームから送った活動記録の一部が、署名の検証で弾かれました。

原因

切り分けのため、英数字だけの本文を、時刻を固定して送るテスト用の関数を作りました。これは通ります。日本語を入れると、通りません。署名の計算ではなく、日本語をバイト列にするところで食い違っていると分かりました。

署名は、文字列ではなくバイト列から計算します。サーバー側(Node.js)は UTF-8 を指定して計算していましたが、Google Apps Script 側は文字コードを指定していませんでした。

直し方

送る前に、本文の英数字以外の文字を、すべて \uXXXX 形式のエスケープに置き換えました。本文が英数字と記号だけになるので、どちらの環境でも同じバイト列になります。

JSON としての意味は変わらないので、受け取る側は何も直さずに済みました。

docs/gas-webhook-code-v2.js(581〜582行目)

let body = JSON.stringify(payload);
body = body.replace(/[^\x00-\x7F]/g, c => '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4));

直したときのコミット:cc20e3a(2026年1月3日)

要になったコード5分より古い要求は、署名を見る前に断る

src/server/middleware/auth.middleware.js(35〜55行目)

const tol = parseInt(process.env.HMAC_TOLERANCE_SEC || '300', 10);
const now = Math.floor(Date.now() / 1000);
const ts = parseInt(String(timestamp || ''), 10);
if (!ts || Math.abs(now - ts) > tol) return false;
…
const expBuf = crypto.createHmac('sha256', secret).update(`${ts}.${bodyRaw}`, 'utf8').digest();
…
return gotBuf.length === expBuf.length && crypto.timingSafeEqual(gotBuf, expBuf);

Google Apps Script は、送る時刻と本文をまとめて署名します。受け取る側は、まず送信時刻が5分以内かを確かめ、それから同じ秘密の鍵で署名を作り直して比べます。

盗んだ要求をあとから送り直しても、5分を過ぎれば通りません。比べるときは timingSafeEqual を使い、比べ終わるまでの時間から正解を探られないようにしています。

§02

DownloadEx

PS> Get-Item .\DownloadEx | Format-List *

ダウンロードしたファイルを、ルールに従って自動でフォルダに振り分けるChrome拡張。

DownloadExの新規ルール画面。サイトパターン、マッチ条件、保存先フォルダ名、ファイルタイプ、判定対象、優先度を入力する
ルールの作成画面
3つの権限だけで動く
v1.3.8公開中
  • 2025.07〜2026.04
  • 個人開発
  • JavaScript / Manifest V3
DownloadExの画面と技術的な詳細を読む(設計・つまずき・コード)

1使っている画面

DownloadExの振り分けルールの一覧
ルールの一覧。スイッチで1つずつ止められます
X-2ページやリンクを右クリックしたときに出る、DownloadExのメニュー静止画 PNG → images/works/downloadex/01-context-menu.png
右クリックから、そのサイトのルールを作る。サイト名が入った状態で新規ルールが開きます
X-4振り分けたあとのダウンロードフォルダ(エクスプローラーで、サイトごと・日付ごとのフォルダが並んでいる画面)静止画 PNG → images/works/downloadex/03-folders.png
ダウンロードした瞬間に、振り分け済み。ルールに合ったものはサイトごとのフォルダへ入り、合わなかったものはいつもの場所に残ります

2作品の詳細

ルールで決めること

項目選べるもの
どのURLで判定するかファイルそのもののURLか、ダウンロードしたページのURLか
一致のしかた部分一致・完全一致・正規表現
ファイルの種類拡張子で絞る(任意)
保存先{YYYY} {MM} {DD} {YYYY-MM-DD} {domain} を差し込める。フォルダ名に使えない文字は置き換える
優先度複数のルールに当てはまったら、数字の大きい方を使う

求める権限は、ダウンロード・保存・右クリックメニューの3つだけ。閲覧しているページの中身には触りません。

右クリックから、そのサイトのルールを作る

ページの上でもリンクの上でも、右クリックメニューの「DownloadEx: このサイトのルールを作成」からルールを作れます。リンクの上ならリンク先の、ページの上なら今見ているページのサイト名が入った状態で、設定画面の新規ルールが開きます。

3こだわったこと

ダウンロードのたびに、待たせない

やりたかったこと

ダウンロードした瞬間に、もう正しいフォルダに入っています。拡張機能が動いていることを、使う人に意識させたくありませんでした。

ぶつかった壁

Manifest V3の拡張は、しばらく使わないと裏で動く処理(Service Worker)ごと止められ、次のダウンロードで起こされます。起きるたびにルールを読み直すと、そのぶん保存が遅れます。

端末間で同期される保存先(storage.sync)は便利ですが、読み込みに時間がかかります。

どう作ったか

同期される保存先の写しを、手元の速い保存先(storage.local)に作っておきます。起動したらまず写しから読み、判定に使う形に先に直してメモリに持ちます。

storage.sync ルールの保存先。端末間で同期 storage.local 同期先の写し。読み込みが速い メモリ上のルール 正規表現は先にコンパイルしておく 比べる文字は小文字にしておく 優先度の高い順に並べておく onDeterminingFilename 保存先を決める 変わるたびに写す 起動したらここから読む 変わったら作り直す

書き方の間違った正規表現は、この段階で見つけて無効にします。ダウンロードのたびに例外で止まることがありません。

どうなったか

起こされた直後でも、同期される保存先の遅い読み込みを待たずに、手元の写しからすぐに振り分けられます。

つまずいたところURLが合わないルールでも、振り分けてしまう
起きたこと

URLが合っていないダウンロードまで、優先度がいちばん高いルールのフォルダへ入っていました。反対に、ファイルの種類を指定していないルールは、URLが合っても使われませんでした。

原因

ルールを優先度の順に調べるループで、URLが合ったときだけ、ファイルの種類を確かめていました。合わなかったときに次のルールへ進む処理がなく、そのまま振り分けの処理まで素通りしていました。

ファイルの種類の判定も、「指定なし」を「どれにも当たらない」と読んでいました。

直し方

URLが合わなければ、その場で次のルールへ進むようにしました。条件を入れ子にせず、合わない条件があれば先に抜けるガード節の形にしたので、外れたルールが下へ素通りすることはありません。ファイルの種類は、指定がなければ全部を対象にします。

background.js(335〜344行目)

// URLマッチしなければ次のルールへ
if (!isMatch) continue;

// ファイルタイプ指定がある場合のみ拡張子チェック
// 空配列 = 全ファイルタイプ対象なのでチェックをスキップ
if (rule.lowerCaseFileTypes.length > 0) {
    if (!ext || !rule.lowerCaseFileTypes.includes(ext)) {
        continue;
    }
}

直したときのコミット:813fa07(2026年2月19日)

要になったコード壊れた正規表現は、最初の1回で見つけて外す

background.js(143〜148行目)

const compiledRegex = (rule.matchType === 'regex' && rule.sitePattern)
    ? (() => {
        try { return new RegExp(rule.sitePattern, 'i'); }
        catch (e) { console.error(`[DownloaderExt] Invalid Regex: ${rule.sitePattern}`); return null; }
    })()
    : null;

ルールを読み込んだときに、正規表現を1回だけ組み立てておきます。書き方が間違っていれば、ここで null にして、そのルールだけを外します。

ダウンロードのたびに組み立て直さないので速く、間違ったルールがあっても、ほかのルールの振り分けは止まりません。