利用者:Kohaku2005/DisplayUsersGroups.js
表示
お知らせ: 保存した後、ブラウザのキャッシュをクリアしてページを再読み込みする必要があります。
多くの Windows や Linux のブラウザ
- Ctrl を押しながら F5 を押す。
Mac における Safari
Mac における Chrome や Firefox
- ⌘ Cmd と ⇧ Shift を押しながら R を押す。
詳細についてはWikipedia:キャッシュを消すをご覧ください。
// @ts-check
/**
* @typedef {{
* get: (args: Record<string, any>) => Promise<any>
* }} Api
*/
/**
* @typedef {{
* Api: new () => Api,
* util: any
* }} Mw
*/
/** @typedef {"*" | "bot" | "interface-admin" | "sysop" | "extendedconfirmed" | "confirmed" | "autoconfirmed" | "user" | "bureaucrat" | "eliminator" | "abusefilter" | "rollbacker" | "steward" | "accountcreator" | "checkuser" | "import" | "transwiki" | "push-subscription-manager" | "ipblock-exempt" | "suppress"} WpGroups */
/**
* @typedef {{
* name: string
* } & (({
* userid: number,
* groups: WpGroups[],
* missing?: false,
* invalid?: false
* } & ({
* blockanononly: boolean,
* blockedby: string,
* blockedbyid: number,
* blockedtimestamp: string,
* blockedtimestampformatted: string,
* blockexpiry: string,
* blockid: number,
* blocknocreate: boolean,
* blockpartial: boolean,
* blockreason: string
* } | {
* blockanononly: undefined
* blockedby: undefined
* blockedbyid: undefined
* blockedtimestamp: undefined
* blockedtimestampformatted: undefined
* blockexpiry: undefined
* blockid: undefined
* blocknocreate: undefined
* blockpartial: undefined
* blockreason: undefined
* })) | {
* missing: true,
* invalid?: false
* } | {
* invalid: true,
* missing?: false
* })} UsersApiResult
*/
/**
* @typedef {{
* user: string,
* expiry: string,
* reason: string
* }} BlocksApiResult
*/
/**
* @typedef {{
* query: {
* users?: UsersApiResult[],
* blocks?: BlocksApiResult[]
* };
* }} UserApiResult
*/
/**
* @typedef {{
* parse: {
* text: string
* }
* }} ParseApiResult
*/
/**
* @typedef {{
* query: {
* pages: {
* pageid: number,
* ns: number,
* title: string
* }[],
* redirects?: {
* from: string,
* to: string
* }[]
* }
* }} RedirectsApiResult
*/
/**
* @typedef {{
* skip?: WpGroups[],
* labels: {
* group: WpGroups,
* label: string,
* text: string
* }[]
* }[]} LabelGroups
*/
/**
* @typedef {{
* label: string;
* isWikitext?: boolean;
* text: string;
* }} Label
*/
(/**
*
* @param {Mw} mw
*/
async (mw) => {
//#region user link query
const USER_LINK_QUERY = '#content a:is([href^="/wiki/%E5%88%A9%E7%94%A8%E8%80%85:"], [href^="/wiki/利用者:"], [href^="/w/index.php"], [href^="/wiki/%E7%89%B9%E5%88%A5:%E6%8A%95%E7%A8%BF%E8%A8%98%E9%8C%B2/"], [href^="/wiki/特別:投稿記録/"])';
//#endregion
//#region user name regexp
const USER_NAME_REGEXP = /(?:\/w\/index.php(?:\?|.*&)title=|\/wiki\/)(?:(?:%E5%88%A9%E7%94%A8%E8%80%85|利用者):|(%E7%89%B9%E5%88%A5:%E6%8A%95%E7%A8%BF%E8%A8%98%E9%8C%B2|特別:投稿記録)\/)([^?&#]*)/;
//#endregion
//#region ip regexp
const IP_REGEXP = /^((([0-9a-fA-F]{1,4}:){7}([0-9a-fA-F]{1,4}|:))|(([0-9a-fA-F]{1,4}:){6}(:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-fA-F]{1,4}:){5}(((:[0-9a-fA-F]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-fA-F]{1,4}:){4}(((:[0-9a-fA-F]{1,4}){1,3})|((:[0-9a-fA-F]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){3}(((:[0-9a-fA-F]{1,4}){1,4})|((:[0-9a-fA-F]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){2}(((:[0-9a-fA-F]{1,4}){1,5})|((:[0-9a-fA-F]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){1}(((:[0-9a-fA-F]{1,4}){1,6})|((:[0-9a-fA-F]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9a-fA-F]{1,4}){1,7})|((:[0-9a-fA-F]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])$/;
//#endregion
//#region label groups
/**
* @type {LabelGroups}
*/
const LABEL_GROUPS = [
{
skip: ["bot", "interface-admin", "sysop"],
labels: [
{
group: "extendedconfirmed",
label: "拡",
text: "拡張承認された利用者"
},
{
group: "confirmed",
label: "承",
text: "承認された利用者"
},
{
group: "autoconfirmed",
label: "自",
text: "自動承認された利用者"
},
{
group: "user",
label: "新",
text: "新規利用者"
}
]
},
{
labels: [
{
group: "sysop",
label: "管",
text: "管理者"
}
]
},
{
labels: [
{
group: "bureaucrat",
label: "ビ",
text: "ビューロクラット"
}
]
},
{
skip: ["sysop"],
labels: [
{
group: "eliminator",
label: "削",
text: "削除者"
}
]
},
{
labels: [
{
group: "interface-admin",
label: "イ",
text: "インターフェース管理者"
}
]
},
{
labels: [
{
group: "abusefilter",
label: "編",
text: "編集フィルター編集者"
}
]
},
{
skip: ["sysop"],
labels: [
{
group: "rollbacker",
label: "巻",
text: "巻き戻し者"
}
]
},
{
labels: [
{
group: "bot",
label: "ボ",
text: "ボット"
}
]
},
{
skip: ["sysop", "steward", "bot", "bureaucrat", "eliminator"],
labels: [
{
group: "accountcreator",
label: "ア",
text: "アカウント作成者"
}
]
},
{
labels: [
{
group: "checkuser",
label: "チ",
text: "チェックユーザー"
}
]
},
{
labels: [
{
group: "suppress",
label: "オ",
text: "オーバーサイト"
}
]
},
{
labels: [
{
group: "steward",
label: "ス",
text: "スチュワード"
}
]
},
{
labels: [
{
group: "import",
label: "ト",
text: "インポート担当者"
}
]
},
{
skip: ["sysop", "import"],
labels: [
{
group: "transwiki",
label: "ウ",
text: "ウィキ間インポート担当者"
}
]
},
{
labels: [
{
group: "push-subscription-manager",
label: "プ",
text: "プッシュ・サブスクリプション取扱者"
}
]
},
{
labels: [
{
group: "ipblock-exempt",
label: "除",
text: "IPブロック適用除外者"
}
]
}
];
//#endregion
const api = new mw.Api();
/**
* @template {any} T
* @param {T | undefined | null} value
* @returns {T}
*/
const nonNull = (value) => /** @type {T} */(/** @type {unknown} */(value));
/**
* @template {any} T
* @param {T[]} array
* @returns {T[][]}
*/
// @ts-ignore
const split50 = (array) => array.reduce((a, b, i) => i % 50 == 0 ? [...a, [b]] : [...a.slice(0, -1), [...a[a.length - 1], b]], []);
/**
*
* @param {Date} date
* @returns
*/
const formatDate = (date) => {
if (Number.isNaN(date.getTime())) return "期限不明";
const year = date.getFullYear();
const month = date.getMonth() + 1;
const dateN = date.getDate();
const day = ["日", "月", "火", "水", "木", "金", "土"][date.getDay()];
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}年${month}月${dateN}日 (${day}) ${hours}:${minutes}`;
};
/**
* @type {WeakMap<Element, Element>}
*/
const labelElements = new WeakMap();
/**
* @type {Map<string, Label[]>}
*/
const userLabels = new Map();
/**
*
* @param {Label[]} labels
* @param {string} user
* @param {Element[]} elements
* @param {boolean} isMissing
*/
const setLabel = (labels, user, elements, isMissing = false) => {
userLabels.set(user, labels);
/**
* @type {WeakMap<Label, string>}
*/
const wikitexts = new WeakMap();
for (const element of elements) {
const labelElement = document.createElement("sub");
labelElement.style.margin = "0 3px";
labelElement.style.padding = "1px";
labelElement.style.color = "black";
labelElement.style.fontSize = "70%";
labelElement.style.fontStyle = "normal";
labelElement.style.border = ".9px solid black";
labelElement.style.display = "inline-flex";
labelElement.style.gap = "3px";
labelElement.style.cursor = "default";
for (const labelItem of labels) {
const { label = "", isWikitext = false, text = "" } = labelItem;
const groupElement = document.createElement("span");
groupElement.setAttribute("title", text);
groupElement.innerText = label;
if (isWikitext) {
const overEvent = async () => {
const startOverEvent = () => {
groupElement.addEventListener("pointerover", overEvent, {
once: true
});
groupElement.setAttribute("title", text);
};
if (!wikitexts.has(labelItem)) {
try {
await api.get({
action: "parse",
format: "json",
text,
prop: "text",
disablelimitreport: 1,
formatversion: "2"
}).then((/** @type {ParseApiResult} */response) => {
wikitexts.set(labelItem, response.parse.text);
});
} catch {
startOverEvent();
return;
}
}
const wikitext = wikitexts.get(labelItem);
if (!wikitext) {
startOverEvent();
return;
}
groupElement.removeAttribute("title");
const tooltipElement = document.createElement("div");
{
// To get the size. Remove later.
tooltipElement.style.opacity = "0";
tooltipElement.style.whiteSpace = "nowrap";
tooltipElement.style.overflowX = "hidden";
tooltipElement.style.position = "absolute";
tooltipElement.style.padding = "5px";
tooltipElement.style.maxWidth = "300px";
tooltipElement.style.fontSize = ".75rem";
tooltipElement.style.boxShadow = "black 0px 0px 5px 0px";
tooltipElement.style.backgroundColor = "white";
tooltipElement.innerHTML = wikitext;
const { right, bottom } = groupElement.getBoundingClientRect();
const { scrollX, scrollY } = window;
const x = scrollX + right;
const y = scrollY + bottom;
tooltipElement.style.top = `${y}px`;
tooltipElement.style.left = `${x}px`;
document.body.append(tooltipElement);
setTimeout(() => {
const { clientWidth: windowWidth } = document.documentElement;
const { clientWidth: tooltipWidth } = tooltipElement;
if (x + tooltipWidth + 10 > windowWidth) {
tooltipElement.style.left = "";
tooltipElement.style.right = "10px";
}
tooltipElement.style.whiteSpace = "";
tooltipElement.style.opacity = "";
tooltipElement.style.overflowX = "";
});
}
/**
* @type {number | null}
*/
let timeoutId;
const resetLeaveTimeout = () => {
if (timeoutId != null) return;
clearLeaveTimeout();
timeoutId = window.setTimeout(() => {
tooltipElement.remove();
window.removeEventListener("pointermove", moveEvent);
startOverEvent();
}, 300);
};
const clearLeaveTimeout = () => {
if (timeoutId != null) clearTimeout(timeoutId);
timeoutId = null;
};
/**
*
* @param {PointerEvent} e
*/
const moveEvent = (e) => {
const { target } = e;
if (!target) return;
if (!(target instanceof Node)) return;
if (groupElement.contains(target) || tooltipElement.contains(target)) {
clearLeaveTimeout();
return;
}
resetLeaveTimeout();
};
setTimeout(() => {
window.addEventListener("pointermove", moveEvent);
});
};
groupElement.addEventListener("pointerover", overEvent, {
once: true
});
}
labelElement.append(groupElement);
}
if (!isMissing) {
[["会", "会話ページに移動", "User_talk:"], ["記", "投稿記録に移動", "Special:Contributions/"]].forEach(v => {
const contributionsElement = document.createElement("a");
contributionsElement.href = mw.util.getUrl(`${v[2]}${user}`);
contributionsElement.innerText = v[0];
contributionsElement.title = v[1];
contributionsElement.style.color = "#0645ad";
contributionsElement.style.fontWeight = "bold";
labelElement.append(contributionsElement);
});
}
element.after(labelElement);
labelElements.set(element, labelElement);
}
};
/**
* @type {Record<"user" | "ip", Map<string, Set<Element>>>}
*/
const untreatedUsers = {
user: new Map(),
ip: new Map()
};
const setUsers = () => {
const [users, ipUsers] = [untreatedUsers.user, untreatedUsers.ip].map((users) => {
/**
* @type {Map<string, Element[]>}
*/
const usersMap = new Map();
for (const [name, elements] of users) {
const filteredElements = [...elements.values()].filter((element) => {
return labelElements.get(element) !== element.nextElementSibling;
});
if (!filteredElements.length) {
usersMap.delete(name);
continue;
}
if (userLabels.has(name)) {
const labels = nonNull(userLabels.get(name));
setLabel(labels, name, filteredElements);
usersMap.delete(name);
continue;
}
usersMap.set(name, filteredElements);
}
return usersMap;
});
untreatedUsers.user.clear();
untreatedUsers.ip.clear();
/**
* @param {Map<string, Element[]>} users
* @param {Map<string, Element[]> | null} ipUsers
* @param {number} depth
*/
const setUsersChild = async (
users,
ipUsers,
depth = 0
) => {
/**
* @type {string[]}
*/
const missingUsers = [];
const userNames = [...users.keys()];
const ipAddresses = ipUsers ? [...ipUsers.keys()] : [];
const splittedUsers = split50(userNames);
const splittedIpAddresses = split50(ipAddresses);
const maxLength = Math.max(splittedUsers.length, splittedIpAddresses.length);
for (let i = 0; i < maxLength; i++) {
const users50 = splittedUsers[i] ?? [];
const ipAddresses50 = splittedIpAddresses[i] ?? [];
const parameters = (() => {
/**
* @type {Record<string, any>}
*/
const parameters = {
"action": "query",
"format": "json",
"formatversion": "2"
};
/**
* @type {string[]}
*/
const list = [];
if (ipAddresses50.length) {
list.push("blocks");
parameters.bkusers = ipAddresses50.join("|");
parameters.bkprop = "user|expiry|reason";
parameters.bklimit = "max";
}
if (users50.length) {
list.push("users");
parameters.usprop = "groups|blockinfo";
parameters.ususers = users50.join("|");
}
parameters.list = list;
return parameters;
})();
await api.get(parameters).then((/** @type {UserApiResult} */result) => {
/**
* @type {Map<string, BlocksApiResult>}
*/
const blocks = new Map();
if (result.query.blocks) {
for (const block of result.query.blocks) {
blocks.set(block.user, block);
}
}
for (const ipAddress of ipAddresses50) {
const elements = nonNull(nonNull(ipUsers).get(ipAddress));
/**
* @type {Label[]}
*/
const labels = [];
labels.push({
label: "IP",
text: "IP利用者"
});
if (blocks.has(ipAddress)) {
const block = nonNull(blocks.get(ipAddress));
const expiry = "期限:" + (block.expiry === "infinity" ?
"無期限" :
formatDate(new Date(block.expiry))
);
const reason = (() => {
if (block.reason) {
return `理由:${block.reason}`;
}
return;
})();
const brackets = /** @type {string[]} */([expiry, reason].filter(Boolean)).join("、");
labels.push({
label: "ブ",
isWikitext: true,
text: `投稿ブロック(${brackets})`
});
}
setLabel(labels, ipAddress, elements);
}
if (result.query.users) {
for (const user of result.query.users) {
const { name } = user;
const elements = users.get(name);
if (!elements) continue;
if (user.missing) {
if (depth >= 10) {
setLabel([{
label: "?",
text: "存在しない利用者(リダイレクト上限)"
}], name, elements, true);
} else {
missingUsers.push(name);
}
continue;
}
if (user.invalid) {
setLabel([{
label: "?",
text: "存在しない利用者(無効な利用者名)"
}], name, elements, true);
continue;
}
const { groups } = user;
if (!groups) continue;
/**
* @type {Label[]}
*/
const labels = [];
if (depth > 0) {
labels.push({
label: `リ${depth}`,
text: `リダイレクト(${depth}回)`
});
}
for (const { skip, labels: targetLabels } of LABEL_GROUPS) {
if (skip && skip.some(item => groups.includes(item))) {
continue;
}
for (const label of targetLabels) {
if (groups.includes(label.group)) {
labels.push({
label: label.label,
text: label.text
});
break;
}
}
}
if (user.blockid) {
const expiry = "期限:" + (user.blockexpiry === "infinite" ?
"無期限" :
formatDate(new Date(user.blockexpiry))
);
const reason = (() => {
if (user.blockreason) {
return `理由:${user.blockreason}`;
}
return;
})();
const brackets = /** @type {string[]} */([expiry, reason].filter(Boolean)).join("、");
labels.push({
label: "ブ",
isWikitext: true,
text: `投稿ブロック(${brackets})`
});
}
setLabel(labels, name, elements);
}
}
});
}
if (missingUsers.length) {
/**
* @type {Map<string, Element[]>}
*/
const redirectUsers = new Map();
const missingUsers50 = split50(missingUsers);
for (const missingUsers of missingUsers50) {
const missingUsersSet = new Set(missingUsers);
/**
* @type {RedirectsApiResult}
*/
const result = await api.get({
"action": "query",
"format": "json",
"titles": missingUsers.map(user => `利用者:${user}`).join("|"),
"redirects": true,
"formatversion": "2",
});
if (result.query.redirects) {
for (const redirect of result.query.redirects) {
if (!redirect.from.startsWith("利用者:")) continue;
if (!redirect.to.startsWith("利用者:")) continue;
const from = redirect.from.slice(4);
const to = redirect.to.slice(4);
if (!missingUsersSet.has(from)) continue;
if (!users.has(from)) continue;
missingUsersSet.delete(from);
redirectUsers.set(to, nonNull(users.get(from)));
}
}
for (const missingUser of missingUsersSet) {
if (!users.has(missingUser)) continue;
setLabel([{
label: "?",
text: "存在しない利用者"
}], missingUser, nonNull(users.get(missingUser)), true);
}
}
await setUsersChild(redirectUsers, null, depth + 1);
}
};
return setUsersChild(users, ipUsers);
}
/**
* @param {Document | Element} elements
*/
const addUsers = (elements) => {
for (const userElement of elements.querySelectorAll(USER_LINK_QUERY)) {
const { href } = /** @type { HTMLAnchorElement } */(userElement);
if (!href) continue;
const matchUser = href.match(USER_NAME_REGEXP);
if (!matchUser) continue;
let [, isContributions, user] = matchUser;
const isIpUser = IP_REGEXP.test(user);
if (user.includes("/")) continue;
if (isContributions && !isIpUser) continue;
const { searchParams } = new URL(href, location.href);
if (searchParams.has("oldid") || searchParams.has("diff") || searchParams.has("target")) continue;
if (searchParams.has("action") && searchParams.get("action") !== "view") {
if (searchParams.get("action") !== "edit") continue;
if (searchParams.get("redlink") !== "1") continue;
}
user = decodeURIComponent(user);
// @ts-ignore
user = user.replaceAll("_", " ");
user = user.trim();
user = user.slice(0, 1).toUpperCase() + user.slice(1);
const targetUsers = isIpUser ? untreatedUsers.ip : untreatedUsers.user;
const targetUser = (() => {
if (!targetUsers.has(user)) {
targetUsers.set(user, new Set());
}
return nonNull(targetUsers.get(user));
})();
targetUser.add(userElement);
}
};
new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type !== "childList") continue;
if (!mutation.addedNodes.length) continue;
for (const node of mutation.addedNodes) {
if (!(node instanceof Element)) continue;
addUsers(node);
}
}
}).observe(document.body, { childList: true, subtree: true });
addUsers(document);
setUsers();
setInterval(() => {
setUsers();
}, 1000);
// @ts-ignore
})(mw);