給与明細・納税・申請履歴などの公共ポータルは、Ajaxや文字コードの違い、SPA化によって「画面には見えるのに取得できない」問題が起きがちです。本記事では Microsoft Edge(Chromium)を前提に、合法性と堅牢性、パフォーマンスを両立したデータ抽出の考え方と実装手順を、実運用レベルのコードとともに丁寧に解説します。
想定シナリオとゴール
対象は「自分または組織が適法にアクセスできる」公共系ポータル(給与明細、年末調整、税・保険、申請管理など)。最終ゴールは、取得の安定化(動的DOM・Ajax・文字化け対策)、適切な実装形態の選定(ブラウザ内JS/拡張/WebView2/ヘッドレス)、パフォーマンス最適化・監査ログ・セキュリティを満たすことです。
結論(最短ルート)
- 第一選択は公式API/構造化エンドポイント:JSONやCSVが公開されていれば
fetch()で直取りが最も堅実。 - HTMLのみならDOMパース:
querySelectorAllで抽出し、動的要素はMutationObserverで安定化。 - Ajaxの実体を見つける:Edge DevToolsのNetworkでXHR/FetchのURL・形式・文字コードを特定。
- 文字化けは明示デコード:
arrayBuffer()→TextDecoderでUTF-8/Shift_JIS/EUC-JP等を確定。 - レンダリング後DOMが必須なら:Playwright/Puppeteer(Edgeチャンネル)やWebView2/拡張機能で自動化。
- 法令順守と最小権限:利用規約・
robots.txt・個人情報保護に配慮し、資格情報の安全管理を徹底。
アーキテクチャ比較(早見表)
| 方式 | 主な用途 | 長所 | 注意点 | 運用適性 |
|---|---|---|---|---|
| ブラウザ内JavaScript(ブックマークレット/DevTools/Userscript) | 都度の手動取得、素早い試行 | 導入が速い、CORSの壁が少ない(同一タブ) | 手動依存、拡張性に限界 | 単発・小規模 |
| Edge 拡張機能(Manifest V3) | 日常業務での半自動抽出 | Content ScriptでDOM直読、権限と保存先を分離 | 審査・権限設計、MV3の非同期SWに慣れが必要 | 中規模・配布前提 |
| WebView2(.NET/Win32) | 業務アプリに組込み、オフライン連携 | ランタイム一体、双方向メッセージで堅牢 | デスクトップ配布・更新設計が必要 | 社内配布・監査重視 |
| Playwright / Puppeteer(ヘッドレスEdge) | 定期バッチ、レンダリング依存ページ | 待機・操作が強力、CI/CDやタスクスケジューラと好相性 | 実行環境の準備・ランタイムコスト | 定期・大量・自動 |
第一選択:公式API/構造化データを使う
多くの公共ポータルは、画面裏でJSON/XLSX/CSVのエンドポイントを叩いています。NetworkタブでURLとパラメータ、認可方式(Cookie、トークン、Referer等)を特定し、fetch()で直接取得しましょう。304(未変更)を活用して差分取得し、ETag/If-None-MatchやIf-Modified-Sinceを使えば通信量を削減できます。
// 再試行・差分対応つきfetch(JSON前提)
async function fetchJsonWithCache(url, { etag, maxRetries = 3, ...init } = {}) {
for (let i = 0; i < maxRetries; i++) {
const headers = new Headers(init?.headers || {});
if (etag) headers.set('If-None-Match', etag);
const res = await fetch(url, { ...init, headers, credentials: 'include' });
if (res.status === 304) return { status: 304, etag, data: null };
if (!res.ok) {
// 429/503は指数バックオフ
if (i < maxRetries - 1 && [429, 503].includes(res.status)) {
await new Promise(r => setTimeout(r, 2 ** i * 500));
continue;
}
throw new Error(`HTTP ${res.status}`);
}
const newEtag = res.headers.get('ETag') || null;
const data = await res.json();
return { status: 200, etag: newEtag, data };
}
}
APIが見つからない/認証が厳格な場合
- NetworkタブでXHR/Fetchを「type: xhr」に絞り、レスポンスがJSON/CSVかを確認。
- 画面でフィルタ入力後にURLやQueryStringが変化するか観察。
- トークンはセッション固有の場合があるため、毎回の取得前に当該画面を先に開くフローに。
HTMLのみ:DOMパースで確実に抜く
構造化エンドポイントが無い場合は、DOMから安全に抽出します。textContent.trim()で空白を整理し、列ヘッダが<th>で与えられている場合はキーとして利用します。
// テーブル → 配列(ヘッダ対応)
function tableToObjects(table) {
const headers = [...table.querySelectorAll('thead th')].map(th => th.textContent.trim());
const rows = [...table.querySelectorAll('tbody tr')].map(tr => {
const cells = [...tr.querySelectorAll('td')].map(td => td.textContent.trim());
if (headers.length === cells.length) {
return Object.fromEntries(headers.map((h, i) => [h, cells[i]]));
}
return cells;
});
return rows;
}
// 画面上の最初のテーブルを抽出し、CSVでダウンロード
(function () {
const table = document.querySelector('table');
if (!table) { console.warn('table not found'); return; }
const data = tableToObjects(table);
const headers = Array.isArray(data[0]) ? data[0].map((_,i)=>`Col${i+1}`) : Object.keys(data[0] || {});
const rows = data.map(r => Array.isArray(r) ? r : headers.map(h => r[h] ?? ''));
const csv = [headers, ...rows].map(cols => cols.map(v => `"${String(v).replace(/"/g, '""')}"`).join(',')).join('\r\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `export_${new Date().toISOString().slice(0,10)}.csv`;
a.click(); URL.revokeObjectURL(url);
})();
動的追加要素はMutationObserverで安定化
// 目的のノードが現れるまで待機してから抽出
function waitForNode(selector, { timeout = 10000, root = document } = {}) {
return new Promise((resolve, reject) => {
const found = root.querySelector(selector);
if (found) return resolve(found);
const obs = new MutationObserver(() => {
const el = root.querySelector(selector);
if (el) { obs.disconnect(); resolve(el); }
});
obs.observe(root, { childList: true, subtree: true });
if (timeout) setTimeout(() => { obs.disconnect(); reject(new Error('timeout')); }, timeout);
});
}
// 例:Ajaxで描画される給与明細テーブルを待って抽出
(async () => {
try {
const table = await waitForNode('table#payslip');
console.table(tableToObjects(table));
} catch (e) {
console.error('抽出失敗', e);
}
})();
Ajaxの実体を特定する(Edge DevTools)
EdgeでF12 → Network → Fetch/XHRを選び、画面操作に伴って飛ぶリクエストを観察します。URL、クエリ、レスポンスのContent-Type、charset、Cookie/ヘッダ、ステータス、キャッシュ制御を確認し、直接fetch()するか、必要な前処理(トークン取得や画面遷移)を設計します。Preview/ResponseパネルでJSON/CSV/HTMLかを瞬時に見分けるのがコツです。
日本語の文字化けを根治する:TextDecoder戦略
公共ポータルではUTF-8/Shift_JIS/CP932/EUC-JP/UTF-16が混在します。fetch().arrayBuffer()で生バイトを取り、Content-Typeと<meta charset>からエンコーディングを推定し、TextDecoderで明示的にデコードします。
function sniffEncodingFromHeadersAndHtml(contentType, headChunk) {
// 1) Content-Typeヘッダ
if (contentType) {
const m = contentType.match(/charset\s*=\s*([^\s;]+)/i);
if (m) return m[1].toLowerCase();
}
// 2) HTML先頭からmeta読み取り(ASCIIで十分)
const ascii = new TextDecoder('iso-8859-1', { fatal: false }).decode(headChunk);
const m2 = ascii.match(/<meta[^>]*charset=["']?\s*([\w\-]+)\s*["']?/i);
if (m2) return m2[1].toLowerCase();
return null;
}
function decodeWithFallback(buffer, contentType) {
const bytes = new Uint8Array(buffer);
// BOM
if (bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF) {
return new TextDecoder('utf-8').decode(bytes.subarray(3));
}
if ((bytes[0] === 0xFF && bytes[1] === 0xFE) || (bytes[0] === 0xFE && bytes[1] === 0xFF)) {
return new TextDecoder('utf-16').decode(bytes);
}
const head = bytes.subarray(0, Math.min(bytes.length, 2048));
const enc = sniffEncodingFromHeadersAndHtml(contentType, head) || 'utf-8';
const candidates = enc.toLowerCase().includes('shift') ? ['shift_jis','cp932','utf-8'] :
enc.toLowerCase().includes('euc') ? ['euc-jp','utf-8','shift_jis'] :
[enc,'utf-8','shift_jis','euc-jp'];
for (const name of candidates) {
try {
const txt = new TextDecoder(name).decode(bytes);
// 置換文字率が高い場合は次候補
const bad = (txt.match(/\uFFFD/g) || []).length / Math.max(1, txt.length);
if (bad < 0.01) return txt;
} catch (e) {}
}
// 最後にUTF-8で
return new TextDecoder('utf-8').decode(bytes);
}
// 汎用:HTMLを文字化けなく取得
async function fetchHtmlDecoded(url, init = {}) {
const res = await fetch(url, { ...init, credentials: 'include' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = await res.arrayBuffer();
const text = decodeWithFallback(buf, res.headers.get('content-type'));
return new DOMParser().parseFromString(text, 'text/html');
}
レンダリング後が必須なら:Playwright / Puppeteer(Edge)
SPAやCanvas描画など「ネットワークだけでは完結しない」場合は、ヘッドレスにページを開き、必要なUI操作と待機の上でDOMから取ります。Edgeをチャンネル指定すればローカルと近い挙動を再現できます。
// Playwright(Edge指定)。Node.jsで実行
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const ctx = await browser.newContext({
viewport: { width: 1280, height: 800 },
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Edge',
});
const page = await ctx.newPage();
await page.goto('https://portal.example.go.jp/login', { waitUntil: 'domcontentloaded' });
// 環境変数で資格情報を渡す(ハードコード禁止)
await page.fill('#userid', process.env.PAYROLL_USER || '');
await page.fill('#password', process.env.PAYROLL_PASS || '');
await page.click('button[type=submit]');
// 一覧画面へ
await page.waitForURL('**/payslip');
await page.waitForLoadState('networkidle');
// Ajaxの実体も保存(将来の直取り用)
page.on('response', async (res) => {
try {
const req = res.request();
if (['xhr','fetch'].includes(req.resourceType())) {
const url = req.url();
if (url.includes('/api/payslip')) {
const json = await res.json();
console.log('API例', url, JSON.stringify(json).slice(0,200));
}
}
} catch {}
});
// DOMから抽出
const rows = await page.$$eval('table#payslip tbody tr', trs =>
trs.map(tr => [...tr.querySelectorAll('td')].map(td => td.textContent.trim()))
);
// CSV保存
const csv = rows.map(r => r.map(v => `"${v.replace(/"/g,'""')}"`).join(',')).join('\r\n');
const fs = require('fs');
fs.writeFileSync('payslip.csv', csv, 'utf8');
await browser.close();
})();
Edge特有の選択肢:WebView2 と 拡張機能(MV3)
WebView2でデスクトップに組み込む
WebView2はEdgeランタイムをホストして動くため、CoreWebView2を介してスクリプト注入・双方向メッセージが可能です。企業内配布・監査要件が強い案件で有効です。
// WPFの例(.NET 6+)
using Microsoft.Web.WebView2.Core;
using System.Text.Json;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
InitAsync();
}
private async void InitAsync()
{
await webView2.EnsureCoreWebView2Async();
webView2.CoreWebView2.WebMessageReceived += (s, e) =>
{
var json = e.TryGetWebMessageAsString();
File.WriteAllText("export.json", json);
};
// DOMロード時に実行する抽出スクリプトを登録
await webView2.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(@"
(function(){
const toObjects = (table) => {
const hs = [...table.querySelectorAll('thead th')].map(th => th.textContent.trim());
return [...table.querySelectorAll('tbody tr')].map(tr => {
const cs = [...tr.querySelectorAll('td')].map(td => td.textContent.trim());
if (hs.length === cs.length) return Object.fromEntries(hs.map((h,i)=>[h,cs[i]]));
return cs;
});
};
new MutationObserver(() => {
const t = document.querySelector('table#payslip');
if (!t) return;
const data = toObjects(t);
window.chrome.webview.postMessage(JSON.stringify({ at: new Date().toISOString(), data }));
}).observe(document, {childList:true, subtree:true});
})();
");
webView2.Source = new Uri("https://portal.example.go.jp/payslip");
}
}
Edge拡張機能(Manifest V3)で分離実装
Content Scriptは「閲覧中ページのDOM取得」に専念し、保存・スケジューリング・外部送信はService Worker(背景)に任せると堅牢です。権限は最小限に。
manifest.json
{
"manifest_version": 3,
"name": "Portal Extractor",
"version": "1.0.0",
"permissions": ["storage","scripting","activeTab","downloads","alarms"],
"host_permissions": ["https://portal.example.go.jp/*"],
"background": { "service_worker": "background.js" },
"action": { "default_title": "Extract data" },
"content_scripts": [{
"matches": ["https://portal.example.go.jp/*"],
"js": ["content.js"],
"run_at": "document_idle"
}]
}
content.js(抽出のみ担当)
function tableToObjects(table) {
const headers = [...table.querySelectorAll('thead th')].map(th => th.textContent.trim());
return [...table.querySelectorAll('tbody tr')].map(tr => {
const cells = [...tr.querySelectorAll('td')].map(td => td.textContent.trim());
if (headers.length === cells.length) return Object.fromEntries(headers.map((h,i)=>[h,cells[i]]));
return cells;
});
}
function waitForTable(id) {
return new Promise(resolve => {
const t = document.querySelector(`table#${id}`);
if (t) return resolve(t);
const mo = new MutationObserver(() => {
const el = document.querySelector(`table#${id}`);
if (el) { mo.disconnect(); resolve(el); }
});
mo.observe(document, { childList: true, subtree: true });
});
}
(async () => {
const table = await waitForTable('payslip');
const data = tableToObjects(table);
chrome.runtime.sendMessage({ type: 'EXTRACTED', payload: { at: Date.now(), data } });
})();
background.js(保存・スケジュール担当)
chrome.runtime.onInstalled.addListener(() => {
chrome.alarms.create('daily', { periodInMinutes: 24 * 60 });
});
chrome.alarms.onAlarm.addListener(alarm => {
if (alarm.name === 'daily') {
// 必要なら特定タブを開いて自動抽出(ログインが必要ならユーザー操作で)
chrome.tabs.create({ url: '[https://portal.example.go.jp/payslip](https://portal.example.go.jp/payslip)' });
}
});
chrome.runtime.onMessage.addListener((msg, sender) => {
if (msg?.type === 'EXTRACTED') {
const rows = msg.payload.data;
const headers = Array.isArray(rows[0]) ? rows[0].map((_,i)=>`Col${i+1}`) : Object.keys(rows[0] || {});
const csv = [headers, ...rows.map(r => Array.isArray(r) ? r : headers.map(h => r[h] ?? ''))]
.map(cols => cols.map(v => `"${String(v).replace(/"/g,'""')}"`).join(','))
.join('\r\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
chrome.downloads.download({ url, filename: `payslip_${new Date().toISOString().slice(0,10)}.csv`, saveAs: false });
}
});
セレクタ設計と堅牢化のコツ
- 安定識別子を優先:
idやdata-属性(例:[data-testid="row"])。 - 構造・位置依存を避ける:
tbody > trのみに依存し、列順変更に備えてヘッダ名でマッピング。 - 代替経路:テキストに基づく探索が必要ならXPathを併用(
document.evaluate)。 - 待機戦略:
networkidle+MutationObserverで過不足ないタイミングで抽出。
パフォーマンス最適化(実践値)
| 施策 | 効果 | 実装例 | 注意点 |
|---|---|---|---|
| 差分取得(ETag/304) | 通信量を大幅削減 | 上述のfetchJsonWithCache | サーバがETag対応か確認 |
| Web Workers | CSV整形など重処理を別スレッド | new Worker('csv.worker.js') | ドメイン制約・バンドル構成 |
| バルク処理の並列数制御 | 回線・サーバ負荷の最適化 | Promiseプールで同時数3〜6 | 429/503で退避(バックオフ) |
| requestIdleCallback | UIブロック回避 | スクロールと同時抽出回避 | フォールバック実装 |
セキュリティ・コンプライアンス
- 権限確認:サイト利用規約、
robots.txt、組織の情報セキュリティ規程を確認。個人情報は目的外利用禁止。 - 資格情報の管理:パスワードのハードコード厳禁。環境変数、OSの資格情報マネージャ、企業向け秘密管理を使用。
- 最小権限:拡張機能の
host_permissionsは対象ドメインのみに限定。 - データ保管:ローカル暗号化、アクセス制御、削除ポリシーを定義。JSON/CSVにマスキングを検討。
- CORSとCSRF:ブラウザ内からのクロスオリジン直取りはCORSに従う。必要であれば拡張機能やサーバ側プロキシを利用。
- 多要素認証:2FAを迂回しない。自動化はユーザー操作を前提にトリガーするか、正式なトークン取得フローを利用。
- XSS/HTML注入:抽出時に
innerHTMLを不用意に評価しない。textContentベースで処理。 - 監査ログ:誰がいつどの範囲を取得したか、日付と件数・ハッシュを保存。
実用コード:Ajaxを可視化して経路を特定
// 一時的に貼って使うデバッグスニペット(DevTools Console)
(function(){
const origFetch = window.fetch;
window.fetch = async (...args) => {
const res = await origFetch(...args);
try {
const url = String(args[0]);
if (url.includes('payslip') || url.includes('api')) {
console.log('[FETCH]', url, res.status, res.headers.get('content-type'));
}
} catch {}
return res;
};
const open = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url) {
this.addEventListener('load', function(){
try {
if (String(url).includes('payslip') || String(url).includes('api')) {
console.log('[XHR]', method, url, this.status, this.getResponseHeader('content-type'));
}
} catch {}
});
return open.apply(this, arguments);
};
console.log('Ajax監視を開始しました');
})();
実用コード:文字コード不明なCSVを安全に読む
async function fetchCsvRows(url) {
const res = await fetch(url, { credentials: 'include' });
if (!res.ok) throw new Error(res.status);
const buf = await res.arrayBuffer();
const text = decodeWithFallback(buf, res.headers.get('content-type')); // 先述関数
return text.split(/\r?\n/).filter(Boolean).map(line => {
// シンプルCSVパーサ(ダブルクォート対応)
const cols = [];
let cur = '', inQ = false;
for (let i=0;i<line.length;i++) {
const ch = line[i];
if (inQ) {
if (ch === '"' && line[i+1] === '"') { cur += '"'; i++; }
else if (ch === '"') { inQ = false; }
else { cur += ch; }
} else {
if (ch === '"') inQ = true;
else if (ch === ',') { cols.push(cur); cur = ''; }
else { cur += ch; }
}
}
cols.push(cur);
return cols;
});
}
運用:スケジューリング・ログ・監査
- スケジュール:ヘッドレスはWindowsタスクスケジューラで日次/週次。拡張機能は
alarmsでリマインド+ユーザー操作。 - ログ:件数、対象期間、ハッシュ(SHA-256)をJSONで保存。失敗時はエラー種別とスタックを記録。
- 再現性:入力条件(年月・部門等)をYAML/JSONで保存し、再取得の一貫性を担保。
- データ品質:列名の正規化、数値・日付の型付け、欠損の扱いをルール化。
トラブルシューティングFAQ
| 症状 | 原因の目安 | 対処 |
|---|---|---|
画面に出るのにfetchでは空 | SPAレンダリング後、APIは別URL | NetworkでXHR/FetchのURLを特定し直叩き。無ければPlaywright等でDOM抽出。 |
| 文字化け(濁点分離や「�」が多い) | Shift_JIS/EUC-JPをUTF-8で読んでいる | TextDecoderで明示デコード。BOM・meta・ヘッダから推定。 |
| 403/401で取得不可 | 認証・トークン不足、Referer/Origin制約 | 正規フローに従い画面を先に開く、セッションを継承、ヘッダの必要最小を確認。 |
| 429(レート制限) | 短時間の連打 | 指数バックオフ、並列数を3〜6に制限、差分取得を徹底。 |
| 拡張機能が保存できない | 権限不足またはMV3のSW停止 | downloads権限を追加、イベント継続条件(メッセージ・アラーム)を整備。 |
| CORSエラー | クロスオリジン制限 | 同一タブで実行(Content Script)、または許可されたプロキシ/バックエンド経由。 |
チェックリスト(公開前に確認)
- 抽出先は自分または組織がアクセス権を持つか。
- まずNetworkでJSON/CSVの直取り有無を調査したか。
- DOM抽出は
MutationObserverで安定化しているか。 - TextDecoderで文字コードを明示し、テストケース(UTF-8/SJIS/EUC-JP)を用意したか。
- 拡張/WebView2/ヘッドレスのいずれか、要件に合う実装形態を選んだか。
- 資格情報の安全管理、最小権限、監査ログ、削除ポリシーを定義したか。
- HTTP 304/ETag・並列数制御・バックオフで負荷配慮ができているか。
ミニマム実装セット(コピペで出発)
次の3点を揃えれば、ほとんどの公共ポータルで「まず動く」状態になります。
- Ajaxログスニペット:通信経路を把握(上記「Ajaxを可視化」)。
- TextDecoder関数:文字化け対策(上記
decodeWithFallback)。 - テーブル抽出&CSV化:DOMから確実に抜き保存(上記サンプル)。
まとめ
Microsoft Edgeで公共ポータルからデータを抽出する要諦は、①まず構造化データに直アクセス、②無ければDOMを丁寧にパース、③動的要素はMutationObserverで待つ、④文字コードを明示的に扱う、⑤必要に応じて拡張機能/WebView2/ヘッドレスを選択、そして⑥セキュリティ・監査を最優先の6点です。ここまでのサンプルを組み合わせれば、Edge環境でもパフォーマンスと安全性を保ちながら、動的サイトのデータ抽出を再現性高く運用できます。

コメント