User:Daniel Quinlan/Scripts/RangeHelper.js: Difference between revisions

Content deleted Content added
reorder some functions, other minor changes
improve user talk page fetching by using Special:PrefixIndex
 
(9 intermediate revisions by the same user not shown)
Line 33:
version: this.version
});
}
 
enumerate() {
if (this.version != 4) {
throw new Error('can only enumerate IPv4 addresses');
}
const count = 1n << BigInt(32 - this.mask);
let current = this.masked(this.mask).ip;
return Array.from({ length: Number(count) }, () =>
IPAddress.#bigIntToIPv4(current++)
);
}
 
Line 46 ⟶ 57:
}
return uppercase ? ipString.toUpperCase() : ipString;
}
 
getRange() {
const size = this.version === 4 ? 32 : 128;
const effectiveMask = this.effectiveMask;
const hostBits = BigInt(size - effectiveMask);
const start = this.ip & (~0n << hostBits);
const end = start | ((1n << hostBits) - 1n);
return {
start: new IPAddress({ ip: start, mask: null, version: this.version }),
end: new IPAddress({ ip: end, mask: null, version: this.version })
};
}
 
inRange(other) {
if (!(other instanceof IPAddress)) return false;
if (this.version !== other.version) return false;
const { start, end } = this.getRange();
return other.ip >= start.ip && other.ip <= end.ip;
}
 
Line 111 ⟶ 141:
 
static #compressIPv6(ipv6) {
let run = null;
const zeroBlocks = ipv6.match(/\b0(?:\:0)+\b/g);
for (const match of ipv6.matchAll(/:?\b(0(?:\:0)+)\b:?/g)) {
if (!zeroBlocks) return ipv6;
if (!run || match[1].length > run[1].length) {
const longestZeroBlock = zeroBlocks.reduce((longest, current) => {
run = match;
return current.length > longest.length ? current : longest;
}, "");
if (longestZeroBlock) {
return ipv6.replace(new RegExp(`:?\\b${longestZeroBlock}\\b:?`), '::');
}
return run ? `${ipv6.slice(0, run.index)}::${ipv6.slice(run.index + run[0].length)}` : ipv6;
return ipv6;
}
}
Line 149 ⟶ 177:
} else if (match[1] === 'RangeCalculator') {
displayRangeCalculator();
}
} else if (mw.config.get('wgCanonicalNamespace') === 'User_talk') {
const ip = IPAddress.from(mw.config.get('wgTitle'));
if (ip && ip.mask) {
displayRangeTalk(ip);
}
} else if (pageName === 'Special:Log/block') {
Line 167 ⟶ 200:
const userToolsContainer = document.querySelector('.mw-contributions-user-tools .mw-changeslist-links');
if (!userToolsContainer) return;
const existingTalkLink = userToolsContainer.querySelector('.mw-contributions-link-talk');
const rangeTalkLink = document.createElement('a');
rangeTalkLink.className = 'mw-contributions-link-talk-range';
const wrapper = document.createElement('span');
if (existingTalkLink) {
const mask = ip.version === 4 ? 24 : 64;
const range = ip.masked(mask);
rangeTalkLink.href = `/wiki/User_talk:${range}`;
rangeTalkLink.title = `User talk:${range}`;
rangeTalkLink.textContent = `(/${mask})`;
wrapper.appendChild(document.createTextNode(' '));
wrapper.appendChild(rangeTalkLink);
existingTalkLink.parentNode.insertBefore(wrapper, existingTalkLink.nextSibling);
} else {
rangeTalkLink.href = `/wiki/User_talk:${ip}`;
rangeTalkLink.title = `User talk:${ip}`;
rangeTalkLink.textContent = 'talk';
wrapper.appendChild(rangeTalkLink);
userToolsContainer.insertBefore(wrapper, userToolsContainer.firstChild);
}
const blockLogLink = userToolsContainer.querySelector('.mw-contributions-link-block-log');
if (blockLogLink) {
Line 218 ⟶ 271:
const heading = document.querySelector('#firstHeading');
if (heading) {
heading.innerHTMLtextContent = `Range blocks for ${ip}`;
}
const contentContainer = document.querySelector('#mw-content-text');
Line 229 ⟶ 282:
contentContainer.appendChild(resultsList);
const masks = ip.version === 4 ? sequence(16, 31) : sequence(19, 64);
const ranges = masks.map(mask => ip.masked(mask).toString(false, true));
if (!masks.includes(ip.mask)) {
ranges.push(ip.toString(false, true));
}
const blocks = [];
Line 257 ⟶ 310:
}
mw.hook('wikipage.content').fire($(contentContainer));
}
 
