We are currently working on new rules for what content should and shouldn't be allowed on this website, and are looking for feedback! See Esolang:2026 topicality proposal to view and give feedback on the current draft.

User:Tpaefawzen/common.js

From Esolang
Jump to navigation Jump to search

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Press Ctrl-F5.
 /**
 * ============================================================================
 *  標準空間ダンプ生成ツール  (ja-ucp ミラー / サーバー移転用)
 *  ja-uncyclopedia (ansaikuropedia.org) MediaWiki 1.39.3 向け 利用者JS
 * ----------------------------------------------------------------------------
 *  目的:
 *    SQL ダンプ (大帝への依頼) が手に入らなくても、ブラウザだけで
 *    「標準空間 (ns0) が取り出せない」状態を回避する。
 *    MediaWiki コア API の export を叩き、importDump.php / Special:Import に
 *    "そのまま" 食わせられる標準エクスポート XML (schema export-0.11) を生成する。
 *
 *  生成物:
 *    <mediawiki ...><siteinfo>...</siteinfo><page>...</page>...</mediawiki>
 *    これは dumpBackup.php / Special:Export と同一フォーマット。
 *
 *  ミラー側での取り込み (CLI 推奨。大量データ向き):
 *    php maintenance/importDump.php --no-updates < uncyc_ja-ns0-*.xml
 *    php maintenance/rebuildall.php                 # またはまとめて下記
 *    php maintenance/refreshLinks.php
 *    php maintenance/initSiteStats.php --update
 *    php maintenance/rebuildrecentchanges.php
 *    # 少量なら Web の Special:Import でも可 (上限 26,214,400 bytes / 1ファイル)
 *
 *  既定設定:
 *    名前空間   = [0] 標準空間のみ   (パネルで変更可。全名前空間も選択可)
 *    版         = 現行版のみ (推奨/確実)   ※全履歴は実験的オプション
 *    リダイレクト = 含む
 *    分割       = 5000 ページごとにパートファイルを書き出し (0で単一ファイル)
 *
 *  重要な注意:
 *    - これは「記事 (wikitext) のダンプ」。アップロード済みファイルの実体
 *      (画像バイナリ) は XML に含まれない。File: 名前空間(ns6)の説明文は
 *      ダンプできるが、画像本体は別途同期が必要
 *      (images.uncyc.org からの取得 / 画像ダンプ)。
 *    - 既定の "現行版のみ" は履歴を持たない。これは現状 (履歴を切るほかない)
 *      と同じだが、まず ns0 を確実に取り出せる点が要件。全履歴が必要な場合は
 *      パネルの「全履歴(実験的)」を使うか、最終的には大帝の SQL/dumpBackup を待つ。
 *    - 【Cloudflare 経路】直叩き/サーバ側からの api.php は Cloudflare のボット判定で
 *      403 になり得る (ミラー抽出が詰まる主因)。一方、Cloudflare チャレンジを通過済みの
 *      "実ブラウザ" からの同一オリジン api.php は通る。本ツールはその実ブラウザを抽出器に
 *      する設計なので、サーバ側が詰まっていても標準空間を吸い出せる。
 *      それでも 403/チャレンジが出る場合は、グローバル変数で経路を差し替え可能:
 *        window.UCP_DUMP_API   = 'https://<worker>/api.php';   // 読み込み前に設定
 *        window.UCP_DUMP_INDEX = 'https://<worker>/index.php'; // 全履歴(Special:Export)用
 *      まず「🧪 テスト」ボタンで 2 件だけ取得し、応答形式 (query.export / continue /
 *      <siteinfo> / <page>) を実ブラウザで確認してから本番を回すこと。
 *
 *  導入方法 (いずれか):
 *    1) 利用者:<あなた>/common.js に貼り付け  → 全ページに「📦 ダンプ生成」リンクが出る
 *    2) サブページ 利用者:<あなた>/dump.js に保存し、common.js から:
 *         mw.loader.load('/index.php?title=利用者:<あなた>/dump.js&action=raw&ctype=text/javascript');
 *    3) Gadget 拡張が有効なので MediaWiki:Gadgets-definition + Gadget: に登録も可
 * ============================================================================
 */
