PowerShell と Node の自動化で Windows のパス・UTF-8 BOM・Cookie・symlink で壊れた箇所を決定論的に避ける
対象読者: Windows 上で PowerShell と Node.js を混在させた自動化スクリプトを運用していて、文字コード・ブラウザプロファイル・ファイル走査の事故を再現しづらい形で潰したい人。
既出確認: PUBLISHED.md の Qiita 欄は「GASで校務メールの定型文を自動生成する」「GASで『未提出一覧』を毎朝メールするミニ自動化」「GASで『校務メモ』をAI整形する最小構成」など、Google Apps Script と校務 AI 活用の記事が中心でした。この記事は PowerShell + Node + Windows のローカル自動化で起きた破損点に絞ります。
現象
PowerShell から Node を呼ぶ自動化で、次の3つが別々のタイミングで壊れました。
- PowerShell が出した UTF-8 BOM 付き JSON を Node 側で
JSON.parse()して落ちる - Chrome と Chromium のプロファイルを同じものとして扱い、Cookie DB のスキーマ差で読めない
- symlink を含むディレクトリを再帰走査し、同じ実体を重複処理したり、想定外の場所までたどる
どれも「たまたま動いた環境」を前提にすると再発します。回避策は、入力を受け取った直後に正規化し、判定できないものは処理対象から外すことです。
原因
UTF-8 BOM
PowerShell と Node の境界では、文字列として見る前に先頭の BOM を落としておかないと、Node 側で JSON の先頭文字が { ではなく \uFEFF になります。
PowerShell 側で BOM なし UTF-8 を明示し、Node 側でも読み込み直後に BOM を除去しておくと、どちらか片方の変更漏れで壊れにくくなります。
Chrome / Chromium の Cookie スキーマ
Chrome と Chromium は見た目が近くても、Cookie DB を同じ形式の永続ストアとして扱う前提にしない方が安全です。
自動化では「Cookie を読めたらログイン済み」と判定しがちですが、ここを雑にすると、別ブラウザ由来の DB を読み込んでスキーマ差で失敗します。ブラウザ種別とプロファイル種別を入力として固定し、違う組み合わせなら処理を止めます。
symlink 越しのファイル走査
再帰走査で symlink を通常ディレクトリと同じようにたどると、同じ実体を二重に処理したり、作業ディレクトリ外のファイルを拾うことがあります。
Windows では junction も絡むため、Dirent#isDirectory() だけで判断せず、isSymbolicLink() と realpath() で「たどるか」「重複か」を分けて扱います。
回避コード
1. PowerShell から BOM なし UTF-8 で JSON を書く
検証済みです。
$data = [ordered]@{
ok = $true
path = ".\input.json"
}
$json = $data | ConvertTo-Json -Depth 10
$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllText(".\payload.json", $json, $utf8NoBom)
Node 側でも、読む直後に BOM を落とします。
検証済みです。
const fs = require("node:fs");
function readJsonUtf8(file) {
const text = fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "");
return JSON.parse(text);
}
const payload = readJsonUtf8("./payload.json");
console.log(payload);
PowerShell 側の出力を直せない場面でも、Node 側の replace(/^\uFEFF/, "") が最後の受け皿になります。
2. Cookie DB は「ブラウザ種別」とセットで扱う
未検証コードです。ブラウザプロファイルを直接読む処理に入る前のガード例です。
const path = require("node:path");
const allowedProfiles = {
chrome: [".browser-profile-chrome"],
chromium: [".browser-profile-chromium"],
};
function assertProfileKind(browserKind, profileDir) {
if (!Object.hasOwn(allowedProfiles, browserKind)) {
throw new Error(`unsupported browser kind: ${browserKind}`);
}
const baseName = path.basename(profileDir);
if (!allowedProfiles[browserKind].includes(baseName)) {
throw new Error(
`profile mismatch: browser=${browserKind}, profile=${baseName}`
);
}
}
assertProfileKind("chromium", ".browser-profile-chromium");
ここで重要なのは、Cookie DB の中身を読みに行く前に止めることです。スキーマの違いを吸収しようとせず、ブラウザ種別とプロファイルを別物として扱います。
3. symlink を再帰走査でたどらない
未検証コードです。ディレクトリ内の通常ファイルだけを集め、symlink は明示的にスキップします。
const fs = require("node:fs/promises");
const path = require("node:path");
async function listFilesNoSymlink(root) {
const seenRealDirs = new Set();
const files = [];
async function walk(dir) {
const realDir = await fs.realpath(dir);
if (seenRealDirs.has(realDir)) return;
seenRealDirs.add(realDir);
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isSymbolicLink()) {
continue;
}
if (entry.isDirectory()) {
await walk(fullPath);
continue;
}
if (entry.isFile()) {
files.push(fullPath);
}
}
}
await walk(root);
return files;
}
listFilesNoSymlink(".").then((files) => {
for (const file of files) console.log(file);
});
symlink を「便利なショートカット」として扱うのではなく、再帰走査の境界として扱います。必要な symlink だけを許可したい場合は、continue の前に許可リスト判定を入れる形にします。
確認方法
BOM の確認
PowerShell で BOM 付きのバイト列を作り、読み込み直後に \uFEFF を落として JSON として読めることを確認します。
$json = '{"ok":true,"path":".\\input.json"}'
$bytes = [Text.Encoding]::UTF8.GetPreamble() + [Text.Encoding]::UTF8.GetBytes($json)
$text = [Text.Encoding]::UTF8.GetString($bytes) -replace '^\uFEFF',''
$obj = $text | ConvertFrom-Json
$obj.ok
Node でも同じ確認ができます。
const raw = Buffer.from([
0xef,
0xbb,
0xbf,
...Buffer.from(JSON.stringify({ ok: true, path: ".\\input.json" }), "utf8"),
]);
const text = raw.toString("utf8").replace(/^\uFEFF/, "");
console.log(JSON.parse(text).ok);
どちらも True または true が出れば、BOM 付き入力を受けても JSON として扱えています。
Cookie プロファイルの確認
Cookie DB の中身を読む前に、ブラウザ種別とプロファイル名の対応だけを確認します。
node .\check-profile-kind.js
Chrome 用プロファイルを Chromium として渡した場合にエラーで止まれば、混在を入口で防げています。
symlink 走査の確認
通常ディレクトリだけを走査し、symlink を含めても同じファイルが重複して出ないことを確認します。
node .\list-files-no-symlink.js
出力に symlink 配下のファイルが混ざらなければ、再帰走査の境界として機能しています。
まとめ
PowerShell と Node を混ぜた Windows 自動化では、壊れる場所を「実行処理の中」ではなく「境界」に寄せて潰すと扱いやすくなります。
- PowerShell から書く JSON は BOM なし UTF-8 にする
- Node は入力直後に BOM を落としてから
JSON.parse()する - Chrome と Chromium の Cookie DB は同一視しない
- symlink は通常ディレクトリとして再帰しない
環境差をなくすより、環境差が入る境界を固定して、判定不能なら止める。Windows のローカル自動化では、この方が再現性を保ちやすいです。