User:Polygnotus/Scripts/DraftCategories.js
Appearance
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.
// <nowiki>
// 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 ============
const TESTING_MODE = false; // Set to false to actually edit pages
const MAX_ARTICLES = null; // Maximum number of articles to process (set to null for unlimited)
const DELAY_BETWEEN_REQUESTS = 5000; // milliseconds between API calls (5 seconds)
const DELAY_BETWEEN_EDITS = 5000; // milliseconds between actual edits (10 seconds)
const MAX_RETRIES = 3;
const BATCH_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 ============
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function getCategoryMembers(category, cmcontinue) {
const api = new mw.Api({
ajax: {
headers: {
'Api-User-Agent': 'DraftCategoriesRemover/1.0 (User:' + mw.config.get('wgUserName') + ')'
}
}
});
const params = {
action: 'query',
list: 'categorymembers',
cmtitle: category,
cmlimit: BATCH_SIZE,
cmnamespace: 0, // Main namespace only
format: 'json'
};
if (cmcontinue) {
params.cmcontinue = cmcontinue;
}
let retries = 0;
while (retries < MAX_RETRIES) {
try {
const result = await api.get(params);
return result;
} catch (error) {
retries++;
console.error(`API error (attempt ${retries}/${MAX_RETRIES}):`, error);
if (retries >= MAX_RETRIES) {
throw error;
}
await sleep(DELAY_BETWEEN_REQUESTS * retries);
}
}
}
async function getPageContent(title) {
const api = new mw.Api({
ajax: {
headers: {
'Api-User-Agent': 'DraftCategoriesRemover/1.0 (User:' + mw.config.get('wgUserName') + ')'
}
}
});
let retries = 0;
while (retries < MAX_RETRIES) {
try {
const result = await api.get({
action: 'query',
titles: title,
prop: 'revisions',
rvprop: 'content|timestamp',
rvslots: 'main',
format: 'json'
});
const pages = result.query.pages;
const pageId = Object.keys(pages)[0];
const page = pages[pageId];
if (page.revisions && page.revisions[0]) {
return {
content: page.revisions[0].slots.main['*'],
timestamp: page.revisions[0].timestamp
};
}
return null;
} catch (error) {
retries++;
console.error(`API error fetching ${title} (attempt ${retries}/${MAX_RETRIES}):`, error);
if (retries >= MAX_RETRIES) {
throw error;
}
await sleep(DELAY_BETWEEN_REQUESTS * retries);
}
}
}
async function editPage(title, newContent, timestamp, summary) {
if (TESTING_MODE) {
console.log(`[TESTING MODE] Would edit ${title} with summary: ${summary}`);
return { success: true, testing: true };
}
const api = new mw.Api({
ajax: {
headers: {
'Api-User-Agent': 'DraftCategoriesRemover/1.0 (User:' + mw.config.get('wgUserName') + ')'
}
}
});
let retries = 0;
while (retries < MAX_RETRIES) {
try {
const result = await api.postWithToken('csrf', {
action: 'edit',
title: title,
text: newContent,
basetimestamp: timestamp,
summary: summary,
minor: true,
format: 'json'
});
return result;
} catch (error) {
retries++;
console.error(`Edit error for ${title} (attempt ${retries}/${MAX_RETRIES}):`, error);
if (retries >= MAX_RETRIES) {
throw error;
}
await sleep(DELAY_BETWEEN_REQUESTS * retries);
}
}
}
// ============ PROCESSING FUNCTIONS ============
function findCategoryInsertionPoint(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)
const stubMatch = content.match(/\{\{[^}]*stub\}\}/i);
if (stubMatch) {
return stubMatch.index;
}
// Look for {{Improve categories}} or {{Uncategorized}}
const improveCatMatch = content.match(/\{\{\s*(Improve categories|Uncategorized)\s*\}\}/i);
if (improveCatMatch) {
return improveCatMatch.index;
}
// Look for existing categories (if any) to insert near them
const existingCatMatch = content.match(/\[\[Category:[^\]]+\]\]/i);
if (existingCatMatch) {
// Find the last category
const allCats = Array.from(content.matchAll(/\[\[Category:[^\]]+\]\]/gi));
if (allCats.length > 0) {
const lastCat = allCats[allCats.length - 1];
return lastCat.index + lastCat[0].length;
}
}
// Look for defaultsort (categories come AFTER this)
const defaultsortMatch = content.match(/\{\{\s*DEFAULTSORT\s*:[^}]+\}\}/i);
if (defaultsortMatch) {
return defaultsortMatch.index + defaultsortMatch[0].length;
}
// Look for authority control templates (categories come AFTER these)
const authorityMatch = content.match(/\{\{\s*Authority control[^}]*\}\}/i);
if (authorityMatch) {
return authorityMatch.index + authorityMatch[0].length;
}
// Look for {{Taxonbar}}
const taxonbarMatch = content.match(/\{\{\s*Taxonbar[^}]*\}\}/i);
if (taxonbarMatch) {
return taxonbarMatch.index + taxonbarMatch[0].length;
}
// Look for {{Portal bar}} or {{Subject bar}}
const portalBarMatch = content.match(/\{\{\s*(Portal bar|Subject bar)[^}]*\}\}/i);
if (portalBarMatch) {
return portalBarMatch.index + portalBarMatch[0].length;
}
// Look for navboxes (categories come AFTER these)
const allNavboxes = Array.from(content.matchAll(/\{\{\s*[Nn]avbox[^}]*\}\}/g));
if (allNavboxes.length > 0) {
const lastNavbox = allNavboxes[allNavboxes.length - 1];
return lastNavbox.index + lastNavbox[0].length;
}
// If nothing found, put at the very end
return content.length;
}
function removeDraftCategoriesTemplate(content) {
const templateNames = [
'Draft categories',
'Draft cats',
'Draftcat',
'Draft Categories',
'Draft category',
'Draft cat',
'Afc categories',
'Draftcats'
];
const templatePattern = templateNames.map(name =>
name.replace(/\s/g, '\\s*')
).join('|');
// Find the template start
const startRegex = new RegExp(
'\\{\\{\\s*(?:' + templatePattern + ')\\s*\\|',
'gi'
);
let modified = false;
let newContent = content;
let match;
while ((match = startRegex.exec(content)) !== null) {
const startPos = match.index;
const contentStart = startPos + match[0].length;
// Count braces to find the matching closing braces
let braceCount = 2; // We start with {{
let pos = contentStart;
let endPos = -1;
while (pos < content.length && braceCount > 0) {
if (content.substr(pos, 2) === '{{') {
braceCount += 2;
pos += 2;
} else if (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
let categories = 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
const categoryRegex = /\[\[Category:[^\]]+\]\]/gi;
const foundCategories = categories.match(categoryRegex) || [];
// Normalize and deduplicate (case-insensitive)
const uniqueCategories = [];
const seen = new Set();
for (const cat of foundCategories) {
const normalized = cat.toLowerCase();
if (!seen.has(normalized)) {
seen.add(normalized);
uniqueCategories.push(cat);
}
}
// Reconstruct with unique categories
const deduplicatedCategories = uniqueCategories.join('\n');
if (deduplicatedCategories) {
// Find insertion point based on MOS:ORDER
const insertionPoint = findCategoryInsertionPoint(content);
// Remove the template
const beforeTemplate = content.substring(0, startPos);
const afterTemplate = content.substring(endPos + 2);
// Determine where to insert categories
if (insertionPoint <= startPos) {
// Insertion point is before the template
const beforeInsertion = content.substring(0, insertionPoint).trimEnd();
const betweenInsertionAndTemplate = content.substring(insertionPoint, startPos);
const afterTemplateContent = afterTemplate.trimStart();
newContent = beforeInsertion + '\n\n' + deduplicatedCategories + '\n' + betweenInsertionAndTemplate.trimStart() + afterTemplateContent;
} else if (insertionPoint > endPos + 2) {
// Insertion point is after the template
const adjustedInsertionPoint = insertionPoint - (endPos + 2 - startPos);
const beforeTemplateContent = beforeTemplate.trimEnd();
const betweenTemplateAndInsertion = afterTemplate.substring(0, adjustedInsertionPoint).trimEnd();
const afterInsertion = 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
};
}
async function processPage(title) {
console.log(`Processing: ${title}`);
try {
const pageData = await getPageContent(title);
if (!pageData) {
console.error(`Could not fetch content for ${title}`);
return { success: false, error: 'Could not fetch content' };
}
const result = 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 {
await editPage(
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
await sleep(DELAY_BETWEEN_EDITS);
}
return { success: true };
} catch (error) {
console.error(`Error processing ${title}:`, error);
return { success: false, error: error };
}
}
async function processDraftCategories() {
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');
const stats = {
total: 0,
processed: 0,
skipped: 0,
failed: 0
};
try {
let cmcontinue = null;
do {
console.log('\n--- Fetching batch of pages ---');
const result = await getCategoryMembers('Category:Articles using draft categories', cmcontinue);
if (!result.query || !result.query.categorymembers) {
console.log('No more pages found');
break;
}
const pages = result.query.categorymembers;
console.log(`Found ${pages.length} pages in this batch`);
for (const page of pages) {
// 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++;
const processResult = await processPage(page.title);
if (processResult.success) {
if (processResult.skipped) {
stats.skipped++;
} else {
stats.processed++;
}
} else {
stats.failed++;
}
// Use standard delay for read operations
await sleep(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);
}
}
})();
// </nowiki>