User:Polygnotus/Scripts/DetectPromo-v2.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/DetectPromo-v2.
//Forked from [[User:Novem Linguae/Scripts/DetectPromo.js]]
//now highlights the words in the article text and those in the top bar are now clickable
// <nowiki>
/*
- Let reviewer know when certain promotional and POV keywords are detected.
- Displays a bar at the top of the article, listing the detected keywords.
- Highlights detected promotional words with a yellow background and red border within the article text.
- Makes the words in the top bar clickable, scrolling to their first occurrence in the article.
- Loads a shared exception list from [[User:Polygnotus/Scripts/DetectPromo/Exceptions]].
- Clicking a highlighted word in the article opens a dialog to add a new exception.
- Submitting the dialog appends the exception to the shared exception page via the API,
so false positives are fixed for all users of the script at once.
- Ignores promotional words that appear in the page title.
- Ignores promotional words that are (part of) a wikilink, both in detection
(wikilinks are stripped from the scanned wikicode) and in highlighting
(text nodes inside <a> elements are skipped).
- Ignores everything in and below the References section, both in detection
(wikicode is truncated at ==References==) and in highlighting.
- In mainspace, only runs on pages about living people (membership in
Category:Living people) or organizations (Wikidata P31 value that is
Q43229 "organization" or a subclass of it, excluding territorial entities,
checked via a SPARQL ASK query against the Wikidata Query Service, cached
for 24 hours). Always runs in draftspace, since drafts usually lack
Wikidata items.
- Skips matches that look like part of a proper noun: a lowercase list entry
matched with a capital letter mid-sentence (e.g. "EFF Pioneer Award",
"Dynamic Island") is not flagged. Sentence-initial capitals are still
flagged, and inherently capitalized entries like "B2B" are unaffected.
- Skips quoted text ("..." and curly quotes) and italics: attributed praise
in quotes is acceptable per WP:PEACOCK, and italics mostly mark titles.
- Defers all work (including API and WDQS requests) until the browser tab is
actually visible, and until the browser is idle, so opening many tabs at
once does not fire off requests for tabs that are never viewed.
*/
class DetectPromo {
/** Page that stores the shared, user-editable exception list */
exceptionsPageTitle = 'User:Polygnotus/Scripts/DetectPromo/Exceptions';
/**
* Run on all drafts regardless of topic. Drafts usually do not have a
* Wikidata item yet (items are typically created after acceptance into
* mainspace), so the organization check would fail on nearly every draft
* about a company. Set to false to apply the same filter to drafts.
*/
alwaysRunInDraftspace = true;
/** How long (in ms) to cache the Wikidata organization check per item */
orgCheckCacheTtl = 24 * 60 * 60 * 1000;
/** Link target for the documentation page, used in edit summaries */
documentationPage = 'User:Polygnotus/Scripts/DetectPromo';
/** @type {string[]} */
wordsToSearch = [
'% growth', '6-figure', '7-figure', '8-figure', '9-figure',
'B2B', 'B2C', 'a record', 'acclaimed', 'accomplished',
'are a necessity', 'around the world', 'award winning', 'award-winning',
'beloved', 'best available', 'bestselling', 'boasts', 'comprehensive',
'countless hours', 'create a revolution', 'critical acclaim',
'disrupt', 'drastically', 'dynamic', 'elevate', 'eminent', 'engaging',
'entrepreneur', 'evangelist', 'excelled', 'exceptional', 'exemplified',
'exemplify', 'expert', 'expertise', 'extensive', 'famous', 'fascinating',
'fast growing', 'fast-growing', 'fastest growing', 'fastest-growing',
'finest', 'fully integrated', 'fully-integrated', 'globally',
'globally recognized', 'growing popularity', 'highlights',
'highly accomplished', 'highly praised', 'highly specialized',
'historic', 'honored with', 'hypnotic', 'illustrious', 'impressive',
'indelible', 'inexhaustible', 'influential', 'innovation', 'innovative',
'insights', 'inspired by', 'integrate', 'invaluable', 'leader in',
'leading', 'legendary', 'leverage', 'massive', 'mastermind', 'more than',
'most highly', 'most important', 'most impressive', 'most notable',
'mystical', 'natural charm', 'noteworthy', 'numerous', 'organically',
'outstanding', 'perfect', 'philanthropist', 'picturesque', 'pioneer',
'pioneering', 'popular destination', 'popularity', 'premiere',
'prestigious', 'prominence', 'prominent', 'promising', 'promulgator',
'ranked', 'reinvent', 'remarkable', 'renowed', 'renowned', 'resonating',
'respected', 'revolutionary', 'rising star', 'save millions', 'savvy',
'seamless', 'sensual', 'several offers', 'showcased', 'signature',
'significant', 'soulful', 'spanning', 'state of art', 'state of the art',
'state-of-art', 'state-of-the-art', 'striking', 'super famous',
'tailored', 'tranquility', 'transcend', 'transform', 'underpin',
'ventured into', 'very first', 'visionary', 'wide selection',
'widely used', 'world class', 'world-class', 'worldwide', 'zero to hero'
];
/**
* Built-in fallback exceptions. These are used in addition to the shared
* exception page, and guarantee the script still works if loading the
* exception page fails. Kept as a flat list of lowercase phrases.
* @type {string[]}
*/
defaultExceptions = [
'a record label', 'a record producer',
'vary drastically',
'dynamic list',
'exceptional cases',
'expert support',
'national register of historic places', 'historic county',
'historic counties', 'historic offences', 'historic crimes',
'historic defeat',
'inspired by',
'as leader in', 'leader in scotland', 'leader in wales',
'leader in holyrood',
'leading to', 'leading up',
'numerous witnesses',
'outstanding debts', 'outstanding warrant', 'award for outstanding',
'world premiere', 'premiere of',
'promising to',
'revolutionary war', 'american revolutionary', 'revolutionary committee',
'revolutionary army', 'revolutionary communist', 'revolutionary party',
'signature =',
'significant blow', 'significant changes', 'significant control',
'more than one non-fifa'
];
/**
* @param {Object} mw
* @param {jQuery} $
*/
constructor(mw, $) {
this.mw = mw;
this.$ = $;
// Store page title for later use
this.pageTitle = this.mw.config.get('wgTitle');
this.pageName = this.mw.config.get('wgPageName');
// Convert to lowercase for case-insensitive comparison
this.pageTitleLower = this.pageTitle.toLowerCase();
/** @type {string[]} All active exception phrases (lowercase) */
this.allExceptions = [];
/** @type {boolean} Whether the shared exceptions page exists */
this.exceptionsPageExists = false;
/** @type {Object<string, string[]>} search word (lowercase) -> applicable exception phrases */
this.exceptionMap = {};
}
async execute() {
if (!this.shouldRunOnThisPage()) {
return;
}
// Only run on pages about living people or organizations, since those
// are the most likely to contain promotional material
if (!(await this.pageQualifies())) {
return;
}
const title = this.mw.config.get('wgPageName');
// Load the article wikicode and the shared exception list in parallel
const [wikicode, remoteExceptions] = await Promise.all([
this.getWikicode(title),
this.loadRemoteExceptions()
]);
if (!wikicode) return;
// Merge built-in defaults with the shared exception page, deduplicated
this.allExceptions = [...new Set([...this.defaultExceptions, ...remoteExceptions])];
this.buildExceptionMap();
// Clean wikicode by removing links and references
const cleanedWikicode = this.cleanWikicode(wikicode);
const searchResults = this.getSearchResults(cleanedWikicode);
if (searchResults.length > 0) {
// Locate the References heading once; used to exclude everything
// in and below that section from highlighting and scrolling
this.referencesHeading = this.getReferencesHeading();
this.displayResults(searchResults);
this.highlightPromoWords(searchResults);
}
}
/**
* Load exception phrases from the shared exceptions page.
* Format: one phrase per line, starting with "*". Everything else is ignored,
* so the page can contain explanatory text.
* @return {Promise<string[]>} lowercase exception phrases
*/
async loadRemoteExceptions() {
try {
const api = new this.mw.Api();
const response = await api.get({
action: 'query',
titles: this.exceptionsPageTitle,
prop: 'revisions',
rvprop: 'content',
rvslots: 'main',
formatversion: '2',
format: 'json'
});
const page = response.query.pages[0];
if (!page || page.missing) {
this.exceptionsPageExists = false;
return [];
}
this.exceptionsPageExists = true;
const content = page.revisions[0].slots.main.content;
return content
.split('\n')
.map(line => line.match(/^\*\s*(.+?)\s*$/))
.filter(Boolean)
.map(match => match[1].toLowerCase());
} catch (error) {
console.error('DetectPromo: error loading exceptions page:', error);
return [];
}
}
/**
* Build a map from each search word to the exception phrases that contain
* that word (as a whole word). Only words with applicable exceptions get
* an entry.
*/
buildExceptionMap() {
this.exceptionMap = {};
for (const word of this.wordsToSearch) {
const lowerWord = word.toLowerCase();
const wordRegex = new RegExp(`\\b${this.escapeRegEx(lowerWord)}\\b`, 'i');
const applicable = this.allExceptions.filter(phrase => wordRegex.test(phrase));
if (applicable.length > 0) {
this.exceptionMap[lowerWord] = applicable;
}
}
}
/**
* @param {string[]} searchResults
*/
displayResults(searchResults) {
const MAX_DISPLAYED_RESULTS = 20;
const displayedResults = searchResults.slice(0, MAX_DISPLAYED_RESULTS);
let html = `
<div id="DetectPromo" style="background-color: #ccc; padding: 10px; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center;">
<div>
<span style="font-weight: bold;">Potentially promotional words detected:</span>
`;
html += displayedResults.map(word =>
`<a href="#" class="promo-word" data-word="${word}" style="color: blue; text-decoration: underline; cursor: pointer;">${word}</a>`
).join(', ');
if (searchResults.length > MAX_DISPLAYED_RESULTS) {
html += ', ...... and more.';
}
html += `</div>
<div>
<a href="#" id="add-exception" style="color: #d33; text-decoration: underline; font-size: 0.9em;" title="Add a word combination that should not be flagged">Add exception</a>
</div>
</div>`;
this.$('#contentSub').after(html);
// Add click event listeners for promo words in the top bar
this.$('.promo-word').on('click', (e) => {
e.preventDefault();
const word = this.$(e.target).data('word');
this.scrollToWord(word);
});
// Add click event listener for the "Add exception" link in the top bar
this.$('#add-exception').on('click', (e) => {
e.preventDefault();
this.showExceptionDialog(searchResults, '');
});
// Delegated click handler for highlighted words in the article text:
// clicking a highlight opens the exception dialog, prefilled with context
this.$('#mw-content-text').on('click', '.promo-highlight', (e) => {
e.preventDefault();
const span = e.currentTarget;
const word = this.$(span).data('word');
const prefill = this.getContextPhrase(span);
this.showExceptionDialog(searchResults, prefill, word);
});
}
/**
* Build a suggested exception phrase from the words surrounding a
* highlighted match, so the user only has to trim it down.
* @param {HTMLElement} span - The clicked highlight span
* @return {string} suggested phrase (match plus up to 2 words on each side)
*/
getContextPhrase(span) {
const parent = span.parentElement;
const parentText = parent.textContent;
// Compute the character offset of the span within its parent
let offset = 0;
for (const node of parent.childNodes) {
if (node === span) break;
offset += node.textContent.length;
}
const spanEnd = offset + span.textContent.length;
const beforeWords = parentText.slice(0, offset).trim().split(/\s+/).filter(Boolean).slice(-2);
const afterWords = parentText.slice(spanEnd).trim().split(/\s+/).filter(Boolean).slice(0, 2);
let phrase = [...beforeWords, span.textContent, ...afterWords].join(' ');
// Strip leading/trailing punctuation for a cleaner suggestion
phrase = phrase.replace(/^[^\w"']+|[^\w"']+$/g, '');
return phrase;
}
/**
* Scroll to the first occurrence of a word in the article
* @param {string} word
*/
scrollToWord(word) {
const content = this.$('#mw-content-text');
// Prefer an actual highlight, which already reflects all exclusions
// (proper nouns, quotes, links, References section)
const highlighted = content.find(`.promo-highlight[data-word="${word}"]`);
if (highlighted.length > 0) {
highlighted[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
const regex = new RegExp(`\\b${this.escapeRegEx(word)}\\b`, 'i');
const elements = content.find('*').contents().filter((_, node) => {
// Same node exclusions as the highlight logic
return node.nodeType === Node.TEXT_NODE &&
regex.test(node.textContent) &&
!(node.parentElement && node.parentElement.closest('a, i, em, q, blockquote')) &&
!this.isInReferencesOrBelow(node);
});
if (elements.length > 0) {
const firstOccurrence = elements[0];
firstOccurrence.parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
/**
* Find the DOM element of the References heading, if present.
* Handles legacy parser output (id on span.mw-headline inside the h2),
* newer output with a .mw-heading wrapper div, and Parsoid output
* (id directly on the h2).
* @return {Element|null}
*/
getReferencesHeading() {
const content = document.getElementById('mw-content-text');
if (!content) return null;
const target = content.querySelector('h2#References, h2 #References, .mw-headline#References');
if (!target) return null;
// Use the outermost heading wrapper so everything after it counts
return target.closest('.mw-heading') || target.closest('h2') || target;
}
/**
* @param {Node} node
* @return {boolean} true if the node is in or below the References section
*/
isInReferencesOrBelow(node) {
if (!this.referencesHeading) return false;
const position = this.referencesHeading.compareDocumentPosition(node);
return Boolean(
(position & Node.DOCUMENT_POSITION_FOLLOWING) ||
(position & Node.DOCUMENT_POSITION_CONTAINED_BY)
);
}
/**
* Highlight promotional words with yellow background and red border within the article text
* @param {string[]} searchResults
*/
highlightPromoWords(searchResults) {
const content = this.$('#mw-content-text');
const highlightStyle = 'background-color: yellow; border: 1px solid red; padding: 2px; margin: -2px; cursor: pointer;';
searchResults.forEach(word => {
const regex = new RegExp(`\\b${this.escapeRegEx(word)}\\b`, 'gi');
content.find('*').contents().filter((_, node) => {
// Skip text nodes inside links, italics, and quote elements,
// and anything in or below the References section
return node.nodeType === Node.TEXT_NODE &&
!(node.parentElement && node.parentElement.closest('a, i, em, q, blockquote')) &&
!this.isInReferencesOrBelow(node);
}).each((_, textNode) => {
const text = textNode.textContent;
if (regex.test(text)) {
const quotedRanges = this.getQuotedRanges(text);
const newHtml = text.replace(regex, (match, offset) => {
// Skip matches that look like part of a proper noun
if (this.isLikelyProperNoun(text, offset, match, word)) {
return match;
}
// Skip matches inside inline double quotes
const matchEnd = offset + match.length;
const inQuotes = quotedRanges.some(
range => offset >= range.start && matchEnd <= range.end
);
if (inQuotes) {
return match;
}
return `<span class="promo-highlight" data-word="${word}" title="Click to mark this as not promotional" style="${highlightStyle}">${match}</span>`;
});
if (newHtml !== text) {
const newElement = document.createElement('span');
newElement.innerHTML = newHtml;
textNode.parentNode.replaceChild(newElement, textNode);
}
}
});
});
}
/**
* Check if a word appears in the page title
* @param {string} word - The word to check
* @return {boolean} - True if the word is in the page title
*/
isWordInPageTitle(word) {
const wordLower = word.toLowerCase();
// Simple check for exact word in title
if (this.pageTitleLower.includes(wordLower)) {
// Check with word boundaries to ensure it's a complete word
const wordRegex = new RegExp(`\\b${this.escapeRegEx(wordLower)}\\b`, 'i');
return wordRegex.test(this.pageTitleLower);
}
return false;
}
/**
* Scans text for promotional words while respecting exceptions.
* Checks each individual match against exception phrases by position,
* so that e.g. "outstanding warrant" is ignored but a separate
* "outstanding performance" in the same document is still flagged.
*
* @param {string} text - The wikicode text to search
* @return {string[]} - List of found promotional words
*/
getSearchResults(text) {
const results = [];
const lowerText = text.toLowerCase();
// For each word to search
for (const word of this.wordsToSearch) {
const lowerWord = word.toLowerCase();
// Check if this word is in the text (quick filter)
if (!lowerText.includes(lowerWord)) {
continue;
}
// Find all matches with word boundaries
const wordRegex = new RegExp(`\\b${this.escapeRegEx(word)}\\b`, 'gi');
const matches = [...text.matchAll(wordRegex)];
if (matches.length === 0) {
continue;
}
// Check if word is in page title
if (this.isWordInPageTitle(word)) {
continue;
}
// Get the exception phrases that apply to this word
const applicableExceptions = this.exceptionMap[lowerWord] || [];
// Pre-find all exception phrase match ranges in the text
const exceptionRanges = [];
for (const exceptionPhrase of applicableExceptions) {
const exRegex = new RegExp(`\\b${this.escapeRegEx(exceptionPhrase)}\\b`, 'gi');
for (const exMatch of text.matchAll(exRegex)) {
exceptionRanges.push({
start: exMatch.index,
end: exMatch.index + exMatch[0].length
});
}
}
// Check each match individually. A match does not count if it is
// inside an exception phrase, or if it looks like part of a proper
// noun (capitalized mid-sentence, e.g. "EFF Pioneer Award").
let hasCountingMatch = false;
for (const match of matches) {
const matchStart = match.index;
const matchEnd = matchStart + match[0].length;
const isInsideException = exceptionRanges.some(
range => matchStart >= range.start && matchEnd <= range.end
);
if (isInsideException) {
continue;
}
if (this.isLikelyProperNoun(text, matchStart, match[0], word)) {
continue;
}
hasCountingMatch = true;
break; // One counting occurrence is enough to flag the word
}
if (hasCountingMatch) {
results.push(word);
}
}
return results;
}
/**
* Heuristic: a lowercase list entry that is matched with a capital letter
* in the middle of a sentence is almost certainly part of a proper noun
* ("EFF Pioneer Award", "Worldwide Developers Conference", "Dynamic
* Island") rather than puffery, which is written in lowercase prose.
*
* Deliberately does NOT apply to:
* - list entries that themselves contain capitals (e.g. "B2B"), and
* - sentence-initial matches, where capitalization is uninformative.
*
* @param {string} text - The full text the match was found in
* @param {number} matchIndex - Start offset of the match within text
* @param {string} matchedText - The matched text itself
* @param {string} listWord - The word list entry that produced the match
* @return {boolean} true if the match should be treated as a proper noun
*/
isLikelyProperNoun(text, matchIndex, matchedText, listWord) {
// Only applies to all-lowercase list entries
if (listWord !== listWord.toLowerCase()) {
return false;
}
// Is the first letter of the match uppercase?
const firstAlpha = matchedText.match(/[A-Za-z]/);
if (!firstAlpha || firstAlpha[0] !== firstAlpha[0].toUpperCase()) {
return false;
}
// Capitalized: decide whether it is sentence-initial. Strip trailing
// whitespace and opening quotes/brackets, then look at the last
// character before the match. Sentence-ending punctuation, newlines,
// and wiki markup characters (list bullets, headings, template pipes)
// all mean the match starts a sentence/line, where capitalization is
// uninformative, so the match is flagged normally.
const before = text.slice(0, matchIndex).replace(/[\s"'\u201C\u201D\u2018\u2019([]+$/, '');
if (before === '' || /[.!?:;\n*#=|>]$/.test(before)) {
return false;
}
return true;
}
/**
* Find the ranges of double-quoted spans ("..." and \u201C...\u201D) in a text,
* so matches inside them can be skipped. Quoted praise is attributed
* speech, which is acceptable per WP:PEACOCK.
* @param {string} text
* @return {{start: number, end: number}[]}
*/
getQuotedRanges(text) {
const ranges = [];
const patterns = [/"[^"\n]*"/g, /\u201C[^\u201D\n]*\u201D/g];
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(text)) !== null) {
ranges.push({ start: match.index, end: match.index + match[0].length });
}
}
return ranges;
}
/**
* Clean wikicode by removing links and references
* This focuses on removing wikilinks to avoid detecting promotional words
* within link targets/titles
*
* @param {string} wikicode
* @return {string} cleanedWikicode
*/
cleanWikicode(wikicode) {
// Cut off everything from the References section onward, so nothing in
// that section or below it (external links, categories, etc.) is scanned
let cleanedCode = wikicode;
const refHeading = cleanedCode.match(/^==\s*References\s*==\s*$/mi);
if (refHeading) {
cleanedCode = cleanedCode.slice(0, refHeading.index);
}
// Remove categories entirely (they should not be scanned)
cleanedCode = cleanedCode.replace(/\[\[Category:[^\]]*\]\]/gi, '');
// Remove all wikilinks entirely (including their display text), so words
// inside links are neither detected nor highlighted. Replaced with a
// space to avoid accidentally merging adjacent words into a new match.
cleanedCode = cleanedCode.replace(/\[\[[^\]]+\]\]/g, ' ');
// Remove <ref> tags
cleanedCode = cleanedCode.replace(/<ref[^<]*<\/ref>|<ref[^>]*\/>/gm, '');
// Unwrap bold markup ('''text''' -> text) so the italics regex below
// does not mangle it
cleanedCode = cleanedCode.replace(/'''(.+?)'''/g, '$1');
// Remove italicized spans entirely (''text''). Italics mostly mark
// titles of works and quoted terms, not the article's own voice.
cleanedCode = cleanedCode.replace(/''.+?''/g, ' ');
// Remove double-quoted spans entirely ("..." and Unicode curly quotes).
// Quoted praise is attributed speech, which is acceptable per
// WP:PEACOCK, so flagging it is noise.
cleanedCode = cleanedCode.replace(/"[^"\n]*"/g, ' ');
cleanedCode = cleanedCode.replace(/\u201C[^\u201D\n]*\u201D/g, ' ');
return cleanedCode;
}
/**
* @return {boolean}
*/
shouldRunOnThisPage() {
const action = this.mw.config.get('wgAction');
const isDiff = this.mw.config.get('wgDiffNewId');
const isDeletedPage = !this.mw.config.get('wgCurRevisionId');
const namespace = this.mw.config.get('wgNamespaceNumber');
const title = this.mw.config.get('wgPageName');
return (
action === 'view' &&
!isDiff &&
!isDeletedPage &&
([0, 118].includes(namespace) ||
title === 'User:Polygnotus')
);
}
/**
* Decide whether this page is about a living person or an organization.
*
* - Living person: membership in [[:Category:Living people]]. This is the
* mechanism the English Wikipedia itself uses to flag BLPs for tooling,
* so it is authoritative and also correctly excludes people who have
* since died.
* - Organization: the page's Wikidata item has a P31 (instance of) value
* that is Q43229 (organization) or any subclass of it, determined via a
* SPARQL ASK query against the Wikidata Query Service. This walks the
* class hierarchy instead of guessing every leaf type (business,
* company, enterprise, nonprofit, government agency, ...).
*
* Fails open: if the classification cannot be determined due to network
* or service errors, the script runs anyway.
*
* @return {Promise<boolean>}
*/
async pageQualifies() {
const namespace = this.mw.config.get('wgNamespaceNumber');
// Test page
if (this.mw.config.get('wgPageName') === 'User:Polygnotus') {
return true;
}
// Drafts usually have no Wikidata item yet, see alwaysRunInDraftspace
if (namespace === 118 && this.alwaysRunInDraftspace) {
return true;
}
let info;
try {
info = await this.getPageClassificationInfo();
} catch (error) {
console.error('DetectPromo: classification query failed, running anyway:', error);
return true;
}
if (info.isLivingPerson) {
return true;
}
if (info.wikibaseItem) {
return this.isOrganization(info.wikibaseItem);
}
// No Living people category and no Wikidata item: does not qualify
return false;
}
/**
* Fetch, in a single API call, whether the page is in
* [[:Category:Living people]] and its linked Wikidata item id (if any).
* @return {Promise<{isLivingPerson: boolean, wikibaseItem: string|null}>}
*/
async getPageClassificationInfo() {
const api = new this.mw.Api();
const response = await api.get({
action: 'query',
pageids: this.mw.config.get('wgArticleId'),
prop: 'pageprops|categories',
ppprop: 'wikibase_item',
clcategories: 'Category:Living people',
formatversion: '2',
format: 'json'
});
const page = response.query.pages[0];
const isLivingPerson = Boolean(page.categories && page.categories.length > 0);
const wikibaseItem = (page.pageprops && page.pageprops.wikibase_item) || null;
return { isLivingPerson, wikibaseItem };
}
/**
* Ask the Wikidata Query Service whether the given item is an
* organization: an instance of Q43229 (organization) or any subclass of
* it, excluding territorial entities (see the query comment below).
* Results are cached in localStorage to avoid hitting WDQS on every page
* view.
*
* Fails open (returns true) on network or service errors.
*
* @param {string} qid - Wikidata item id, e.g. "Q95"
* @return {Promise<boolean>}
*/
async isOrganization(qid) {
if (!/^Q\d+$/.test(qid)) {
return false;
}
// Check the cache first
const cacheKey = 'DetectPromo-org-' + qid;
if (this.mw.storage) {
const cached = this.mw.storage.getObject(cacheKey);
if (cached && cached.expires > Date.now()) {
return cached.value;
}
}
// A plain P31/P279* walk to Q43229 is not enough: the Wikidata class
// hierarchy lets cities, states, and countries reach "organization"
// (e.g. federated state -> state -> political organization). The two
// FILTER NOT EXISTS clauses exclude anything that is also an
// administrative territorial entity (Q56061) or a human settlement
// (Q486972). Verified: Google/Harvard/MSF -> true; Berlin/Germany/
// humans/abstract concepts -> false.
const sparql = `ASK { wd:${qid} wdt:P31/wdt:P279* wd:Q43229 . ` +
`FILTER NOT EXISTS { wd:${qid} wdt:P31/wdt:P279* wd:Q56061 . } ` +
`FILTER NOT EXISTS { wd:${qid} wdt:P31/wdt:P279* wd:Q486972 . } }`;
const url = 'https://query.wikidata.org/sparql?format=json&query=' + encodeURIComponent(sparql);
try {
const response = await fetch(url, {
headers: { 'Accept': 'application/sparql-results+json' }
});
if (!response.ok) {
throw new Error('WDQS returned HTTP ' + response.status);
}
const data = await response.json();
const result = data.boolean === true;
// Cache the result
if (this.mw.storage) {
this.mw.storage.setObject(cacheKey, {
value: result,
expires: Date.now() + this.orgCheckCacheTtl
});
}
return result;
} catch (error) {
console.error('DetectPromo: WDQS organization check failed, running anyway:', error);
return true;
}
}
/**
* @param {string} title
* @return {Promise<string|null>} wikicode
*/
async getWikicode(title) {
try {
const api = new this.mw.Api();
const response = await api.get({
action: 'parse',
page: title,
prop: 'wikitext',
formatversion: '2',
format: 'json'
});
return response.parse.wikitext;
} catch (error) {
console.error('Error fetching wikicode:', error);
return null;
}
}
/**
* Show a dialog that lets the user add an exception phrase directly to the
* shared exceptions page.
* @param {string[]} detectedWords - All promotional words detected on this page
* @param {string} prefill - Suggested phrase to prefill the text field with
* @param {string} [clickedWord] - The specific word that was clicked, if any
*/
showExceptionDialog(detectedWords, prefill, clickedWord) {
const self = this;
// Use MediaWiki's OOjs UI dialog framework
this.mw.loader.using(['oojs-ui-core', 'oojs-ui-windows', 'oojs-ui-widgets'], () => {
// Create layout for dialog content
const layout = new OO.ui.FieldsetLayout();
// Text field for entering the word combination
const wordCombinationField = new OO.ui.TextInputWidget({
value: prefill || '',
placeholder: 'Enter word combination (e.g., "outstanding warrant")',
title: 'Enter the exact multi-word combination that should not be flagged as promotional'
});
// Display the detected promotional words for reference
const detectedWordsHtml = $('<div>')
.addClass('detected-words-list')
.css({
'margin-bottom': '10px',
'background-color': '#f8f9fa',
'padding': '8px',
'border-radius': '2px',
'border': '1px solid #eaecf0'
})
.append($('<strong>').text(clickedWord ? 'Detected word: ' : 'Detected promotional words: '))
.append(document.createTextNode(clickedWord || detectedWords.join(', ')));
// Add fields to layout
layout.addItems([
new OO.ui.FieldLayout(wordCombinationField, {
label: 'Word combination that is NOT promotional',
align: 'top'
})
]);
// Add the detected words info before the layout
layout.$element.prepend(detectedWordsHtml);
// Add instructions
const instructionsLayout = new OO.ui.PanelLayout({
padded: true,
expanded: false
});
instructionsLayout.$element.append(
$('<div>')
.css('margin-bottom', '1em')
.append($('<p>').html('Adding an exception edits <b>[[' + self.exceptionsPageTitle + ']]</b> in your name. The exception takes effect for all users of this script.'))
.append($('<p>').html('<strong>The combination must:</strong>'))
.append($('<ul>')
.append($('<li>').text('Include at least 2 words'))
.append($('<li>').text('Include one of the detected promotional words'))
.append($('<li>').text('Form a phrase that is NOT promotional'))
)
.append($('<p>').text('For example:'))
.append($('<ul>')
.append($('<li>').text('"outstanding" can be promotional, but "outstanding warrant" is not'))
.append($('<li>').text('"leading" can be promotional, but "leading to" is not'))
.append($('<li>').text('"revolutionary" can be promotional, but "revolutionary war" is not'))
)
);
// Define a proper dialog class with a name
function ExceptionDialog(config) {
ExceptionDialog.super.call(this, config);
}
OO.inheritClass(ExceptionDialog, OO.ui.ProcessDialog);
// Define static properties
ExceptionDialog.static.name = 'detectPromoExceptionDialog';
ExceptionDialog.static.title = 'Add exception';
ExceptionDialog.static.actions = [
{
action: 'cancel',
label: 'Cancel',
flags: ['safe', 'close']
},
{
action: 'submit',
label: 'Add exception',
flags: ['primary', 'progressive']
}
];
ExceptionDialog.static.size = 'medium';
// Create dialog
const exceptionDialog = new ExceptionDialog();
// Define dialog process
ExceptionDialog.prototype.getActionProcess = function (action) {
const dialog = this;
if (action === 'submit') {
const wordCombination = wordCombinationField.getValue().trim();
// Check for characters that would break the wikitext list format
if (/[\[\]{}|<>\n]/.test(wordCombination)) {
wordCombinationField.setValidityFlag(false);
return new OO.ui.Process(function () {
dialog.showErrors(new OO.ui.Error(
'The combination may not contain the characters [ ] { } | < > or line breaks.',
{ recoverable: true }
));
});
}
// Check if the word combination has at least 2 words
const wordCount = wordCombination.split(/\s+/).filter(w => w.length > 0).length;
if (wordCount < 2) {
wordCombinationField.setValidityFlag(false);
return new OO.ui.Process(function () {
dialog.showErrors(new OO.ui.Error(
'Please enter a multi-word combination (at least 2 words). We only want to exclude word combinations that are not promotional in context.',
{ recoverable: true }
));
});
}
// Check if any of the detected promotional words are part of the combination
const includedWord = detectedWords.find(word => {
const wordRegex = new RegExp(`\\b${self.escapeRegEx(word)}\\b`, 'i');
return wordRegex.test(wordCombination);
});
if (!includedWord) {
wordCombinationField.setValidityFlag(false);
return new OO.ui.Process(function () {
dialog.showErrors(new OO.ui.Error(
'Your word combination must include one of the detected promotional words.',
{ recoverable: true }
));
});
}
// Check for duplicates against the currently loaded exceptions
if (self.allExceptions.includes(wordCombination.toLowerCase())) {
wordCombinationField.setValidityFlag(false);
return new OO.ui.Process(function () {
dialog.showErrors(new OO.ui.Error(
'This exception already exists.',
{ recoverable: true }
));
});
}
// All validation passed: save the exception, then close
return new OO.ui.Process(function () {
return self.saveException(wordCombination, includedWord).then(
function () {
dialog.close({ action: action });
},
function (error) {
dialog.showErrors(new OO.ui.Error(
'Saving the exception failed: ' + error,
{ recoverable: true }
));
}
);
});
}
// Handle cancel and close actions explicitly
if (action === 'cancel' || action === 'close') {
return new OO.ui.Process(function () {
dialog.close({ action: action });
});
}
// Fallback for other actions
return ExceptionDialog.super.prototype.getActionProcess.call(this, action);
};
// Define dialog setup process - this is the proper way to add content
ExceptionDialog.prototype.getSetupProcess = function () {
return ExceptionDialog.super.prototype.getSetupProcess.call(this).next(function () {
// Add content to the body
this.$body.append(instructionsLayout.$element, layout.$element);
}, this);
};
// Add dialog to window manager
const windowManager = new OO.ui.WindowManager();
this.$('body').append(windowManager.$element);
windowManager.addWindows([exceptionDialog]);
// Open dialog
windowManager.openWindow(exceptionDialog);
});
}
/**
* Append an exception phrase to the shared exceptions page via the API,
* update the in-memory exception list, and remove highlights that are now
* covered by the new exception.
* @param {string} phrase - The non-promotional word combination
* @param {string} includedWord - The detected promotional word inside it
* @return {Promise}
*/
saveException(phrase, includedWord) {
const self = this;
const storedPhrase = phrase.toLowerCase();
const currentPage = this.mw.config.get('wgPageName');
const api = new this.mw.Api();
const summary = `Adding exception "${storedPhrase}" for "${includedWord}" via [[${this.documentationPage}|DetectPromo]] (seen on [[${currentPage}]])`;
let editParams;
if (this.exceptionsPageExists) {
// Appending avoids edit conflicts when multiple users add exceptions
editParams = {
action: 'edit',
title: this.exceptionsPageTitle,
appendtext: '\n* ' + storedPhrase,
summary: summary
};
} else {
// Create the page with a short explanation on first use
editParams = {
action: 'edit',
title: this.exceptionsPageTitle,
text: `This page lists exceptions for [[${this.documentationPage}|DetectPromo]]. One phrase per line, starting with an asterisk. Lines not starting with an asterisk are ignored.\n\n* ${storedPhrase}`,
summary: summary
};
}
return api.postWithEditToken(editParams).then(function () {
self.exceptionsPageExists = true;
// Update the in-memory state so duplicate checks and highlight
// removal work without a page reload
self.allExceptions.push(storedPhrase);
const lowerWord = includedWord.toLowerCase();
if (!self.exceptionMap[lowerWord]) {
self.exceptionMap[lowerWord] = [];
}
self.exceptionMap[lowerWord].push(storedPhrase);
self.removeCoveredHighlights(storedPhrase);
self.mw.notify(`Exception "${storedPhrase}" added. Thank you!`, { type: 'success' });
});
}
/**
* Remove highlight spans whose match now falls inside the given exception
* phrase, so the page reflects the new exception without a reload.
* @param {string} phrase - The new exception phrase (lowercase)
*/
removeCoveredHighlights(phrase) {
const self = this;
const exRegex = new RegExp(`\\b${this.escapeRegEx(phrase)}\\b`, 'gi');
this.$('.promo-highlight').each(function (_, span) {
const parent = span.parentElement;
if (!parent) return;
const parentText = parent.textContent;
// Compute the character offset of the span within its parent
let offset = 0;
for (const node of parent.childNodes) {
if (node === span) break;
offset += node.textContent.length;
}
const spanEnd = offset + span.textContent.length;
// Check whether this specific match is inside an exception match
exRegex.lastIndex = 0;
let match;
let covered = false;
while ((match = exRegex.exec(parentText)) !== null) {
if (offset >= match.index && spanEnd <= match.index + match[0].length) {
covered = true;
break;
}
}
if (covered) {
// Unwrap the span, keeping its text
self.$(span).replaceWith(document.createTextNode(span.textContent));
}
});
}
/**
* @param {string} string
* @return {string} escapedString
*/
escapeRegEx(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
}
$(() => {
// Only start once the tab is actually being looked at. New page patrol
// workflows often open many tabs at once; deferring until the tab is
// visible avoids API and WDQS requests for tabs that may never be viewed.
const startWhenIdle = () => {
const run = () => {
mw.loader.using(['mediawiki.api', 'mediawiki.storage', 'oojs-ui-core', 'oojs-ui-windows', 'oojs-ui-widgets']).then(() => {
new DetectPromo(mw, $).execute();
});
};
// Yield to page rendering; fall back if requestIdleCallback is missing
if (window.requestIdleCallback) {
window.requestIdleCallback(run, { timeout: 3000 });
} else {
setTimeout(run, 0);
}
};
if (document.visibilityState === 'hidden') {
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
document.removeEventListener('visibilitychange', onVisibilityChange);
startWhenIdle();
}
};
document.addEventListener('visibilitychange', onVisibilityChange);
} else {
startWhenIdle();
}
});
// </nowiki>