User:Polygnotus/Scripts/DraftCategories.js
Appearance
From Wikipedia, the free encyclopedia
Code that you insert on this page could contain malicious content capable of compromising your account. If you import a script from another page with "importScript", "mw.loader.load", "iusc", or "lusc", take note that this causes you to dynamically load a remote script, which could be changed by others. Editors are responsible for all edits and actions they perform, including by scripts. User scripts are not centrally supported and may malfunction or become inoperable due to software changes. A guide to help you find broken scripts is available. If you are unsure whether code you are adding to this page is safe, you can ask at the appropriate village pump.
This code will be executed when previewing this page.
This code will be executed when previewing this page.
Documentation for this user script can be added at User:Polygnotus/Scripts/DraftCategories.
// Script to remove {{Draft categories}} template while preserving categories //https://en.wikipedia.org/wiki/Category:Articles_using_draft_categories //According to https://en.wikipedia.org/wiki/Wikipedia:Categorization#Categorizing_draft_pages // there are 4 methods: nowiki, html comment, [[:Category and {{Draft categories}} //Currently https://en.wikipedia.org/wiki/Wikipedia:Categorization#Categorizing_draft_pages doesn't even mention Tolbot and lists 2 scripts that could be merged into one //the scripts listed dont even support all 4 methods, dont support all redirects to the template (if draft then cat, if mainspace uncat) //deduplicate then insert at MOS:ORDER //Can't find Tolbot code, not sure how well it does //How do I find [[:Category in mainspace? Ideally without parsing the dump? // Searching for insource:/\[\[:Category:/ returns 20k articles... //i don't see a restriction on linking to another namespace in the MOS, although it violates least astonishment //api returns only 10k results oof. //check if [[:Category is near the bottom //https://en.wikipedia.org/wiki/User:DannyS712/Draft_no_cat //https://en.wikipedia.org/wiki/Category:AfC_submissions_with_categories //https://en.wikipedia.org/wiki/Category:AfC_submissions_with_categories (function(){ 'use strict'; // ============ CONFIGURATION ============ constTESTING_MODE=false;// Set to false to actually edit pages constMAX_ARTICLES=null;// Maximum number of articles to process (set to null for unlimited) constDELAY_BETWEEN_REQUESTS=5000;// milliseconds between API calls (5 seconds) constDELAY_BETWEEN_EDITS=5000;// milliseconds between actual edits (10 seconds) constMAX_RETRIES=3; constBATCH_SIZE=50;// Number of pages to fetch at once // ============ MAIN EXECUTION ============ // Add a link to the toolbox to run the script if(mw.config.get('wgCanonicalSpecialPageName')===false){ mw.loader.using(['mediawiki.util'],function(){ mw.util.addPortletLink( 'p-tb', '#', 'Remove Draft Categories', 't-remove-draft-cats', 'Remove Draft categories template from articles' ); $('#t-remove-draft-cats').click(function(e){ e.preventDefault(); if(confirm('This will process all articles in Category:Articles using draft categories. Continue?')){ processDraftCategories(); } }); }); } // ============ API FUNCTIONS ============ functionsleep(ms){ returnnewPromise(resolve=>setTimeout(resolve,ms)); } asyncfunctiongetCategoryMembers(category,cmcontinue){ constapi=newmw.Api({ ajax:{ headers:{ 'Api-User-Agent':'DraftCategoriesRemover/1.0 (User:'+mw.config.get('wgUserName')+')' } } }); constparams={ action:'query', list:'categorymembers', cmtitle:category, cmlimit:BATCH_SIZE, cmnamespace:0,// Main namespace only format:'json' }; if(cmcontinue){ params.cmcontinue=cmcontinue; } letretries=0; while(retries<MAX_RETRIES){ try{ constresult=awaitapi.get(params); returnresult; }catch(error){ retries++; console.error(`API error (attempt ${retries}/${MAX_RETRIES}):`,error); if(retries>=MAX_RETRIES){ throwerror; } awaitsleep(DELAY_BETWEEN_REQUESTS*retries); } } } asyncfunctiongetPageContent(title){ constapi=newmw.Api({ ajax:{ headers:{ 'Api-User-Agent':'DraftCategoriesRemover/1.0 (User:'+mw.config.get('wgUserName')+')' } } }); letretries=0; while(retries<MAX_RETRIES){ try{ constresult=awaitapi.get({ action:'query', titles:title, prop:'revisions', rvprop:'content|timestamp', rvslots:'main', format:'json' }); constpages=result.query.pages; constpageId=Object.keys(pages)[0]; constpage=pages[pageId]; if(page.revisions&&page.revisions[0]){ return{ content:page.revisions[0].slots.main['*'], timestamp:page.revisions[0].timestamp }; } returnnull; }catch(error){ retries++; console.error(`API error fetching ${title} (attempt ${retries}/${MAX_RETRIES}):`,error); if(retries>=MAX_RETRIES){ throwerror; } awaitsleep(DELAY_BETWEEN_REQUESTS*retries); } } } asyncfunctioneditPage(title,newContent,timestamp,summary){ if(TESTING_MODE){ console.log(`[TESTING MODE] Would edit ${title} with summary: ${summary}`); return{success:true,testing:true}; } constapi=newmw.Api({ ajax:{ headers:{ 'Api-User-Agent':'DraftCategoriesRemover/1.0 (User:'+mw.config.get('wgUserName')+')' } } }); letretries=0; while(retries<MAX_RETRIES){ try{ constresult=awaitapi.postWithToken('csrf',{ action:'edit', title:title, text:newContent, basetimestamp:timestamp, summary:summary, minor:true, format:'json' }); returnresult; }catch(error){ retries++; console.error(`Edit error for ${title} (attempt ${retries}/${MAX_RETRIES}):`,error); if(retries>=MAX_RETRIES){ throwerror; } awaitsleep(DELAY_BETWEEN_REQUESTS*retries); } } } // ============ PROCESSING FUNCTIONS ============ functionfindCategoryInsertionPoint(content){ // Categories go at position 8 in End matter (MOS:ORDER) // After: defaultsort, authority control, taxonbar, portal bar, navboxes // Before: improve categories, uncategorized, stub templates // Look for stub templates (these come AFTER categories) conststubMatch=content.match(/\{\{[^}]*stub\}\}/i); if(stubMatch){ returnstubMatch.index; } // Look for {{Improve categories}} or {{Uncategorized}} constimproveCatMatch=content.match(/\{\{\s*(Improve categories|Uncategorized)\s*\}\}/i); if(improveCatMatch){ returnimproveCatMatch.index; } // Look for existing categories (if any) to insert near them constexistingCatMatch=content.match(/\[\[Category:[^\]]+\]\]/i); if(existingCatMatch){ // Find the last category constallCats=Array.from(content.matchAll(/\[\[Category:[^\]]+\]\]/gi)); if(allCats.length>0){ constlastCat=allCats[allCats.length-1]; returnlastCat.index+lastCat[0].length; } } // Look for defaultsort (categories come AFTER this) constdefaultsortMatch=content.match(/\{\{\s*DEFAULTSORT\s*:[^}]+\}\}/i); if(defaultsortMatch){ returndefaultsortMatch.index+defaultsortMatch[0].length; } // Look for authority control templates (categories come AFTER these) constauthorityMatch=content.match(/\{\{\s*Authority control[^}]*\}\}/i); if(authorityMatch){ returnauthorityMatch.index+authorityMatch[0].length; } // Look for {{Taxonbar}} consttaxonbarMatch=content.match(/\{\{\s*Taxonbar[^}]*\}\}/i); if(taxonbarMatch){ returntaxonbarMatch.index+taxonbarMatch[0].length; } // Look for {{Portal bar}} or {{Subject bar}} constportalBarMatch=content.match(/\{\{\s*(Portal bar|Subject bar)[^}]*\}\}/i); if(portalBarMatch){ returnportalBarMatch.index+portalBarMatch[0].length; } // Look for navboxes (categories come AFTER these) constallNavboxes=Array.from(content.matchAll(/\{\{\s*[Nn]avbox[^}]*\}\}/g)); if(allNavboxes.length>0){ constlastNavbox=allNavboxes[allNavboxes.length-1]; returnlastNavbox.index+lastNavbox[0].length; } // If nothing found, put at the very end returncontent.length; } functionremoveDraftCategoriesTemplate(content){ consttemplateNames=[ 'Draft categories', 'Draft cats', 'Draftcat', 'Draft Categories', 'Draft category', 'Draft cat', 'Afc categories', 'Draftcats' ]; consttemplatePattern=templateNames.map(name=> name.replace(/\s/g,'\\s*') ).join('|'); // Find the template start conststartRegex=newRegExp( '\\{\\{\\s*(?:'+templatePattern+')\\s*\\|', 'gi' ); letmodified=false; letnewContent=content; letmatch; while((match=startRegex.exec(content))!==null){ conststartPos=match.index; constcontentStart=startPos+match[0].length; // Count braces to find the matching closing braces letbraceCount=2;// We start with {{ letpos=contentStart; letendPos=-1; while(pos<content.length&&braceCount>0){ if(content.substr(pos,2)==='{{'){ braceCount+=2; pos+=2; }elseif(content.substr(pos,2)==='}}'){ braceCount-=2; pos+=2; if(braceCount===0){ endPos=pos-2;// Position of the final }} } }else{ pos++; } } if(endPos!==-1){ // Extract the content between the pipes and the closing braces letcategories=content.substring(contentStart,endPos).trim(); // Fix [[:Category: to [[Category: categories=categories.replace(/\[\[:Category:/gi,'[[Category:'); // Unwrap categories inside HTML comments: <!-- [[Category:...]] --> categories=categories.replace(/<!--\s*(\[\[Category:[^\]]+\]\])\s*-->/gi,'1ドル'); // Unwrap categories inside nowiki tags: <nowiki>[[Category:...]]</nowiki> categories=categories.replace(/<nowiki>\s*(\[\[Category:[^\]]+\]\])\s*<\/nowiki>/gi,'1ドル'); // Now deduplicate all categories constcategoryRegex=/\[\[Category:[^\]]+\]\]/gi; constfoundCategories=categories.match(categoryRegex)||[]; // Normalize and deduplicate (case-insensitive) constuniqueCategories=[]; constseen=newSet(); for(constcatoffoundCategories){ constnormalized=cat.toLowerCase(); if(!seen.has(normalized)){ seen.add(normalized); uniqueCategories.push(cat); } } // Reconstruct with unique categories constdeduplicatedCategories=uniqueCategories.join('\n'); if(deduplicatedCategories){ // Find insertion point based on MOS:ORDER constinsertionPoint=findCategoryInsertionPoint(content); // Remove the template constbeforeTemplate=content.substring(0,startPos); constafterTemplate=content.substring(endPos+2); // Determine where to insert categories if(insertionPoint<=startPos){ // Insertion point is before the template constbeforeInsertion=content.substring(0,insertionPoint).trimEnd(); constbetweenInsertionAndTemplate=content.substring(insertionPoint,startPos); constafterTemplateContent=afterTemplate.trimStart(); newContent=beforeInsertion+'\n\n'+deduplicatedCategories+'\n'+betweenInsertionAndTemplate.trimStart()+afterTemplateContent; }elseif(insertionPoint>endPos+2){ // Insertion point is after the template constadjustedInsertionPoint=insertionPoint-(endPos+2-startPos); constbeforeTemplateContent=beforeTemplate.trimEnd(); constbetweenTemplateAndInsertion=afterTemplate.substring(0,adjustedInsertionPoint).trimEnd(); constafterInsertion=afterTemplate.substring(adjustedInsertionPoint).trimStart(); newContent=beforeTemplateContent+betweenTemplateAndInsertion+'\n\n'+deduplicatedCategories+'\n'+afterInsertion; }else{ // Insertion point is within the template area, just replace newContent=beforeTemplate.trimEnd()+'\n\n'+deduplicatedCategories+'\n'+afterTemplate.trimStart(); } // Clean up excessive whitespace newContent=newContent.replace(/\n{3,}/g,'\n\n').trim()+'\n'; modified=true; }else{ // No categories found, just remove the template newContent=content.substring(0,startPos)+content.substring(endPos+2); newContent=newContent.replace(/\n{3,}/g,'\n\n').trim()+'\n'; modified=true; } // Update content for next iteration content=newContent; // Reset regex startRegex.lastIndex=0; } } return{ modified:modified, content:newContent }; } asyncfunctionprocessPage(title){ console.log(`Processing: ${title}`); try{ constpageData=awaitgetPageContent(title); if(!pageData){ console.error(`Could not fetch content for ${title}`); return{success:false,error:'Could not fetch content'}; } constresult=removeDraftCategoriesTemplate(pageData.content); if(!result.modified){ console.log(`No Draft categories template found in ${title}`); return{success:true,skipped:true}; } if(TESTING_MODE){ console.log(`[TESTING MODE] Would remove Draft categories template from ${title}`); console.log('Original excerpt:',pageData.content.substring(0,500)); console.log('Modified excerpt:',result.content.substring(0,500)); }else{ awaiteditPage( title, result.content, pageData.timestamp, 'Removing [[Template:Draft categories]] wrapper (categories preserved and deduplicated per [[MOS:ORDER]])' ); console.log(`Successfully edited ${title}`); // Extra delay after actual edits awaitsleep(DELAY_BETWEEN_EDITS); } return{success:true}; }catch(error){ console.error(`Error processing ${title}:`,error); return{success:false,error:error}; } } asyncfunctionprocessDraftCategories(){ console.log('=== Draft Categories Removal Script ==='); console.log('Testing mode:',TESTING_MODE); console.log('Max articles to process:',MAX_ARTICLES===null?'unlimited':MAX_ARTICLES); console.log('Delay between API reads:',DELAY_BETWEEN_REQUESTS+'ms'); console.log('Delay between edits:',DELAY_BETWEEN_EDITS+'ms'); conststats={ total:0, processed:0, skipped:0, failed:0 }; try{ letcmcontinue=null; do{ console.log('\n--- Fetching batch of pages ---'); constresult=awaitgetCategoryMembers('Category:Articles using draft categories',cmcontinue); if(!result.query||!result.query.categorymembers){ console.log('No more pages found'); break; } constpages=result.query.categorymembers; console.log(`Found ${pages.length} pages in this batch`); for(constpageofpages){ // Check if we've hit the limit if(MAX_ARTICLES!==null&&stats.total>=MAX_ARTICLES){ console.log(`\nReached maximum article limit (${MAX_ARTICLES}). Stopping.`); break; } stats.total++; constprocessResult=awaitprocessPage(page.title); if(processResult.success){ if(processResult.skipped){ stats.skipped++; }else{ stats.processed++; } }else{ stats.failed++; } // Use standard delay for read operations awaitsleep(DELAY_BETWEEN_REQUESTS); } // Break outer loop if we hit the limit if(MAX_ARTICLES!==null&&stats.total>=MAX_ARTICLES){ break; } cmcontinue=result.continue?result.continue.cmcontinue:null; }while(cmcontinue); console.log('\n=== Processing Complete ==='); console.log('Total pages checked:',stats.total); console.log('Successfully processed:',stats.processed); console.log('Skipped (no template found):',stats.skipped); console.log('Failed:',stats.failed); if(TESTING_MODE){ console.log('\n*** TESTING MODE WAS ENABLED - NO ACTUAL EDITS WERE MADE ***'); } }catch(error){ console.error('Fatal error:',error); } } })();