// display talk pages for IP range
async function displayRangeTalk(ip) {
async function getUserTalkPages(ip, maxPages = 32) {
const userTalk = new Set();
const { start, end } = ip.getRange();
const prefix = commonPrefix(start.toString(true), end.toString(true));
const validPrefix = /^\w+[.:]\w+[.:]/.test(prefix);
let url = null;
let pagesFetched = 0;
let errors = false;
if (validPrefix) {
url = `/wiki/Special:PrefixIndex?prefix=${encodeURIComponent(prefix)}&namespace=3`;
}
while (url && pagesFetched < maxPages && !errors) {
try {
const html = await fetch(url).then(res => res.text());
const parser = new DOMParser();
const fetched = parser.parseFromString(html, 'text/html');
const links = fetched.querySelectorAll('ul.mw-prefixindex-list > li > a');
for (const link of links) {
const ipText = link.textContent;
const pageIp = IPAddress.from(ipText);
if (pageIp && ip.inRange(pageIp)) {
userTalk.add(`User talk:${ipText}`);
}
}
const nextLink = fetched.querySelector('.mw-prefixindex-nav a');
if (nextLink && nextLink.textContent.includes('Next page') && nextLink.href) {
url = nextLink.href;
} else {
url = null;
}
} catch (error) {
console.error('Error fetching usertalk pages:', error);
errors = true;
break;
}
pagesFetched++;
}
if (!validPrefix || errors || pagesFetched === maxPages) {
url = `/wiki/Special:Contributions/${ip}?limit=500`;
try {
const html = await fetch(url).then(res => res.text());
const parser = new DOMParser();
const fetched = parser.parseFromString(html, 'text/html');
const talkLinks = fetched.querySelectorAll('.mw-contributions-list a.mw-usertoollinks-talk:not(.new)');
for (const link of talkLinks) {
const title = link.title;
if (title) userTalk.add(title);
}
} catch (error) {
console.error('Error fetching usertalk pages:', error);
}
}
return Array.from(userTalk);
}
function timeAgo(timestamp) {
const delta = (Date.now() - new Date(timestamp)) / 1000;
const units = { year: 31536000, month: 2628000, day: 86400, hour: 3600, minute: 60 };
for (const [unit, seconds] of Object.entries(units)) {
let count = delta / seconds;
if (count >= 1) return `${count | 0} ${unit}${count >= 2 ? 's' : ''}`;
}
return 'just now';
}
api = new mw.Api();
const contentContainer = document.querySelector('#mw-content-text');
if (!contentContainer) return;
const elementsToRemove = [
'#mw-content-subtitle .subpages',
'#mw-content-text .noarticletext',
'.vector-menu-content-list #ca-addsection',
'.vector-menu-content-list #ca-dt-page-subscribe',
'.vector-menu-content-list #ca-edit',
'.vector-menu-content-list #ca-nstab-user',
'.vector-menu-content-list #ca-protect',
'.vector-menu-content-list #ca-talk',
'.vector-menu-content-list #ca-watch',
'.vector-menu-content-list #ca-wikilove',
'.vector-menu-content-list #t-info',
'.vector-menu-content-list #t-log',
'.vector-menu-content-list #t-urlshortener',
'.vector-menu-content-list #t-urlshortener-qrcode',
'.vector-menu-content-list #t-whatlinkshere',
];
for (const selector of elementsToRemove) {
document.querySelector(selector)?.remove();
}
const cactions = document.getElementById('p-cactions');
if (cactions) {
const listItems = cactions.querySelectorAll('li');
const anyVisible = Array.from(listItems).some(li => {
return li.offsetParent !== null;
});
if (!anyVisible) {
cactions.style.display = 'none';
}
}
const contributions = document.querySelector('#t-contributions a');
if (contributions) {
contributions.href = `/wiki/Special:Contributions/${ip}`;
}
const globalContributions = document.querySelector('#t-global-contributions a');
if (globalContributions) {
globalContributions.href = `/wiki/Special:GlobalContributions/${ip}`;
}
const blockUser = document.querySelector('#t-blockip a');
if (blockUser) {
blockUser.href = `/wiki/Special:Block/${ip}`;
}
let userTalk;
let userTalkMethod;
if (ip.version === 4 && ip.mask >= 24) {
userTalk = ip.enumerate().map(ipString => `User talk:${ipString}`);
userTalkMethod = "enumerate";
} else {
userTalk = await getUserTalkPages(ip);
userTalkMethod = "contributions";
if (!userTalk.length) {
const resultMessage = document.createElement('p');
resultMessage.style.color = 'var(--color-notice, gray)';
resultMessage.textContent = 'No user talk pages found for recent contributions from this IP range.';
contentContainer.appendChild(resultMessage);
return;
}
}
const infoResponses = await Promise.all(
batch(userTalk, 50).map(titles => api.get({
action: 'query',
titles: titles.join('|'),
prop: 'info|revisions',
format: 'json',
formatversion: 2
}))
);
const pages = infoResponses
.flatMap(response => response.query.pages)
.filter(page => !page.missing && page.revisions && page.revisions.length > 0)
.map(page => ({
title: page.title,
timestamp: page.revisions[0].timestamp,
redirect: !!page.redirect
}))
.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
if (!pages.length) {
const resultMessage = document.createElement('p');
if (userTalkMethod === "enumerate") {
resultMessage.style.color = 'var(--color-notice, gray)';
resultMessage.textContent = 'No user talk pages found.';
} else {
resultMessage.style.color = 'var(--color-error, red)';
resultMessage.textContent = 'An error occurred while retrieving timestamps for user talk pages in this IP range.';
}
contentContainer.appendChild(resultMessage);
return;
}
const parseTasks = [];
for (const page of pages) {
const ip = page.title.replace(/^User talk:/, '');
const relativeTime = `${timeAgo(page.timestamp)} ago`;
const headerText = `== ${relativeTime}: [[Special:Contributions/${ip}|${ip}]] ([[${page.title}|talk]]) ==`;
const inclusionText = `{{${page.title}}}`;
parseTasks.push({ text: headerText, disableeditsection: true, });
parseTasks.push({ text: inclusionText, disableeditsection: false, });
}
const parsePromises = parseTasks.map(task =>
api.post({
action: 'parse',
format: 'json',
prop: 'text',
contentmodel: 'wikitext',
title: `Special:BlankPage/RangeTalk/${ip}`,
text: task.text,
disableeditsection: task.disableeditsection,
})
);
for (const promise of parsePromises) {
const result = await promise;
const html = result.parse.text['*'];
const fragment = document.createRange().createContextualFragment(html);
contentContainer.appendChild(fragment);
}
mw.hook('wikipage.content').fire($(contentContainer));
const twinkleElementsToRemove = [
'.vector-menu-content-list #tw-block',
'.vector-menu-content-list #tw-rpp',
'.vector-menu-content-list #tw-unlink',
'.vector-menu-content-list #tw-warn',
'.vector-menu-content-list #twinkle-talkback',
'.vector-menu-content-list #twinkle-welcome',
];
for (const selector of twinkleElementsToRemove) {
document.querySelector(selector)?.remove();
}
}
 
