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
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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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();
}
});
})();