Windows のタスクスケジューラの設定を、Node のスクリプトから schtasks /query /TN <タスク名> /XML で読んでいます。
取り出した XML から <Arguments> の中のパスを拾い、そのファイルが在るかを fs.existsSync で確かめる、という使い方です。
日本語を含むパス(ユーザー名が日本語)の機械で、このパスが existsSync で false になりました。
原因は、XML の宣言と実際のバイト列の文字コードが一致していないことでした。
実測: 同じタスクを3通りのコードページで取った
Windows 11 10.0.26200(日本語)/ Node v24.18.1 / 1台だけの実測です(2026-09-19)。
Node の execSync で shell: "cmd.exe" を指定して 呼び、返ってきたバイト列をそのまま調べました。
測ったのはこの呼び方だけです。
| コードページ | バイト数 | 先頭4バイト | 宣言 | UTF-8 として読んだときの置換文字(U+FFFD) | 日本語1語が読めた符号 |
<Arguments> のパスで existsSync(UTF-8 で読む / Shift_JIS で読む) |
|---|---|---|---|---|---|---|
| 指定なし(継いだ値 932) | 1,864 | 3c 3f 78 6d |
encoding="UTF-16" |
52個 | Shift_JIS | false / true |
chcp 932 |
1,864 | 3c 3f 78 6d |
encoding="UTF-16" |
52個 | Shift_JIS | false / true |
chcp 65001 |
1,909 | 3c 3f 78 6d |
encoding="UTF-16" |
0個 | UTF-8 | true / false |
読み取れること(この1台・この3条件について):
- **先頭4バイトは
3c 3f 78 6d(<?xm)で、BOM がありません。**UTF-16 なら1文字2バイトになるので、この並びにはなりません - **宣言は3回とも
encoding="UTF-16"で、実体は3回とも UTF-16 ではありません。**UTF-16LE として読んでも日本語1語は見つかりませんでした - **測った2つのコードページでは、実体はそのときのコードページの符号でした。**932 なら Shift_JIS、65001 なら UTF-8 です(バイト数も変わります)。他のコードページは測っていません
cmd.exe経由でコードページを指定せずに呼ぶと、継いだ値(この機械では 932)の符号になりました
つまり、測った範囲では次の3つの読み方がどれも、ある条件で壊れました。
| 読み方 | 壊れた条件(測った3条件のうち) |
|---|---|
| XML の宣言に従う(UTF-16) | 3条件すべて(実体が UTF-16 ではない) |
| UTF-8 と決め打ちする | コードページが 932 のとき(この機械の既定) |
| Shift_JIS と決め打ちする | コードページが 65001 のとき |
何が起きたか
Node の Buffer#toString() の既定は UTF-8 です。
コードページを指定せずに schtasks /query /XML を呼び、その出力を既定のまま文字列にすると、
**日本語の部分が置換文字(U+FFFD)に化けます。**ASCII の部分は壊れないので、XML としてはそれらしく読めてしまいます。
その文字列から <Arguments> の中の引用符つきのパスを取り出して fs.existsSync にかけると false でした。
同じバイト列を Shift_JIS として読めば true です(上の表の1行目)。
例外は出ません。「ファイルが無い」と区別がつかない形で false が返ります。
ASCII だけのパスなら壊れないので、英語のユーザー名の機械では再現しないはずです(確かめてはいません)。
注意: chcp は、呼んだ側のコンソールのコードページも書き換える
execSync("chcp 65001 >nul & ...", { shell: "cmd.exe" }) の chcp は子の cmd.exe の中で実行されますが、
**終わったあとも、親のコンソールのコードページは 65001 のまま残りました。**子が親と同じコンソールを共有しているためだと考えていますが、仕組みはドキュメントで確かめていません。
| 実行したもの | 実行前のコンソール | 実行後のコンソール |
|---|---|---|
| 最後に戻さない版の測定スクリプト | 932 | 65001 |
| 最後に起動時の値へ戻す版(下に全文) | 932 | 932 |
(PowerShell のコンソールで chcp 932 にしてから node で走らせ、終わったあと同じコンソールで chcp を読みました。)
上の表の「Shift_JIS と決め打ちする」が壊れる条件は、この副作用で作られます。
コードページを変えて呼ぶなら、起動時の値を読んでおき、終わったら戻してください。
確かめ方
下のスクリプトは依存パッケージなしで、ファイルへの書き込みも通信もしません。ただしコンソールのコードページを一時的に変え、最後に起動時の値へ戻します(戻ったかどうかも codePage.before / codePage.after として出します)。
パスやユーザー名は出力しません(バイト数・先頭4バイト・真偽だけを出します)。
2つめの引数には、そのタスクの XML に含まれているはずの日本語を1語渡します(タスク名に日本語が入っていれば、それで足ります)。
node repro-schtasks-xml-encoding.mjs "<タスク名>" "<XML に含まれる日本語1語>"
argPathFound が false のときは、<Arguments> の中に引用符つきの文字列が1つも無かったという意味です(<Command> にだけパスを書いたタスク、引用符が " と書かれている XML など)。そのときパスの2列は null になり、existsSync にはかけていません。拾うのは最初の引用符つきの文字列で、それがパスとは限りません("--flag" のような引数なら、existsSync は当然 false です)。
codePage.before が null のときは、起動時のコードページを読めなかったので**戻していません。**その場合は自分で chcp を確かめてください。
// schtasks /query /XML が返すバイト列の文字コードを測る(依存0・ファイルへの書き込みなし)。
// コードページ(chcp)を「指定なし / 932 / 65001」の3通りにして、cmd.exe 経由で同じタスクの XML を取る。
// ★副作用: chcp は子の cmd.exe で実行しても、同じコンソールを使う親のコードページまで書き換える。
// そのため最後に、起動時のコードページへ戻し、戻ったかどうかも出力する。
// 本文(パスやユーザー名)は1文字も出さない。出すのはバイト数・先頭4バイト・判定の真偽だけ。
// 使い方: node repro-schtasks-xml-encoding.mjs <タスク名> <XML に含まれるはずの日本語1語>
import { execSync } from "node:child_process";
import fs from "node:fs";
const [task, probe] = process.argv.slice(2);
if (!task || !probe) {
console.error("使い方: node repro-schtasks-xml-encoding.mjs <タスク名> <XML に含まれるはずの日本語1語>");
process.exit(1);
}
const run = (cmd) => execSync(cmd, { shell: "cmd.exe", env: { ...process.env, REPRO_TASK: task } });
const currentCp = () => (run("chcp").toString().match(/\d+/) || [null])[0];
const inheritedCp = currentCp();
// <Arguments> の中の、引用符で囲まれた最初のパス。見つからなければ null(existsSync にはかけない)
const argPath = (text) => (text.match(/<Arguments>[^<]*?"([^"]+)"/) || [null, null])[1];
const exists = (p) => (p === null ? null : fs.existsSync(p));
const results = [];
try {
for (const cp of [null, "932", "65001"]) {
// タスク名は JS 側でコマンド文字列に連結せず、環境変数で渡す(展開するのは cmd)
const query = 'schtasks /query /TN "%REPRO_TASK%" /XML';
const buf = run(cp ? `chcp ${cp} >nul & ${query}` : query);
const asUtf8 = buf.toString("utf8");
const asSjis = new TextDecoder("shift_jis").decode(buf);
results.push({
chcp: cp ?? `指定なし(継いだ値 ${inheritedCp})`,
bytes: buf.length,
head: [...buf.subarray(0, 4)].map((b) => b.toString(16).padStart(2, "0")).join(" "),
declaration: (asUtf8.match(/<\?xml[^>]*\?>/) || [null])[0],
utf8ReplacementChars: (asUtf8.match(/\uFFFD/g) || []).length,
probeReadableAsUtf8: asUtf8.includes(probe),
probeReadableAsShiftJIS: asSjis.includes(probe),
probeReadableAsUtf16le: new TextDecoder("utf-16le").decode(buf).includes(probe),
// 引用符つきのパスが <Arguments> に在ったか(false なら下の2つは null)
argPathFound: argPath(asUtf8) !== null,
// そのパスを2通りに読んで fs.existsSync にかけた結果(パスそのものは出さない)
argPathExistsAsUtf8: exists(argPath(asUtf8)),
argPathExistsAsShiftJIS: exists(argPath(asSjis)),
});
}
} finally {
if (inheritedCp) run(`chcp ${inheritedCp} >nul`);
}
console.log(JSON.stringify({
measuredAt: new Date().toISOString(), node: process.version, probe, results,
codePage: { before: inheritedCp, after: currentCp() },
}, null, 1));
直し方の候補(測ったのは1つ目だけ)
-
コードページを自分で決めてから呼び、終わったら戻す。
chcp 65001 >nul & schtasks ... /XMLにして UTF-8 として読むと、置換文字は0個・existsSyncも true でした(表の3行目)。**戻す処理は上のスクリプトのfinallyの形で、正常に終わった場合に戻ることを確かめました。**途中で失敗した場合は測っていません。Ctrl+C で止めた場合はfinallyが走らず、65001 のまま残ることがあります(これも測っていません) - 読んだ文字列に U+FFFD が含まれていたら、別の方法で取り直す(PowerShell の
Export-ScheduledTaskなど)。Export-ScheduledTask側の文字コードは今回測っていません
どちらにしても、この実測の範囲では、XML の宣言に書かれた encoding は実体と一致しませんでした。
測っていないこと
- 測ったのはこの PC 1台・日本語の Windows 11 だけです。他の言語の Windows、他の版の Windows では確かめていません
- 測ったコードページは 932 と 65001 の2つだけです
-
cmd.exe経由のexecSync以外(execFileで直接起動、PowerShell から呼ぶ、ファイルへリダイレクトする)は測っていません - なぜ宣言が UTF-16 のままなのかは分かりません(ドキュメントでは確かめていません)
タスクスケジューラで定期実行している構成を、読み取りだけで点検する道具を置いています: https://h26005.tailf680df.ts.net:10000/free.html