(function () {
	'use strict';

	if (window.UCP_DUMP_LOADED) { return; }
	window.UCP_DUMP_LOADED = true;

	// ---- 環境 (mw.config から取得 / ハードコードしない) ----------------------
	var API, INDEX, DBNAME;

	// ---- 既定設定 -----------------------------------------------------------
	var CFG = {
		namespaces: [0],          // 標準空間
		includeRedirects: true,
		history: 'current',       // 'current' | 'full'
		batch: 500,               // current: 1リクエストでエクスポートするページ数
		fullBatch: 30,            // full: Special:Export 1回に渡すタイトル数
		splitEvery: 5000,         // N ページごとにパート出力 (0=分割しない)
		delayMs: 120,             // リクエスト間ウェイト (サーバ負荷軽減)
		maxRetry: 4
	};

	var state = { running: false, abort: false, hasHighLimits: false };

	// ====================================================================== //
	//  低レベルユーティリティ                                                //
	// ====================================================================== //
	function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }

	function pad(n, w) { var s = String(n); while (s.length < w) { s = '0' + s; } return s; }

	function stamp() {
		var d = new Date();
		return d.getFullYear() + pad(d.getMonth() + 1, 2) + pad(d.getDate(), 2) +
			'-' + pad(d.getHours(), 2) + pad(d.getMinutes(), 2);
	}

	function fmt(n) { return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ','); }

	function postForm(url, params) {
		var body = new URLSearchParams(params).toString();
		return fetch(url, {
			method: 'POST',
			credentials: 'same-origin',
			headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
			body: body
		});
	}

	// API (JSON, formatversion=2) を叩く。5xx/通信失敗はバックオフ再試行。
	// 注: 直叩き api.php は Cloudflare のボット判定で 403 になり得る。実ブラウザ
	//     (Cloudflare チャレンジ通過済み) なら通る。通らない場合は 403/チャレンジを
	//     明示し、window.UCP_DUMP_API でプロキシ(Worker)へ振り向けられる。
	function apiJson(params, attempt) {
		attempt = attempt || 0;
		var p = Object.assign({ format: 'json', formatversion: '2', maxlag: '5' }, params);
		return postForm(API, p).then(function (res) {
			if (res.status === 503 || res.status >= 500) {
				if (attempt < CFG.maxRetry) {
					return sleep(1000 * (attempt + 1)).then(function () { return apiJson(params, attempt + 1); });
				}
				throw new Error('HTTP ' + res.status + ' (サーバ/Cloudflare 応答異常)');
			}
			if (res.status === 403) {
				throw new Error('HTTP 403 — Cloudflare が api.php を拒否しています。'
					+ 'ページを再読込して Cloudflare を通過させるか、window.UCP_DUMP_API に'
					+ 'プロキシ (Worker) を設定してください。');
			}
			return res.text().then(function (body) {
				try {
					return JSON.parse(body);
				} catch (e) {
					if (/cloudflare|cf-browser-verification|challenge-form|just a moment|enable javascript/i.test(body)) {
						throw new Error('Cloudflare のチャレンジページが返りました (JSON 不在)。'
							+ 'ページを再読込して通過後に再試行してください。');
					}
					throw new Error('JSON 解析失敗: ' + body.slice(0, 120));
				}
			});
		}).then(function (data) {
			if (data && data.error) {
				if (data.error.code === 'maxlag' && attempt < CFG.maxRetry) {
					return sleep(2000 * (attempt + 1)).then(function () { return apiJson(params, attempt + 1); });
				}
				throw new Error('API エラー: ' + data.error.code + ' / ' + (data.error.info || ''));
			}
			return data;
		}).catch(function (e) {
			// 再試行はネットワーク/5xx 系のみ。403・チャレンジ・API エラーは即時返す。
			var msg = (e && e.message) || '';
			if (attempt < CFG.maxRetry && /Failed to fetch|NetworkError|HTTP 5|timeout|load failed/i.test(msg)) {
				return sleep(1000 * (attempt + 1)).then(function () { return apiJson(params, attempt + 1); });
			}
			throw e;
		});
	}

	// export 結果文字列を取り出す (formatversion 1/2 両対応)
	function extractExport(data) {
		var exp = data && data.query && data.query.export;
		if (exp == null) { return ''; }
		if (typeof exp === 'string') { return exp; }
		if (typeof exp === 'object') { return exp['*'] || exp.content || ''; }
		return '';
	}

	// 1バッチ分の XML から、ヘッダ (最初の <page> まで) と <page>...</page> 群を分離
	function splitXml(xml) {
		var s = xml.search(/<page[\s>]/);
		if (s === -1) { return { header: null, pages: '' }; }
		var e = xml.lastIndexOf('</page>');
		var pages = (e === -1) ? '' : xml.slice(s, e + '</page>'.length);
		return { header: xml.slice(0, s), pages: pages };
	}

	function countPages(pagesStr) {
		var m = pagesStr.match(/<page[\s>]/g);
		return m ? m.length : 0;
	}

	// ====================================================================== //
	//  ダンプ収集 (パート書き出しを内包)                                     //
	// ====================================================================== //
	function Collector(ui) {
		this.ui = ui;
		this.header = null;
		this.buf = [];
		this.bufCount = 0;     // 現バッファ内のページ数
		this.partIndex = 0;
		this.total = 0;        // 全体の処理済みページ数
		this.parts = [];       // {name, url} 再ダウンロード用
	}

	Collector.prototype.ingest = function (xml) {
		var sp = splitXml(xml);
		if (sp.header && !this.header) { this.header = sp.header; }
		if (sp.pages) {
			this.buf.push(sp.pages + '\n');
			var c = countPages(sp.pages);
			this.bufCount += c;
			this.total += c;
		}
		if (CFG.splitEvery > 0 && this.bufCount >= CFG.splitEvery) { this.flush(); }
	};

	Collector.prototype.flush = function () {
		if (this.bufCount === 0) { return; }
		this.partIndex++;
		var doc = (this.header || '<mediawiki xmlns="http://www.mediawiki.org/xml/export-0.11/" version="0.11" xml:lang="ja">\n')
			+ this.buf.join('') + '</mediawiki>\n';

		var nsLabel = (CFG.namespaces.length === 1) ? ('ns' + CFG.namespaces[0]) :
			(CFG.namespaces.length >= 20 ? 'all' : 'multi');
		var hist = (CFG.history === 'full') ? 'full' : 'cur';
		var name = DBNAME + '-' + nsLabel + '-' + hist + '-' + stamp()
			+ (CFG.splitEvery > 0 ? '-part' + pad(this.partIndex, 3) : '') + '.xml';

		var blob = new Blob([doc], { type: 'application/xml;charset=utf-8' });
		var url = URL.createObjectURL(blob);
		triggerDownload(url, name);
		this.parts.push({ name: name, url: url, pages: this.bufCount });
		this.ui.addPart(name, url, this.bufCount);
		this.ui.log('✓ 書き出し: ' + name + ' (' + fmt(this.bufCount) + ' ページ)');

		this.buf = [];
		this.bufCount = 0;
	};

	function triggerDownload(url, name) {
		var a = document.createElement('a');
		a.href = url;
		a.download = name;
		document.body.appendChild(a);
		a.click();
		document.body.removeChild(a);
		// 複数ファイルの連続DLはブラウザにブロックされる場合がある。
		// その場合はパネルの一覧から手動で保存できる (URL は保持)。
	}

	// ====================================================================== //
	//  収集ルーチン                                                          //
	// ====================================================================== //

	// 現行版: generator=allpages + export を 1リクエストに統合
	function runCurrent(col, ui) {
		var namespaces = CFG.namespaces.slice();

		function doNs(i) {
			if (i >= namespaces.length) { return Promise.resolve(); }
			var ns = namespaces[i];
			ui.phase('エクスポート中 (ns' + ns + ')');
			var cont = null;

			function step() {
				if (state.abort) { return Promise.reject(new Error('__ABORT__')); }
				var params = {
					action: 'query',
					generator: 'allpages',
					gapnamespace: String(ns),
					gaplimit: String(CFG.batch),
					gapfilterredir: CFG.includeRedirects ? 'all' : 'nonredirects',
					export: '1'
				};
				if (cont) { Object.assign(params, cont); }

				return apiJson(params).then(function (data) {
					col.ingest(extractExport(data));
					ui.progress(col.total);
					cont = data.continue || null;
					if (cont) {
						return sleep(CFG.delayMs).then(step);
					}
				});
			}

			return step().then(function () { return doNs(i + 1); });
		}

		return doNs(0);
	}

	// 全履歴 (実験的): allpages で列挙 → Special:Export(curonly なし) にサーバ側で処理させる
	function runFull(col, ui) {
		var namespaces = CFG.namespaces.slice();

		function exportFull(titles) {
			if (!titles.length) { return Promise.resolve(); }
			// curonly / wpDownload は「キー自体を送らない」。空値で送ると getCheck 実装の
			// 版で curonly がチェック扱い (=現行版のみ) になり全履歴が取れないため。
			return postForm(INDEX, {
				title: 'Special:Export',
				pages: titles.join('\n')
			}).then(function (res) { return res.text(); }).then(function (text) {
				if (/^\s*(<!DOCTYPE|<html)/i.test(text)) {
					ui.log('⚠ Special:Export が XML を返しませんでした (履歴上限/権限/captcha 等の可能性)。'
						+ '対象 ' + titles.length + ' 件をスキップ。');
					return;
				}
				col.ingest(text);
				ui.progress(col.total);
				return sleep(CFG.delayMs);
			});
		}

		function doNs(i) {
			if (i >= namespaces.length) { return Promise.resolve(); }
			var ns = namespaces[i];
			ui.phase('全履歴エクスポート中 (ns' + ns + ')');
			var cont = null;
			var titleBuf = [];

			function drain() {
				if (titleBuf.length < CFG.fullBatch) { return Promise.resolve(); }
				var chunk = titleBuf.splice(0, CFG.fullBatch);
				return exportFull(chunk).then(drain);
			}

			// continue を保持しながら 列挙 → drain を回すループ
			function loop() {
				if (state.abort) { return Promise.reject(new Error('__ABORT__')); }
				var params = {
					action: 'query',
					list: 'allpages',
					apnamespace: String(ns),
					aplimit: String(CFG.batch),
					apfilterredir: CFG.includeRedirects ? 'all' : 'nonredirects'
				};
				if (cont) { Object.assign(params, cont); }
				return apiJson(params).then(function (data) {
					var list = (data.query && data.query.allpages) || [];
					list.forEach(function (p) { titleBuf.push(p.title); });
					cont = data.continue || null;
					return drain();
				}).then(function () {
					if (cont) { return sleep(CFG.delayMs).then(loop); }
					// 残りを書き出し
					var rest = function () {
						if (!titleBuf.length) { return Promise.resolve(); }
						return exportFull(titleBuf.splice(0, CFG.fullBatch)).then(rest);
					};
					return rest();
				});
			}

			return loop().then(function () { return doNs(i + 1); });
		}

		return doNs(0);
	}

	// ====================================================================== //
	//  起動 / 停止                                                           //
	// ====================================================================== //
	function start(ui) {
		if (state.running) { return; }
		if (CFG.namespaces.length === 0) { ui.log('⚠ 名前空間が選択されていません。'); return; }
		if (CFG.history === 'full' &&
			!window.confirm('全履歴モードは非常に重く、サーバの履歴上限 ($wgExportMaxHistory) に依存します。\n'
				+ '通常は「現行版のみ」を推奨します。全履歴で続行しますか?')) {
			return;
		}

		state.running = true;
		state.abort = false;
		ui.setRunning(true);
		ui.log('=== 開始: ' + (CFG.history === 'full' ? '全履歴' : '現行版のみ')
			+ ' / ns=[' + CFG.namespaces.join(',') + '] / リダイレクト'
			+ (CFG.includeRedirects ? '含む' : '除外') + ' ===');

		var col = new Collector(ui);
		var runner = (CFG.history === 'full') ? runFull : runCurrent;

		runner(col, ui).then(function () {
			col.flush();
			ui.phase('完了');
			ui.log('=== 完了: 合計 ' + fmt(col.total) + ' ページ / ' + col.parts.length + ' ファイル ===');
			ui.log('ミラー側で:  php maintenance/importDump.php --no-updates < ' + DBNAME + '-*.xml');
		}).catch(function (e) {
			if (e && e.message === '__ABORT__') {
				col.flush();
				ui.log('■ 中断しました (途中まで ' + fmt(col.total) + ' ページを書き出し)。');
			} else {
				ui.log('✗ エラー: ' + (e && e.message ? e.message : e));
				console.error('[UCP-Dump]', e);
			}
		}).then(function () {
			state.running = false;
			ui.setRunning(false);
		});
	}

	function stop(ui) {
		if (!state.running) { return; }
		state.abort = true;
		ui.log('… 停止要求を受理 (進行中のリクエスト完了後に止まります)');
	}

	// 2件だけ取得して generator+export の応答形式を実ブラウザで検証 (ファイルは作らない)
	function selfTest(ui) {
		if (state.running) { return; }
		var ns = CFG.namespaces.length ? CFG.namespaces[0] : 0;
		ui.log('🧪 テスト: ns' + ns + ' を2件取得して応答を検査します…');
		apiJson({
			action: 'query', generator: 'allpages', gapnamespace: String(ns),
			gaplimit: '2', gapfilterredir: 'all', export: '1'
		}).then(function (data) {
			var exp = data.query && data.query.export;
			var expType = (exp == null) ? 'なし(!)' : (typeof exp === 'string' ? 'string' : 'object {"*":…}');
			var xml = extractExport(data);
			ui.log('  query.export 型: ' + expType);
			ui.log('  continue: ' + (data.continue ? JSON.stringify(data.continue) : 'なし'));
			ui.log('  <siteinfo>: ' + (xml.indexOf('<siteinfo') >= 0 ? 'あり' : 'なし')
				+ ' / <page>: ' + countPages(xml) + ' 件');
			ui.log('  XML先頭: ' + xml.slice(0, 200).replace(/\n/g, '⏎'));
			if (countPages(xml) > 0 && xml.indexOf('<siteinfo') >= 0) {
				ui.log('✅ メカニズム正常。「▶ ダンプ生成」で本番実行できます。');
			} else {
				ui.log('⚠ 想定と異なる応答。API基底/権限/Cloudflare を確認してください。');
			}
		}).catch(function (e) {
			ui.log('✗ テスト失敗: ' + ((e && e.message) ? e.message : e));
		});
	}

	// ====================================================================== //
	//  UI                                                                    //
	// ====================================================================== //
	function buildUI(namespaceMap) {
		injectStyle();

		var panel = document.createElement('div');
		panel.id = 'ucp-dump-panel';
		panel.style.display = 'none';
		panel.innerHTML =
			'<div class="ucpd-head">📦 標準空間ダンプ生成 <span class="ucpd-x" title="閉じる">×</span></div>' +
			'<div class="ucpd-body">' +
			'  <div class="ucpd-row"><b>名前空間</b> ' +
			'    <button type="button" class="ucpd-mini" data-preset="ns0">標準のみ</button> ' +
			'    <button type="button" class="ucpd-mini" data-preset="content">主要</button> ' +
			'    <button type="button" class="ucpd-mini" data-preset="all">全て</button> ' +
			'    <button type="button" class="ucpd-mini" data-preset="none">解除</button>' +
			'  </div>' +
			'  <div class="ucpd-row ucpd-ns" id="ucpd-ns"></div>' +
			'  <div class="ucpd-row">' +
			'    <label><input type="checkbox" id="ucpd-redir" checked> リダイレクトを含む</label>' +
			'  </div>' +
			'  <div class="ucpd-row"><b>版</b> ' +
			'    <label><input type="radio" name="ucpd-hist" value="current" checked> 現行版のみ(推奨)</label> ' +
			'    <label><input type="radio" name="ucpd-hist" value="full"> 全履歴(実験的)</label>' +
			'  </div>' +
			'  <details class="ucpd-adv"><summary>詳細設定</summary>' +
			'    <div class="ucpd-row">バッチ <input type="number" id="ucpd-batch" value="500" min="10" max="5000" style="width:5em"> ' +
			'      分割(ページ毎) <input type="number" id="ucpd-split" value="5000" min="0" style="width:6em"> ' +
			'      ウェイトms <input type="number" id="ucpd-delay" value="120" min="0" style="width:5em"></div>' +
			'    <div class="ucpd-note" id="ucpd-limits"></div>' +
			'  </details>' +
			'  <div class="ucpd-row ucpd-btns">' +
			'    <button type="button" id="ucpd-start" class="ucpd-go">▶ ダンプ生成</button>' +
			'    <button type="button" id="ucpd-stop" class="ucpd-stop" disabled>■ 停止</button>' +
			'    <button type="button" id="ucpd-test" class="ucpd-test" title="2件だけ取得して応答形式を検査(ファイルは作らない)">🧪 テスト</button>' +
			'  </div>' +
			'  <div class="ucpd-row ucpd-prog"><span id="ucpd-phase">待機中</span> — <b id="ucpd-count">0</b> ページ <span id="ucpd-approx"></span></div>' +
			'  <div class="ucpd-bar"><div id="ucpd-bar-in"></div></div>' +
			'  <div class="ucpd-row"><b>生成ファイル</b> <span class="ucpd-note">(複数DLがブロックされたら下から手動保存)</span></div>' +
			'  <div id="ucpd-parts" class="ucpd-parts"></div>' +
			'  <pre id="ucpd-log" class="ucpd-log"></pre>' +
			'</div>';
		document.body.appendChild(panel);

		// 名前空間チェックボックス生成 (id>=0 のみ。Special/Media は除外)
		var nsWrap = panel.querySelector('#ucpd-ns');
		var ids = Object.keys(namespaceMap).map(Number).filter(function (n) { return n >= 0; })
			.sort(function (a, b) { return a - b; });
		ids.forEach(function (id) {
			var name = namespaceMap[id] || '';
			var label = (id === 0) ? '(標準)' : name;
			var lab = document.createElement('label');
			lab.className = 'ucpd-nsi';
			lab.innerHTML = '<input type="checkbox" value="' + id + '"' + (id === 0 ? ' checked' : '') + '> '
				+ '<span>' + id + ':' + escapeHtml(label) + '</span>';
			nsWrap.appendChild(lab);
		});

		// ---- UI ハンドラ群 ----
		var ui = {
			approxTotals: { articles: 0, pages: 0 },
			el: function (id) { return panel.querySelector(id); },
			log: function (msg) {
				var pre = this.el('#ucpd-log');
				var t = new Date();
				pre.textContent += '[' + pad(t.getHours(), 2) + ':' + pad(t.getMinutes(), 2) + ':' + pad(t.getSeconds(), 2) + '] ' + msg + '\n';
				pre.scrollTop = pre.scrollHeight;
			},
			phase: function (p) { this.el('#ucpd-phase').textContent = p; },
			progress: function (n) {
				this.el('#ucpd-count').textContent = fmt(n);
				var denom = (CFG.namespaces.length === 1 && CFG.namespaces[0] === 0)
					? this.approxTotals.articles : this.approxTotals.pages;
				if (denom > 0) {
					var pct = Math.min(100, Math.round(n / denom * 100));
					this.el('#ucpd-bar-in').style.width = pct + '%';
					this.el('#ucpd-approx').textContent = '/ 約 ' + fmt(denom) + ' (' + pct + '%)';
				}
			},
			addPart: function (name, url, pages) {
				var div = document.createElement('div');
				div.className = 'ucpd-part';
				var a = document.createElement('a');
				a.href = url; a.download = name; a.textContent = '⬇ ' + name;
				div.appendChild(a);
				var span = document.createElement('span');
				span.className = 'ucpd-note';
				span.textContent = ' (' + fmt(pages) + ' p)';
				div.appendChild(span);
				this.el('#ucpd-parts').appendChild(div);
			},
			setRunning: function (run) {
				this.el('#ucpd-start').disabled = run;
				this.el('#ucpd-stop').disabled = !run;
			},
			readConfig: function () {
				CFG.namespaces = Array.prototype.slice.call(nsWrap.querySelectorAll('input:checked'))
					.map(function (c) { return Number(c.value); });
				CFG.includeRedirects = this.el('#ucpd-redir').checked;
				CFG.history = panel.querySelector('input[name="ucpd-hist"]:checked').value;
				CFG.batch = clampInt(this.el('#ucpd-batch').value, 10, state.hasHighLimits ? 5000 : 500, 500);
				CFG.splitEvery = clampInt(this.el('#ucpd-split').value, 0, 1e9, 5000);
				CFG.delayMs = clampInt(this.el('#ucpd-delay').value, 0, 60000, 120);
			}
		};

		// プリセット
		panel.querySelectorAll('[data-preset]').forEach(function (btn) {
			btn.addEventListener('click', function () {
				var preset = btn.getAttribute('data-preset');
				var content = [0, 4, 6, 8, 10, 12, 14, 102, 104, 106, 110, 112, 116, 828]; // 主要
				nsWrap.querySelectorAll('input').forEach(function (c) {
					var id = Number(c.value);
					if (preset === 'ns0') { c.checked = (id === 0); }
					else if (preset === 'all') { c.checked = true; }
					else if (preset === 'none') { c.checked = false; }
					else if (preset === 'content') { c.checked = content.indexOf(id) !== -1; }
				});
			});
		});

		panel.querySelector('.ucpd-x').addEventListener('click', function () { panel.style.display = 'none'; });
		ui.el('#ucpd-start').addEventListener('click', function () { ui.readConfig(); start(ui); });
		ui.el('#ucpd-stop').addEventListener('click', function () { stop(ui); });
		ui.el('#ucpd-test').addEventListener('click', function () { ui.readConfig(); selfTest(ui); });

		return { panel: panel, ui: ui };
	}

	function clampInt(v, min, max, def) {
		var n = parseInt(v, 10);
		if (isNaN(n)) { return def; }
		return Math.max(min, Math.min(max, n));
	}

	function escapeHtml(s) {
		return String(s).replace(/[&<>"]/g, function (c) {
			return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c];
		});
	}

	function injectStyle() {
		if (document.getElementById('ucpd-style')) { return; }
		var css =
			'#ucp-dump-panel{position:fixed;right:12px;bottom:12px;width:420px;max-width:94vw;z-index:100000;' +
			'background:#fff;border:1px solid #a2a9b1;border-radius:6px;box-shadow:0 4px 18px rgba(0,0,0,.25);' +
			'font-size:12px;line-height:1.5;color:#202122;font-family:sans-serif}' +
			'.ucpd-head{background:#36c;color:#fff;padding:6px 10px;border-radius:6px 6px 0 0;font-weight:bold;cursor:default}' +
			'.ucpd-x{float:right;cursor:pointer;font-weight:bold;padding:0 4px}' +
			'.ucpd-body{padding:8px 10px;max-height:72vh;overflow:auto}' +
			'.ucpd-row{margin:6px 0}' +
			'.ucpd-ns{display:flex;flex-wrap:wrap;gap:2px 8px;max-height:120px;overflow:auto;border:1px solid #eaecf0;padding:4px;border-radius:4px}' +
			'.ucpd-nsi{white-space:nowrap;font-size:11px}' +
			'.ucpd-mini{font-size:11px;padding:1px 6px;cursor:pointer}' +
			'.ucpd-btns{display:flex;gap:8px;margin-top:8px}' +
			'.ucpd-go{background:#36c;color:#fff;border:none;border-radius:4px;padding:6px 14px;cursor:pointer;font-weight:bold}' +
			'.ucpd-go:disabled{background:#a2a9b1;cursor:default}' +
			'.ucpd-stop{background:#fff;border:1px solid #d33;color:#d33;border-radius:4px;padding:6px 12px;cursor:pointer}' +
			'.ucpd-stop:disabled{border-color:#c8ccd1;color:#c8ccd1;cursor:default}' +
			'.ucpd-test{background:#fff;border:1px solid #36c;color:#36c;border-radius:4px;padding:6px 10px;cursor:pointer;margin-left:auto}' +
			'.ucpd-bar{height:8px;background:#eaecf0;border-radius:4px;overflow:hidden;margin:4px 0}' +
			'#ucpd-bar-in{height:100%;width:0;background:#36c;transition:width .3s}' +
			'.ucpd-parts{max-height:90px;overflow:auto}' +
			'.ucpd-part a{color:#36c;text-decoration:none}.ucpd-part a:hover{text-decoration:underline}' +
			'.ucpd-note{color:#72777d;font-size:11px}' +
			'.ucpd-log{background:#101418;color:#cfe;padding:6px;border-radius:4px;height:120px;overflow:auto;white-space:pre-wrap;word-break:break-all;font-size:11px;margin-top:6px}' +
			'.ucpd-adv summary{cursor:pointer;color:#36c}';
		var st = document.createElement('style');
		st.id = 'ucpd-style';
		st.textContent = css;
		document.head.appendChild(st);
	}

	// ====================================================================== //
	//  初期化                                                                //
	// ====================================================================== //
	function init() {
		// 既定は同一オリジンの api.php / index.php。Cloudflare が直叩きを 403 する
		// 環境では、window.UCP_DUMP_API / window.UCP_DUMP_INDEX に Worker プロキシ等を
		// 設定して経路を差し替えられる。
		API = window.UCP_DUMP_API || mw.util.wikiScript('api');
		INDEX = window.UCP_DUMP_INDEX || mw.util.wikiScript('index');
		DBNAME = mw.config.get('wgDBname') || 'wiki';

		// 名前空間一覧・統計・権限を取得してから UI を構築
		Promise.all([
			apiJson({ action: 'query', meta: 'siteinfo', siprop: 'namespaces|statistics' }),
			apiJson({ action: 'query', meta: 'userinfo', uiprop: 'rights' })
		]).then(function (results) {
			var si = results[0].query;
			var nsMap = {};
			Object.keys(si.namespaces).forEach(function (k) {
				var n = si.namespaces[k];
				nsMap[n.id] = (n.name !== undefined ? n.name : (n['*'] || ''));
			});

			var rights = (results[1].query && results[1].query.userinfo && results[1].query.userinfo.rights) || [];
			state.hasHighLimits = rights.indexOf('apihighlimits') !== -1;

			var built = buildUI(nsMap);
			built.ui.approxTotals.articles = (si.statistics && si.statistics.articles) || 0;
			built.ui.approxTotals.pages = (si.statistics && si.statistics.pages) || 0;
			built.ui.el('#ucpd-limits').textContent = '高速制限(apihighlimits): '
				+ (state.hasHighLimits ? '利用可 — バッチを最大5000まで上げられます' : 'なし — バッチ上限500')
				+ ' / 総記事 約' + fmt(built.ui.approxTotals.articles)
				+ ' / 総ページ 約' + fmt(built.ui.approxTotals.pages);

			// ツールバーにトグルリンクを設置
			var link = mw.util.addPortletLink('p-cactions', '#', '📦 ダンプ生成', 'ca-ucp-dump',
				'標準空間ダンプ生成ツールを開く');
			if (link) {
				link.addEventListener('click', function (e) {
					e.preventDefault();
					built.panel.style.display = (built.panel.style.display === 'none') ? 'block' : 'none';
				});
			}
			built.ui.log('準備完了。「📦 ダンプ生成」または下のパネルから開始してください。');
		}).catch(function (e) {
			console.error('[UCP-Dump] 初期化失敗', e);
			mw.notify('ダンプツールの初期化に失敗しました: ' + (e && e.message ? e.message : e), { type: 'error' });
		});
	}

	mw.loader.using(['mediawiki.util']).then(function () {
		if (document.readyState === 'loading') {
			document.addEventListener('DOMContentLoaded', init);
		} else {
			init();
		}
	});
})();