Thành viên:MGA73/ReduceNonFree.js
Giao diện
Chú ý: Sau khi lưu thay đổi trang, bạn phải xóa bộ nhớ đệm của trình duyệt để nhìn thấy các thay đổi. Google Chrome, Firefox, Internet Explorer và Safari: Giữ phím ⇧ Shift và nhấn nút Reload/Tải lại trên thanh công cụ của trình duyệt. Để biết chi tiết và hướng dẫn cho các trình duyệt khác, xem Trợ giúp:Xóa bộ nhớ đệm.
// ==UserScript==// @name 1. Resize & Upload Flow Script// @namespace http://example.org/// @version 1.0// @description Script to batch resize and upload image files on Wikimedia wikis// @author MGA73// @match *://*.wikipedia.org/wiki/*// @match *://commons.wikimedia.org/wiki/*// @grant none// ==/UserScript==// --- PIEEXIFJS LIBRARY LOADER (JPEG EXIF) ---const piexifScript = document.createElement('script');piexifScript.src = 'https://cdn.jsdelivr.net/npm/piexifjs';document.head.appendChild(piexifScript);$(document).ready(function() { // --- 1. CONFIGURATION --- const API_BASE = 'https://vi.wikipedia.org/w/api.php'; // API endpoint for Commons (change per wiki) const SANDBOX_PAGE = 'Thành_viên:MGA73/Sandbox'; // Sandbox page - use underscore for spaces const STORAGE_KEY = 'resizeQueue'; // LocalStorage key for file queue // File namespace prefixes - add all relevant prefixes for your wiki, use underscores for spaces const FILE_NAMESPACE_PREFIXES = ['File:', 'Tập_tin:']; // Allowed image extensions for processing (exclude svg, gif, tif, tiff) const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp']; const MAX_TOTAL_PIXELS = 200000; // Maximum allowed pixels (0.1 megapixel) // --- 2. START BUTTON (Only on Sandbox Page) --- const page = mw.config.get('wgPageName'); if (page === SANDBOX_PAGE) { // Create and style the start button const $btn = $('<a>') .text('Start resize & upload flow') .attr('href', '#') .css({ position: 'fixed', bottom: '10px', left: '10px', zIndex: 10000, color: '#007bff', textDecoration: 'underline', backgroundColor: 'white', padding: '4px 8px', border: '1px solid #007bff', borderRadius: '4px', fontFamily: 'Arial, sans-serif', fontSize: '14px', cursor: 'pointer', maxWidth: '200px', overflowWrap: 'break-word', }) .click(function(e) { e.preventDefault(); localStorage.removeItem(STORAGE_KEY); // Collect all links for files matching any configured namespace const links = [...document.querySelectorAll('a[href^="/wiki/"]')].filter(a => { const href = decodeURIComponent(a.getAttribute('href')); return FILE_NAMESPACE_PREFIXES.some(prefix => href.startsWith(`/wiki/${prefix}`)); }); // Extract filenames from href, decode and filter by allowed extensions const names = links.map(a => decodeURIComponent(a.getAttribute('href').slice(6))); // strip "/wiki/" const filtered = names.filter(name => { const ext = name.split('.').pop().toLowerCase(); return ALLOWED_EXTENSIONS.includes(ext); }); if (filtered.length === 0) { alert('No valid image files found to process.'); return; } localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered)); location.href = `/wiki/${filtered[0]}`; }); $('body').append($btn); } // --- 3. FILE PAGE HANDLING --- // Check if current page is in one of the file namespaces configured if (FILE_NAMESPACE_PREFIXES.some(prefix => page.startsWith(prefix))) { const fileListStr = localStorage.getItem(STORAGE_KEY); if (!fileListStr) return; const fileList = JSON.parse(fileListStr); if (!fileList.includes(page)) return; runMainFlow(); } // --- 4. MAIN FLOW FUNCTION --- async function runMainFlow() { let fileList = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); if (fileList.length === 0) { localStorage.removeItem(STORAGE_KEY); return; } const current = mw.config.get('wgPageName'); if (current !== fileList[0]) { // Navigate to next file in queue location.href = `/wiki/${fileList[0]}`; return; } // Get original image URL via API const fileURL = await getOriginalURL(current); if (!fileURL) { shiftAndContinue(); return; } const ext = fileURL.split('.').pop().toLowerCase(); if (!ALLOWED_EXTENSIONS.includes(ext)) { // Skip forbidden file types early shiftAndContinue(); return; } // Show confirm dialog with Yes, No, Abort options const userChoice = await askUserConfirm(`Do you want to downsize this file: "${current}"?\n\nYes: Resize and upload\nNo: Skip this file\nAbort: Stop processing`); if (userChoice === 'Abort') { // Stop processing and clear queue localStorage.removeItem(STORAGE_KEY); alert('Process aborted by user.'); return; } else if (userChoice === 'No') { shiftAndContinue(); return; } // Fetch the image as a blob const blob = await fetch(fileURL, { mode: 'cors' }).then(r => r.blob()); // Resize image if needed const outBlob = await resizeImage(blob, ext); if (outBlob) { // Upload resized file await uploadFile(current, outBlob); } else { // No resizing needed (already within limits) } shiftAndContinue(); } // --- 5. API: Get Original Image URL --- async function getOriginalURL(filename) { const api = `${API_BASE}?action=query&format=json&prop=imageinfo&iiprop=url&titles=${encodeURIComponent(filename)}&origin=*`; try { const res = await fetch(api).then(r => r.json()); const page = Object.values(res.query.pages)[0]; return page.imageinfo?.[0]?.url || null; } catch { return null; } }// --- 6. RESIZE IMAGE FUNCTION ---async function resizeImage(blob, ext) { try { const arrayBuffer = await blob.arrayBuffer(); const uint8 = new Uint8Array(arrayBuffer); let exifObj = {}; // --- JPEG EXIF extraction --- if (['jpg', 'jpeg'].includes(ext) && typeof piexif !== 'undefined') { try { // Convert Uint8Array to binary string for piexif.load const binaryStr = Array.from(uint8).map(b => String.fromCharCode(b)).join(''); exifObj = piexif.load(binaryStr); } catch (e) { console.warn('Failed to extract EXIF:', e); } } // Create an ImageBitmap for resizing const bitmap = await createImageBitmap(blob); const totalPixels = bitmap.width * bitmap.height; if (totalPixels <= MAX_TOTAL_PIXELS) { return null; // No resize needed } // Calculate new dimensions keeping aspect ratio const scale = Math.sqrt(MAX_TOTAL_PIXELS / totalPixels); let width = Math.floor(bitmap.width * scale); let height = Math.floor(bitmap.height * (width / bitmap.width)); while (width * height > MAX_TOTAL_PIXELS) { width--; height = Math.floor(bitmap.height * (width / bitmap.width)); } // Create canvas for resizing let canvas; if (typeof OffscreenCanvas !== 'undefined') { canvas = new OffscreenCanvas(width, height); } else { const tempCanvas = document.createElement('canvas'); tempCanvas.width = width; tempCanvas.height = height; canvas = tempCanvas; } const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, width, height); ctx.drawImage(bitmap, 0, 0, width, height); const mimeTypes = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', }; const mime = mimeTypes[ext]; if (!mime) return null; // Convert resized canvas to blob const resizedBlob = await canvas.convertToBlob({ type: mime }); // --- JPEG: Insert EXIF metadata back --- if (['jpg', 'jpeg'].includes(ext) && typeof piexif !== 'undefined') { const reader = new FileReader(); return new Promise(resolve => { reader.onload = function () { const base64Data = reader.result.split(',')[1]; const exifBytes = piexif.dump(exifObj); const newData = piexif.insert(exifBytes, 'data:image/jpeg;base64,' + base64Data); const finalBlob = base64ToBlob(newData, mime); resolve(finalBlob); }; reader.readAsDataURL(resizedBlob); }); } // Return resized blob if no special metadata handling needed return resizedBlob; } catch (err) { console.error('Resize error:', err); return null; }}// --- Helper: Convert base64 string to Blob ---function base64ToBlob(base64, mime) { const binary = atob(base64.split(',')[1] || base64); const len = binary.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) { bytes[i] = binary.charCodeAt(i); } return new Blob([bytes], { type: mime });} // --- 7. UPLOAD FILE FUNCTION --- async function uploadFile(filename, blob) { const formData = new FormData(); formData.append('action', 'upload'); formData.append('format', 'json'); formData.append('filename', filename.replace(/^File:|^Tập_tin:/, '')); formData.append('file', blob); formData.append('ignorewarnings', '1'); formData.append('comment', `Resized to ≤${MAX_TOTAL_PIXELS.toLocaleString()} px via script.`); formData.append('token', mw.user.tokens.get('csrfToken')); try { const response = await fetch(API_BASE, { method: 'POST', body: formData, credentials: 'same-origin', }); const json = await response.json(); return json.upload?.result === 'Success'; } catch { return false; } } // --- 8. SHIFT AND CONTINUE PROCESSING --- function shiftAndContinue() { let fileList = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); if (!fileList || fileList.length === 0) { localStorage.removeItem(STORAGE_KEY); alert('Processing complete: all files handled.'); return; } fileList.shift(); if (fileList.length) { localStorage.setItem(STORAGE_KEY, JSON.stringify(fileList)); location.href = `/wiki/${fileList[0]}`; } else { localStorage.removeItem(STORAGE_KEY); alert('Processing complete: all files handled.'); } } // --- 9. CUSTOM CONFIRM DIALOG WITH YES, NO, ABORT --- function askUserConfirm(message) { return new Promise(resolve => { // Create modal container const modal = document.createElement('div'); Object.assign(modal.style, { position: 'fixed', top: '0', left: '0', right: '0', bottom: '0', backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'center', zIndex: 100000, }); // Create dialog box const dialog = document.createElement('div'); Object.assign(dialog.style, { backgroundColor: 'white', padding: '20px', borderRadius: '8px', width: '320px', fontFamily: 'Arial, sans-serif', textAlign: 'center', boxShadow: '0 0 10px rgba(0,0,0,0.25)', }); // Message text const msg = document.createElement('div'); msg.textContent = message; msg.style.marginBottom = '20px'; dialog.appendChild(msg); // Buttons container const btnContainer = document.createElement('div'); btnContainer.style.display = 'flex'; btnContainer.style.justifyContent = 'space-between'; // Helper to create buttons function createButton(text, value, bgColor) { const btn = document.createElement('button'); btn.textContent = text; btn.style.flex = '1'; btn.style.margin = '0 5px'; btn.style.padding = '8px'; btn.style.border = 'none'; btn.style.borderRadius = '4px'; btn.style.backgroundColor = bgColor; btn.style.color = 'white'; btn.style.fontWeight = 'bold'; btn.style.cursor = 'pointer'; btn.addEventListener('click', () => { document.body.removeChild(modal); resolve(value); }); return btn; } btnContainer.appendChild(createButton('Yes', 'Yes', '#28a745')); btnContainer.appendChild(createButton('No', 'No', '#ffc107')); btnContainer.appendChild(createButton('Abort', 'Abort', '#dc3545')); dialog.appendChild(btnContainer); modal.appendChild(dialog); document.body.appendChild(modal); }); }});