/*
* Adds a "Test against past hits" button to [[Special:AbuseFilter/test]].
* Useful for testing your changes to a filter without tediously checking
* each old hit with [[Special:AbuseFilter/examine]].
*
* Only the "user", "page", "before", and "after" fields are respected.
*/
// jshint esnext: false, esversion: 8
// <nowiki>
(function() {
/* globals $, mw, OO */
'use strict';
// If forking, PLEASE change this line.
const API_USER_AGENT = "batchtest-plus/0.5 (https://en.wikipedia.org/wiki/User:Suffusion_of_Yellow/batchtest-plus.js)";
const DEFAULT_CONFIG = {
"default" : {
batchSize: 100, // Same as Special:AbuseFilter/test
maxConcurrentRequests: 10, // Too many seems to cause random HTTP timeouts
falsePositveTestFilter: false, // Filter at your wiki matching a random sample of edits
enableFalseNegativeTest: false
},
"en.wikipedia.org" : {
falsePositiveTestFilter: 1201,
enableFalseNegativeTest: true
}
};
let config = { }, api;
function handleApiError(code, details) {
if (typeof code != 'string')
throw code; // Something went very wrong
if (code == "http" && details.textStatus == "abort")
return { aborted: true }; // Aborted by user, not an error
return {
error : (code == "http") ?
"HTTP error: " + details.textStatus :
"API returned error \"" + code + "\": " + details.error.info
};
}
// Make API abuselog entry into something human-readable.
function formatLogEntry(log) {
let link = (target, text) =>
$('<a></a>', {
href: mw.util.getUrl(target),
text: text
});
let sclink = (params, text) =>
$('<a></a>', {
href: new mw.Uri(mw.config.get('wgScript')).extend(params),
text: text
});
let monthNamesShort =
[ "", "Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec" ];
let t = log.timestamp;
let date = parseInt(t.slice(8, 10)) +
" " + monthNamesShort[parseInt(t.slice(5, 7))] +
" " + t.slice(0, 4);
let time = t.slice(11, 19);
let $li = $('<li></li>').append(
"(",
link("Special:AbuseLog/" + log.id, "details"),
" | ",
link("Special:AbuseFilter/examine/log/" + log.id, "examine"),
" | ",
log.revid ? link("Special:Diff/" + log.revid, "diff") : "diff",
") . . ",
link("Special:AbuseFilter/" + log.filter_id,
log.filter_id).attr("title", log.filter),
" (",
mw.html.escape(log.action),
" -> ",
$('<span></span>', {
text : log.result || "none",
class : "filter-highlighter-" + (log.result || "noaction")
}),
") . . ",
link(log.title, log.title),
" (",
log.title.indexOf("Special:") !== 0 ?
sclink( { title : log.title,
action : "history" }, "hist") : "hist",
" | ",
sclink( { title: "Special:AbuseLog",
wpSearchTitle : log.title }, "log"),
"); ",
mw.html.escape(time),
" . . ",
link("Special:Contributions/" + log.user, log.user),
" (",
link("User talk:" + log.user, "talk"),
" | ",
sclink( { title: "Special:AbuseLog",
wpSearchUser : log.user }, "log"),
")"
);
$li.addClass("btp-maybematch");
return [date, $li];
}
async function doTest(abuselog, filter, stats, cb) {
let pending = [];
for(let log of abuselog) {
let idx = pending.length < config.maxConcurrentRequests ?
pending.length : await Promise.race(pending);
if (idx === undefined)
break; // Something went wrong with the last request
pending[idx] = api.post({
action : 'abusefiltercheckmatch',
filter : filter,
logid : log.id
}).catch(handleApiError)
.then(response => {
let result;
if (response.aborted)
return;
stats.tested++;
if (!response || !response.abusefiltercheckmatch) {
stats.errors++;
result = null;
} else {
result = response.abusefiltercheckmatch.result;
}
if (result)
stats.matches++;
if (cb)
cb(log.id, result, response.error);
return idx;
});
}
await Promise.all(pending);
return stats;
}
async function testAtTestPage(filters, query, testFilter, action, stats) {
if (!query) {
// We got here because the user clicked "Test".
// If they had clicked "continue", query would be defined.
stats = {
tested : 0,
errors: 0,
matches : 0
};
query = {
action : "query",
list : "abuselog",
aflprop : "ids|filter|user|title|action|result|timestamp|revid",
afllimit : config.batchSize
};
let user = $('[name="wpTestUser"]').val();
let title = $('[name="wpTestPage"]').val();
let after = $('[name="wpTestPeriodStart"]').val();
let before = $('[name="wpTestPeriodEnd"]').val();
testFilter = $('[name="wpFilterRules"]').val();
action = $('[name="wpTestAction"]').val();
if (filters.length)
query.aflfilter = filters;
if (user.length)
query.afluser = user;
if (title.length)
query.afltitle = title;
if (before.length)
query.aflstart = before;
if (after.length)
query.aflend = after;
// Cleanup last run, or the normal /test results
$('.mw-changeslist, .btp-results, .btp-progress').remove();
mw.util.$content.append('<div class="btp-results"></div>');
mw.util.$content.append('<h4 class="btp-progress"></h4>');
}
let response = await api.get(query).catch(handleApiError);
if (!response || !response.query || !response.query.abuselog) {
if (response.error)
mw.notify(response.error);
return;
}
let abuselog = response.query.abuselog
.filter((log) => (action === "0" || log.action.includes(action)));
let $results = $('<div></div>'), $loglines = {};
let prev = $(".btp-results").find('h4').last().text();
let $ul = $('<ul></ul>');
$results.append($ul);
for(let log of abuselog) {
let [date, $li] = formatLogEntry(log);
$loglines[log.id] = $li;
if (date != prev ) {
prev = date;
$ul = $('<ul></ul>');
$results.append($('<h4></h4>', { text: date }), $ul);
}
$ul.append($li);
}
$('.btp-results').append($results);
await doTest(abuselog, testFilter, stats, (id, result, err) => {
$loglines[id].removeClass('btp-maybematch').removeAttr("title");
if (result === null) {
$loglines[id].addClass('btp-error').attr("title", err);
} else if (result === true) {
$loglines[id].addClass('btp-match');
} else {
$loglines[id].addClass('btp-nomatch');
}
});
let $summary = $('<h4></h4>').append(
$('<span></span>', {
text: stats.matches + "/" + stats.tested + " match, " + stats.errors + " error(s)"
})
);
if (response.continue) {
$summary.append(
", ",
$('<a></a>', {
text: "continue?"
}).click(() => {
$summary.remove();
query.aflstart = response.continue.aflstart;
testAtTestPage(filters, query, testFilter, action, stats);
})
);
}
$results.append($summary);
// For popups/markblocked/filter-highlighter/etc.
mw.hook('wikipage.content').fire($results);
}
async function testAtFilterEditor(filterRules, id) {
let stats = {
tested : 0,
errors: 0,
matches : 0
};
let query = {
action : "query",
list : "abuselog",
aflprop : "ids|filter",
afllimit : config.batchSize,
aflfilter : id
};
$('.btp-progress').text("Fetching filter log...");
let response = await api.get(query).catch(handleApiError);
if (!response || !response.query || !response.query.abuselog || !response.query.abuselog.length) {
$('.btp-progress').text("Failed to fetch filter log");
return;
}
await doTest(response.query.abuselog, filterRules, stats, () => {
$('.btp-progress').text(
stats.matches + "/" + stats.tested + " match, "
+ stats.errors + " error(s) (Filter rule: "
+ response.query.abuselog[0].filter + ")"
);
});
}
function setupFilterEditor() {
let $form = $("#mw-abusefilter-editing-form");
let $saveButton = $form.find("input[type=submit]");
let FPButton, FNButton;
if (config.falsePositiveTestFilter) {
FPButton = new OO.ui.ButtonWidget({
label: 'FP check',
title: 'Check for false positives'
}).on("click", async () => {
api.abort();
testAtFilterEditor($("#wpFilterRules").val(), config.falsePositiveTestFilter);
});
}
let id = $form.attr("action") && $form.attr("action").match(/\d+$/);
if (config.enableFalseNegativeTest && id) {
FNButton = new OO.ui.ButtonWidget({
label: 'FN check',
title: 'Check for false negatives'
}).on("click", () => {
api.abort();
testAtFilterEditor($("#wpFilterRules").val(), id[0]);
});
}
$saveButton.parent().after(
FPButton && FPButton.$element,
FNButton && FNButton.$element,
$('<span style="font-size:85%"></span>').html('<a href="https://en.wikipedia.org/wiki/User:Suffusion_of_Yellow/batchtest-plus">What\'s this?')
);
$form.append($('<div class="btp-progress"></div>'));
}
function setupTestPage() {
let filterId = mw.config.get('wgPageName').match(/\/(\d+)$/);
let filters = new OO.ui.TextInputWidget({
placeholder: "Filter IDs (separate with pipes)",
value: filterId && filterId[1]
});
let test = new OO.ui.ButtonWidget({
label: "Test"
}).on("click", () => {
api.abort();
testAtTestPage(filters.getValue());
});
let cancel = new OO.ui.ButtonWidget({
label: "Cancel"
}).on("click", () => {
api.abort();
});
let fieldset = new OO.ui.FieldsetLayout({
label: "Test against past hits"
}).addItems([new OO.ui.HorizontalLayout({
items: [filters, test, cancel]
})]);
$('#wpFilterForm').append(fieldset.$element);
}
function setup() {
Object.assign(config, DEFAULT_CONFIG['default']);
Object.assign(config, DEFAULT_CONFIG[mw.config.get('wgServerName')]);
if(window.batchTestPlusConfig) {
Object.assign(config, window.batchTestPlusConfig['default']);
Object.assign(config, window.batchTestPlusConfig[mw.config.get('wgServerName')]);
}
api = new mw.Api( {
ajax: {
headers: {
'Api-User-Agent' : API_USER_AGENT
}
}
});
if (/\/test(\/\d+)?$/.test(mw.config.get('wgPageName')))
setupTestPage();
else if ($('#mw-abusefilter-editing-form') &&
(config.falsePositveTestFilter || config.enableFalseNegativeCheck));
setupFilterEditor();
}
if (mw.config.get('wgCanonicalSpecialPageName') === 'AbuseFilter') {
$.when($.ready,
mw.loader.load("https://en.wikipedia.org/w/index.php?action=raw&title=User:Suffusion_of_Yellow/batchtest-plus.css&ctype=text/css", "text/css"),
mw.loader.using(
['mediawiki.util',
'mediawiki.api',
'mediawiki.Uri',
'oojs-ui-core'])).then(setup);
}
})();
// </nowiki>