Line 264 ⟶ 513:
const heading = document.querySelector('#firstHeading');
if (heading) {
heading.innerHTMLtextContent = 'Range calculator';
}
const contentContainer = document.querySelector('#mw-content-text');
Line 362 ⟶ 611:
return '<span style="color:red;">No common range found.</span>';
}
const resultRange = ips[0].masked(bestMask).toString(false, true);
const contribsLink = `<a href="/wiki/Special:Contributions/${resultRange}" target="_blank">${resultRange.toString(false, true)}</a>`;
const blockLink = `<a href="/wiki/Special:Block/${resultRange}" target="_blank">block</a>`;
return `<span>${ips.length} unique IP${ips.length === 1 ? '' : 's'}: ${contribsLink} (${blockLink})</span>`;
Line 410 ⟶ 659:
const userTools = `(<a href="/wiki/User_talk:${block.user}" title="User talk:${block.user}">talk</a> | <span><a href="/wiki/Special:Contributions/${block.user}" title="Special:Contributions/${block.user}">contribs</a>)`;
const action = block.action === "reblock" ? "changed block settings for" : `${block.action}ed`;
const ipLink = `<a href="/wiki/Special:Contributions/${block.range}" title=""><bdi>${block.range.toString(false, true)}</bdi></a>`;
let restrictions = '';
if (block.params?.restrictions) {
Line 476 ⟶ 725:
}
return wikitext;
}
 
function batch(items, maxSize) {
const minBins = Math.ceil(items.length / maxSize);
const bins = Array.from({length: minBins}, () => []);
items.forEach((item, i) => {
bins[i % minBins].push(item);
});
return bins;
}
 
Line 491 ⟶ 749:
const index = ipList.findIndex(i => i.equals(ip));
if (index !== -1) ipList.splice(index, 1);
}
 
function commonPrefix(a, b) {
let i = 0;
while (i < a.length && i < b.length && a[i] === b[i]) {
i++;
}
return a.slice(0, i);
}
});