跳转到内容

User:A2569875/Module:Graph/ThirdPartyAPIAddon.js

维基百科,自由的百科全书
注意:保存之后,你必须清除浏览器缓存才能看到做出的更改。Google Chrome、Firefox、Microsoft Edge及Safari:按住⇧ Shift键并单击工具栏的“刷新”按钮。参阅Help:绕过浏览器缓存以获取更多帮助。
// User:A2569875/Module:Graph/ThirdPartyAPIAddon.js
/**
 * 維基百科舊版圖表第三方外部 API 擴充套件 (NASA / NOAA 等非維基站點支援)
 *
 * 運作機制:
 * 1. 專門讀取官方小工具忽略之 .gr-async-chart-unsupported[data-chart-spec] 容器。
 * 2. 首次執行時透過 MediaWiki OOUI 彈出風險告知視窗,確認後記錄於 localStorage。
 * 3. 若使用者選擇「不同意並移除」,腳本會自動搜尋使用者個人 common.js / skin.js 移除自身引用;
 *    若無法自動移除,則以 OOUI 彈窗提示使用者手動移除。
 */
( function () {
	'use strict';

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

	var CONSENT_STORAGE_KEY = 'mw_graph_3rdparty_addon_consent_v1';
	var ADDON_KEYWORD = 'ThirdPartyAPIAddon.js';
	var CORE_FALLBACK_PAGES = [
		'MediaWiki:Gadget-Graph/Core.js',
		'User:A2569875/Module:Graph/Core.js',
		'User:a2569875/Module:Graph/Core.js'
	];

	var consentChecked = false;
	var consentGranted = false;
	var consentDialogPromise = null;

	// =========================================================================
	// 1. 讀取與寫入 localStorage 風險同意狀態
	// =========================================================================
	function hasStoredConsent() {
		try {
			return !!( window.localStorage && localStorage.getItem( CONSENT_STORAGE_KEY ) === 'accepted' );
		} catch ( e ) {
			return false;
		}
	}

	function saveStoredConsent() {
		try {
			if ( window.localStorage ) {
				localStorage.setItem( CONSENT_STORAGE_KEY, 'accepted' );
			}
		} catch ( e ) { /* empty */ }
	}

	// =========================================================================
	// 2. 自動搜尋並從使用者個人 JS 頁面移除 ThirdPartyAPIAddon.js
	// =========================================================================
	function removeAddonLinesFromCode( code ) {
		if ( typeof code !== 'string' || code.indexOf( ADDON_KEYWORD ) === -1 ) {
			return null;
		}
		var lines = code.split( '\n' );
		var out = [];
		var removedCount = 0;

		for ( var i = 0; i < lines.length; i++ ) {
			if ( lines[ i ].indexOf( ADDON_KEYWORD ) !== -1 ) {
				removedCount++;
			} else {
				out.push( lines[ i ] );
			}
		}
		return removedCount > 0 ? out.join( '\n' ) : null;
	}

	function autoRemoveSelfFromUserScripts() {
		return mw.loader.using( [ 'mediawiki.api', 'mediawiki.user', 'oojs-ui-core', 'oojs-ui-windows' ] ).then( function () {
			var userName = mw.config.get( 'wgUserName' );
			if ( !userName ) {
				return OO.ui.alert(
					'您目前尚未登入,或此腳本是由瀏覽器擴充功能載入。請自行從瀏覽器擴充功能(如 Tampermonkey / Greasemonkey)中移除 ThirdPartyAPIAddon.js。',
					{ title: '請手動移除擴充腳本', size: 'medium' }
				);
			}

			var skin = mw.config.get( 'skin' ) || 'vector-2022';
			var candidatePages = [
				'User:' + userName + '/common.js',
				'User:' + userName + '/' + skin + '.js',
				'User:' + userName + '/vector.js',
				'User:' + userName + '/vector-2022.js',
				'User:' + userName + '/monobook.js',
				'User:' + userName + '/minerva.js'
			];
			// 去重
			var uniquePages = [];
			for ( var i = 0; i < candidatePages.length; i++ ) {
				if ( uniquePages.indexOf( candidatePages[ i ] ) === -1 ) {
					uniquePages.push( candidatePages[ i ] );
				}
			}

			var api = new mw.Api();
			return api.get( {
				action: 'query',
				prop: 'revisions',
				titles: uniquePages.join( '|' ),
				rvprop: 'content',
				rvslots: 'main',
				formatversion: 2
			} ).then( function ( data ) {
				var pages = ( data && data.query && data.query.pages ) || [];
				var editPromises = [];
				var modifiedTitles = [];

				for ( var p = 0; p < pages.length; p++ ) {
					var pageObj = pages[ p ];
					if ( !pageObj || pageObj.missing ) {
						continue;
					}
					var rev = pageObj.revisions && pageObj.revisions[ 0 ];
					var content = ( rev && rev.slots && rev.slots.main && ( rev.slots.main.content || rev.slots.main[ '*' ] ) ) ||
						( rev && ( rev.content || rev[ '*' ] ) ) || '';
					var cleaned = removeAddonLinesFromCode( content );
					if ( cleaned !== null ) {
						modifiedTitles.push( pageObj.title );
						editPromises.push(
							api.postWithEditToken( {
								action: 'edit',
								title: pageObj.title,
								text: cleaned,
								summary: '自動移除 ' + ADDON_KEYWORD + '(使用者於風險告知彈窗選擇停用並移除)'
							} )
						);
					}
				}

				if ( editPromises.length === 0 ) {
					return OO.ui.alert(
						'系統在您的個人腳本頁(如 Special:MyPage/common.js)中未找到對 ' + ADDON_KEYWORD + ' 的直接引用。若您是透過全域腳本(meta:Special:MyPage/global.js)、其他子頁面或瀏覽器擴充功能載入,請手動將其移除。',
						{ title: '無法自動定位腳本,請手動移除', size: 'medium' }
					);
				}

				return window.jQuery.when.apply( window.jQuery, editPromises ).then( function () {
					return OO.ui.alert(
						'已成功從您的個人腳本頁(' + modifiedTitles.join( '、' ) + ')自動移除 ' + ADDON_KEYWORD + '。頁面即將重新整理以生效。',
						{ title: '已自動移除擴充腳本', size: 'medium' }
					).then( function () {
						window.location.reload();
					} );
				}, function () {
					return OO.ui.alert(
						'自動編輯個人腳本頁時發生錯誤,請手動前往 Special:MyPage/common.js 移除包含 ' + ADDON_KEYWORD + ' 的程式碼行。',
						{ title: '自動移除失敗,請手動移除', size: 'medium' }
					);
				} );
			} );
		} );
	}

	// =========================================================================
	// 3. OOUI 首次安裝風險告知彈窗
	// =========================================================================
	function ensureUserConsent() {
		var $ = window.jQuery;
		if ( consentGranted || hasStoredConsent() ) {
			consentGranted = true;
			consentChecked = true;
			return $.Deferred().resolve( true ).promise();
		}
		if ( consentDialogPromise ) {
			return consentDialogPromise;
		}

		var dfd = $.Deferred();
		consentDialogPromise = dfd.promise();

		mw.loader.using( [ 'oojs-ui-core', 'oojs-ui-windows' ] ).then( function () {
			var messageContainer = document.createElement( 'div' );
			messageContainer.style.lineHeight = '1.6';

			var p1 = document.createElement( 'p' );
			p1.textContent = '您已載入「舊版圖表第三方外部 API 擴充套件(ThirdPartyAPIAddon.js)」。此腳本用於還原條目中依賴 NASA、NOAA 等非維基媒體官方網站之動態資料圖表。';

			var p2 = document.createElement( 'p' );
			p2.textContent = '⚠️ 資安與隱私風險告知:啟用此功能後,您的瀏覽器將直接向第三方外部伺服器請求資料(外部網站將可得知您的 IP 位址),且外部回傳之資料不受維基媒體基金會(WMF)與中文維基百科社群審核管控。';

			var p3 = document.createElement( 'p' );
			p3.style.fontWeight = 'bold';
			p3.textContent = '若您點擊「確定啟用」,即代表您已知悉並同意自行承擔潛在之跨網站腳本(XSS)與外部連線風險(維基媒體基金會不為第三方資料背書),系統將記住您的選擇不再重複詢問;若您不同意,請點擊「取消並自動移除」,系統將自動協助從您的個人腳本頁移除此擴充套件。';

			messageContainer.appendChild( p1 );
			messageContainer.appendChild( p2 );
			messageContainer.appendChild( p3 );

			var windowManager = new OO.ui.WindowManager();
			$( document.body ).append( windowManager.$element );

			var messageDialog = new OO.ui.MessageDialog();
			windowManager.addWindows( [ messageDialog ] );

			var instance = windowManager.openWindow( messageDialog, {
				title: '第三方外部 API 圖表擴充套件 — 風險告知與免責聲明',
				message: $( messageContainer ),
				size: 'large',
				actions: [
					{
						action: 'accept',
						label: '確定啟用(我願意自行承擔風險)',
						flags: [ 'primary', 'progressive' ]
					},
					{
						action: 'reject',
						label: '取消並自動移除此腳本',
						flags: [ 'safe', 'destructive' ]
					}
				]
			} );

			instance.closed.then( function ( closeData ) {
				windowManager.destroy();
				if ( closeData && closeData.action === 'accept' ) {
					saveStoredConsent();
					consentGranted = true;
					consentChecked = true;
					dfd.resolve( true );
				} else {
					consentGranted = false;
					consentChecked = true;
					autoRemoveSelfFromUserScripts();
					dfd.resolve( false );
				}
			} );
		}, function () {
			dfd.resolve( false );
		} );

		return consentDialogPromise;
	}

	// =========================================================================
	// 4. 確保 Core.js 與對應圖表渲染器 (Chart2D / ChartPolar / Chart3D) 已就緒
	// =========================================================================
	function ensureCoreLoaded() {
		var $ = window.jQuery;
		if ( GW.ensureRendererForType && GW.extractRecords && GW.applySafeTransforms ) {
			return $.Deferred().resolve().promise();
		}
		var scriptPath = ( window.mw && mw.config && mw.config.get( 'wgScript' ) ) || '/w/index.php';
		var dfd = $.Deferred();

		var tryIdx = function ( idx ) {
			if ( GW.ensureRendererForType ) {
				dfd.resolve();
				return;
			}
			if ( idx >= CORE_FALLBACK_PAGES.length ) {
				dfd.reject( new Error( '無法載入 Graph Core.js' ) );
				return;
			}
			var rawUrl = scriptPath + '?title=' + encodeURIComponent( CORE_FALLBACK_PAGES[ idx ] ) + '&action=raw&ctype=text/javascript';
			mw.loader.getScript( rawUrl ).then( function () {
				if ( GW.ensureRendererForType ) {
					dfd.resolve();
				} else {
					tryIdx( idx + 1 );
				}
			}, function () {
				tryIdx( idx + 1 );
			} );
		};
		tryIdx( 0 );
		return dfd.promise();
	}

	// 解析第三方 API 常見的 CSV 資料 (例如 NASA GISS 氣溫、NOAA 觀測數據)
	function parseExternalCSV( text ) {
		if ( typeof text !== 'string' || !text.trim() ) {
			return [];
		}
		var lines = text.replace( /\r\n/g, '\n' ).replace( /\r/g, '\n' ).split( '\n' );
		var headers = null;
		var out = [];

		var splitLine = function ( line ) {
			var res = [];
			var cur = '';
			var inQuotes = false;
			for ( var i = 0; i < line.length; i++ ) {
				var ch = line.charAt( i );
				if ( inQuotes ) {
					if ( ch === '"' ) {
						if ( line.charAt( i + 1 ) === '"' ) {
							cur += '"';
							i++;
						} else {
							inQuotes = false;
						}
					} else {
						cur += ch;
					}
				} else if ( ch === '"' ) {
					inQuotes = true;
				} else if ( ch === ',' ) {
					res.push( cur.trim() );
					cur = '';
				} else {
					cur += ch;
				}
			}
			res.push( cur.trim() );
			return res;
		};

		for ( var l = 0; l < lines.length; l++ ) {
			var line = lines[ l ].trim();
			if ( !line || line.charAt( 0 ) === '#' ) {
				continue;
			}
			if ( !headers ) {
				headers = splitLine( line );
			} else {
				var vals = splitLine( line );
				var row = {};
				for ( var c = 0; c < headers.length; c++ ) {
					var h = headers[ c ];
					if ( h ) {
						var v = vals[ c ] !== undefined ? vals[ c ] : '';
						var num = Number( v );
						row[ h ] = ( v !== '' && !isNaN( num ) ) ? num : v;
					}
				}
				out.push( row );
			}
		}
		return out;
	}

	function validateThirdPartyUrl( rawUrl ) {
		if ( typeof rawUrl !== 'string' || !rawUrl.trim() ) {
			return null;
		}
		var u = rawUrl.trim();
		if ( u.indexOf( '//' ) === 0 ) {
			u = 'https:' + u;
		} else if ( /^http:\/\//i.test( u ) ) {
			u = 'https://' + u.slice( 7 );
		}
		var parsed;
		try {
			parsed = new URL( u );
		} catch ( e ) {
			return null;
		}
		if ( parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.pathname.indexOf( '..' ) !== -1 ) {
			return null;
		}
		return parsed.href;
	}

	function fetchThirdPartyData( url, formatSpec ) {
		var $ = window.jQuery;
		var isCsv = ( formatSpec && String( formatSpec.type || '' ).toLowerCase() === 'csv' ) || /\.csv(?:\?|$)/i.test( url );
		var dfd = $.Deferred();

		$.ajax( {
			url: url,
			dataType: isCsv ? 'text' : 'json',
			cache: true
		} ).done( function ( data ) {
			if ( typeof data === 'string' ) {
				try {
					dfd.resolve( JSON.parse( data ) );
				} catch ( e ) {
					dfd.resolve( parseExternalCSV( data ) );
				}
			} else {
				dfd.resolve( data );
			}
		} ).fail( function ( jqXHR, textStatus, err ) {
			dfd.reject( new Error( '第三方 API 載入失敗: ' + ( err || textStatus ) ) );
		} );

		return dfd.promise();
	}

	// =========================================================================
	// 5. 掃描並渲染 .gr-async-chart-unsupported 容器
	// =========================================================================
	function processUnsupportedContainer( container ) {
		if ( !container || container.getAttribute( 'data-gr-3p-loaded' ) === '1' ) {
			return;
		}
		container.setAttribute( 'data-gr-3p-loaded', '1' );

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

		var targetUrl = validateThirdPartyUrl( spec.apiUrl || spec.rawUrl );
		if ( !targetUrl ) {
			return;
		}

		ensureCoreLoaded().then( function () {
			if ( GW.applyConvertedI18n ) {
				GW.applyConvertedI18n( container, spec );
			}
			return window.jQuery.when(
				GW.ensureRendererForType( spec.type ),
				fetchThirdPartyData( targetUrl, spec.format )
			);
		} ).then( function ( renderer, rawData ) {
			if ( typeof renderer !== 'function' ) {
				return;
			}
			var rawList = Array.isArray( rawData ) ? rawData : GW.extractRecords( rawData, spec.format );
			var rows = GW.applySafeTransforms ? GW.applySafeTransforms( rawList, spec.transforms ) : rawList;
			if ( GW.applyRowI18n ) {
				rows = GW.applyRowI18n( rows, spec._i18nDict );
			}
			renderer( container, spec, rows, {
				notice: spec.title
			} );
		} ).then( null, function ( err ) {
			if ( window.console && console.warn ) {
				console.warn( '[ThirdPartyAPIAddon.js] 略過第三方圖表渲染:', err );
			}
		} );
	}

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

		// 若尚未獲得使用者同意(或首次安裝),先觸發 OOUI 風險告知彈窗
		ensureUserConsent().then( function ( allowed ) {
			if ( !allowed ) {
				return;
			}
			for ( var j = 0; j < containers.length; j++ ) {
				processUnsupportedContainer( containers[ j ] );
			}
		} );
	}

	function bootAddon() {
		// 首次安裝時,即使當前頁面沒有第三方圖表,也立即彈出一次 OOUI 風險確認視窗以完成授權記錄
		if ( !hasStoredConsent() && !consentChecked ) {
			ensureUserConsent().then( function ( allowed ) {
				if ( allowed ) {
					scanUnsupportedRoot( document );
				}
			} );
		} else {
			scanUnsupportedRoot( document );
		}

		if ( window.mw && typeof mw.hook === 'function' ) {
			mw.hook( 'wikipage.content' ).add( function ( $content ) {
				if ( consentGranted || hasStoredConsent() ) {
					if ( $content && $content.length ) {
						for ( var i = 0; i < $content.length; i++ ) {
							scanUnsupportedRoot( $content[ i ] );
						}
					}
					scanUnsupportedRoot( document );
				}
			} );
		}
	}

	if ( window.mw && mw.loader && typeof mw.loader.using === 'function' ) {
		mw.loader.using( [ 'jquery', 'mediawiki.base', 'mediawiki.util' ] ).then( bootAddon );
	} 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' ] ).then( bootAddon );
			}
		} );
	}
}() );