// ==UserScript==
// @name Luogu Alias And Customize Tags
// @namespace http://tampermonkey.net/
// @version 2025-01-23 15:17
// @description try to take over the world!
// @author normalpcer
// @match https://www.luogu.com.cn/*
// @match https://www.luogu.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=luogu.com.cn
// @grant none
// @license MIT
// ==/UserScript==
/**
* 自定义类名、LocalStorage 项的前缀。
* 为了避免与其他插件重名。
*/
const Prefix = "normalpcer-alias-tags-";
const Cooldown = 1000; // 两次修改的冷却时间(毫秒)
const Colors = new Map(Object.entries({
purple: "#9d3dcf",
red: "#fe4c61",
orange: "#f39c11",
green: "#52c41a",
blue: "#3498db",
gray: "#bfbfbf",
}));
/**
* 用于确定一个用户
*/
class UserIdentifier {
uid; // 0 为无效项
username;
constructor(uid, username) {
this.uid = uid;
this.username = username;
}
static fromUid(uid) {
console.log(`UserIdentifier::fromUid(${uid})`);
let res = uidToIdentifier.get(uid);
if (res !== undefined) {
return new Promise((resolve, _) => {
resolve(res);
});
}
// 否则,直接通过 API 爬取
const APIBase = "/api/user/search?keyword=";
let api = APIBase + uid.toString();
console.log("api: " + api);
let xml = new XMLHttpRequest();
xml.open("GET", api);
return new Promise((resolve, reject) => {
xml.addEventListener("loadend", () => {
console.log("status: " + xml.status);
console.log("response: " + xml.responseText);
if (xml.status === 200) {
let json = JSON.parse(xml.responseText);
let users = json["users"];
if (users.length !== 1) {
reject();
}
else {
let uid = users[0]["uid"];
let username = users[0]["name"];
let identifier = new UserIdentifier(uid, username);
uidToIdentifier.set(uid, identifier);
usernameToIdentifier.set(username, identifier);
resolve(identifier);
}
}
else {
reject();
}
});
xml.send();
});
}
static fromUsername(username) {
console.log(`UserIdentifier::fromUsername(${username})`);
let res = usernameToIdentifier.get(username);
if (res !== undefined) {
return new Promise((resolve) => {
resolve(res);
});
}
const APIBase = "/api/user/search?keyword=";
let api = APIBase + username;
let xml = new XMLHttpRequest();
xml.open("GET", api);
return new Promise((resolve, reject) => {
xml.addEventListener("loadend", () => {
console.log("response: ", xml.responseText);
if (xml.status === 200) {
let json = JSON.parse(xml.responseText);
let users = json["users"];
if (users.length !== 1) {
reject();
}
else {
let uid = users[0]["uid"];
let username = users[0]["name"];
let identifier = new UserIdentifier(uid, username);
uidToIdentifier.set(uid, identifier);
usernameToIdentifier.set(username, identifier);
resolve(identifier);
}
}
else {
reject();
}
});
xml.send();
});
}
/**
* 通过用户给定的字符串,自动判断类型并创建 UserIdentifier 对象。
* @param s 新创建的 UserIdentifier 对象
*/
static fromAny(s) {
// 保证:UID 一定为数字
// 忽略首尾空格,如果是一段完整数字,视为 UID
if (s.trim().match(/^\d+$/)) {
return UserIdentifier.fromUid(parseInt(s));
}
else {
return UserIdentifier.fromUsername(s);
}
}
dump() {
return { uid: this.uid, username: this.username };
}
}
let uidToIdentifier = new Map();
let usernameToIdentifier = new Map();
class UsernameAlias {
id;
newName;
constructor(id, newName) {
this.id = id;
this.newName = newName;
}
/**
* 在当前文档中应用别名。
* 当前采用直接 dfs 全文替换的方式。
*/
apply() {
function dfs(p, alias) {
// 进行一些特判。
/**
* 如果当前为私信页面,那么位于顶栏的用户名直接替换会出现问题。
* 在原名的后面用括号标注别名,并且在修改时删除别名
*/
if (window.location.href.includes("/chat")) {
if (p.classList.contains("title")) {
let a_list = p.querySelectorAll(`a[href*='/user/${alias.id.uid}']`);
if (a_list.length === 1) {
let a = a_list[0];
if (a.children.length !== 1)
return;
let span = a.children[0];
if (!(span instanceof HTMLSpanElement))
return;
if (span.innerText.includes(alias.id.username)) {
if (span.getElementsByClassName(Prefix + "alias").length !== 0)
return;
// 尝试在里面添加一个 span 标注别名
let alias_span = document.createElement("span");
alias_span.classList.add(Prefix + "alias");
alias_span.innerText = `(${alias.newName})`;
span.appendChild(alias_span);
// 在真实名称修改时删除别名
let observer = new MutationObserver(() => {
span.removeChild(alias_span);
observer.disconnect();
});
observer.observe(span, {
characterData: true,
childList: true,
subtree: true,
attributes: true,
});
}
}
return;
}
}
if (p.children.length == 0) {
// 到达叶子节点,进行替换
if (!p.innerText.includes(alias.id.username)) {
return; // 尽量不做修改
}
p.innerText = p.innerText.replaceAll(alias.id.username, alias.newName);
}
else {
for (let element of p.children) {
if (element instanceof HTMLElement) {
dfs(element, alias);
}
}
}
}
dfs(document.body, this);
}
dump() {
return { uid: this.id.uid, newName: this.newName };
}
}
let aliases = new Map();
let cache = new Map(); // 每个 UID 的缓存
class SettingBoxItem {
}
class SettingBoxItemText {
element = null;
placeholder;
constructor(placeholder) {
this.placeholder = placeholder;
}
createElement() {
if (this.element !== null) {
throw "SettingBoxItemText::createElement(): this.element is not null.";
}
let new_element = document.createElement("input");
new_element.placeholder = this.placeholder;
this.element = new_element;
return new_element;
}
getValue() {
if (this.element instanceof HTMLInputElement) {
return this.element.value;
}
else {
throw "SettingBoxItemText::getValue(): this.element is not HTMLInputElement.";
}
}
}
/**
* 位于主页的设置块
*/
class SettingBox {
title;
items = [];
placed = false; // 已经被放置
callback = null; // 确定之后调用的函数
constructor(title) {
this.title = title;
}
/**
* 使用一个新的函数处理用户输入
* @param func 用作处理的函数
*/
handle(func = null) {
this.callback = func;
}
/**
* 尝试在当前文档中放置设置块。
* 如果已经存在,则不会做任何事。
*/
place() {
if (this.placed)
return;
let parent = document.getElementById(Prefix + "boxes-parent");
if (!(parent instanceof HTMLDivElement))
return;
let new_element = document.createElement("div");
new_element.classList.add("lg-article");
// 标题元素
let title_element = document.createElement("h2");
title_element.innerText = this.title;
// "收起"按钮
let fold_button = document.createElement("span");
fold_button.innerText = "[收起]";
fold_button.style.marginLeft = "0.5em";
fold_button.setAttribute("fold", "0");
title_element.appendChild(fold_button);
new_element.appendChild(title_element);
// 依次创建接下来的询问
let queries = document.createElement("div");
for (let x of this.items) {
queries.appendChild(x.createElement());
}
// “确定”按钮
let confirm_button = document.createElement("input");
confirm_button.type = "button";
confirm_button.value = "确定";
confirm_button.classList.add("am-btn", "am-btn-primary", "am-btn-sm");
if (this.callback !== null) {
let callback = this.callback;
let args = this.items;
confirm_button.onclick = () => callback(args);
}
queries.appendChild(confirm_button);
new_element.appendChild(queries);
fold_button.onclick = () => {
if (fold_button.getAttribute("fold") === "0") {
fold_button.innerText = "[展开]";
fold_button.setAttribute("fold", "1");
queries.style.display = "none";
}
else {
fold_button.innerText = "[收起]";
fold_button.setAttribute("fold", "0");
queries.style.display = "block";
}
};
parent.insertBefore(new_element, parent.children[0]); // 插入到开头
this.placed = true;
}
}
/**
* 用户自定义标签
*/
class UserTag {
id;
tag;
constructor(id, tag) {
this.id = id;
this.tag = tag;
}
/**
* 应用一个标签
*/
apply() {
// 寻找所有用户名出现的位置
// 对于页面中的所有超链接,如果链接内容含有 "/user/uid",且为叶子节点,则认为这是一个用户名
let feature = `/user/${this.id.uid}`;
let selector = `a[href*='${feature}']`;
if (window.location.href.includes(feature)) {
selector += ", .user-name > span";
}
let links = document.querySelectorAll(selector);
for (let link of links) {
if (!(link instanceof HTMLElement)) {
console.log("UserTag::apply(): link is not HTMLElement.");
continue;
}
// 已经放置过标签
if (link.parentElement?.getElementsByClassName(Prefix + "customized-tag").length !== 0) {
// console.log("UserTag::apply(): already placed tag.");
continue;
}
if (link.children.length === 1 && link.children[0] instanceof HTMLSpanElement) {
// 特别地,仅有一个 span 是允许的
link = link.children[0];
}
else if (link.children.length !== 0) {
// 否则,要求 link 为叶子节点
// console.log("UserTag::apply(): link is not a leaf node.");
continue;
}
if (!(link instanceof HTMLElement))
continue; // 让 Typescript 认为 link 是 HTMLElement
// console.log(link);
// 获取用户名颜色信息
// - 如果存在颜色属性,直接使用
// - 否则,尝试通过 class 推断颜色
let existsColorStyle = false;
let color = link.style.color;
let colorHex = "";
let colorName = ""; // 通过 class 推断的颜色名
if (color !== "") {
existsColorStyle = true;
// 尝试解析十六进制颜色或者 rgb 颜色
if (color.startsWith("#")) {
colorHex = color;
}
else if (color.startsWith("rgb")) {
let rgb = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
if (rgb !== null) {
// 十进制转为十六进制
const f = (x) => parseInt(x).toString(16).padStart(2, "0");
colorHex = "#" + f(rgb[1]) + f(rgb[2]) + f(rgb[3]);
}
else {
throw "UserTag::apply(): cannot parse color " + color;
}
}
else {
throw "UserTag::apply(): cannot parse color " + color;
}
}
else {
// 尝试从类名推断
let classList = link.classList;
for (let x of classList) {
if (x.startsWith("lg-fg-")) {
colorName = x.substring(6);
break;
}
}
}
if (!existsColorStyle && colorName === "") {
// 尝试使用缓存中的颜色
if (cache.has(this.id.uid)) {
let data = cache.get(this.id.uid)?.get("color");
console.log("data", data);
if (data !== undefined && typeof data === "string") {
colorHex = data;
existsColorStyle = true;
}
}
}
// 完全无法推断,使用缺省值灰色
if (!existsColorStyle && colorName === "") {
let color = Colors.get("gray");
if (color !== undefined) {
colorHex = color;
}
else {
throw "UserTag::apply(): cannot find color gray.";
}
}
console.log(`tag ${this.tag} for ${this.id.uid}. colorHex = ${colorHex}, colorName = ${colorName}`);
// 生成标签
let new_element = document.createElement("span");
new_element.classList.add("lg-bg-" + colorName);
new_element.classList.add("am-badge");
new_element.classList.add("am-radius");
new_element.classList.add(Prefix + "customized-tag");
new_element.innerText = this.tag;
if (!existsColorStyle) {
let color = Colors.get(colorName);
if (color !== undefined) {
colorHex = color;
}
else {
throw "UserTag::apply(): cannot find color " + colorName;
}
}
new_element.style.setProperty("background", colorHex, "important");
new_element.style.setProperty("border-color", colorHex, "important");
new_element.style.setProperty("color", "#fff", "important");
// 特别地,如果 innerText 不以空格结尾,添加 0.3em 的 margin-left
if (!link.innerText.endsWith(" ")) {
new_element.style.marginLeft = "0.3em";
}
// 插入到文档中
if (!(link instanceof HTMLAnchorElement)) {
if (link.parentElement instanceof HTMLAnchorElement) {
link = link.parentElement;
}
}
if (!(link instanceof HTMLElement)) {
throw "UserTag::apply(): link is not HTMLElement before insertion.";
}
let parent = link.parentElement;
if (parent === null) {
throw "UserTag::apply(): cannot find parent.";
}
// 在 link 之后
if (parent.lastChild === link) {
parent.appendChild(new_element);
}
else {
parent.insertBefore(new_element, link.nextSibling);
}
// 在原始元素被修改时删除标签
// 仍然是为了适配私信界面
let observer = new MutationObserver(() => {
observer.disconnect();
new_element.remove();
});
observer.observe(link, {
childList: true,
characterData: true,
subtree: true,
attributes: true,
});
// 在缓存中保存颜色信息
if (!cache.has(this.id.uid))
cache.set(this.id.uid, new Map());
cache.get(this.id.uid)?.set("color", colorHex);
saveCache();
}
}
dump() {
return { uid: this.id.uid, tag: this.tag };
}
}
let tags = new Map();
/**
* 从 localStorage 加载/存储数据
*/
const StorageKeyName = Prefix + "alias_tag_data";
const StorageCacheKeyName = Prefix + "alias_tag_cache";
function load() {
let json = localStorage.getItem(StorageKeyName);
if (json !== null) {
let data = JSON.parse(json);
let _identifiers = data.identifiers;
if (_identifiers instanceof Array) {
for (let x of _identifiers) {
let uid = x.uid;
let username = x.username;
// 判断 uid 为数字,username 为字符串
if (typeof uid === "number" && typeof username === "string") {
let identifier = new UserIdentifier(uid, username);
uidToIdentifier.set(uid, identifier);
usernameToIdentifier.set(username, identifier);
}
}
}
let _aliases = data.aliases;
if (_aliases instanceof Array) {
for (let x of _aliases) {
let uid = x.uid;
let newName = x.newName;
if (typeof uid === "number" && typeof newName === "string") {
let identifier = uidToIdentifier.get(uid);
if (identifier !== undefined) {
aliases.set(identifier, new UsernameAlias(identifier, newName));
}
}
}
}
let _tags = data.tags;
if (_tags instanceof Array) {
for (let x of _tags) {
let uid = x.uid;
let tag = x.tag;
if (typeof uid === "number" && typeof tag === "string") {
let identifier = uidToIdentifier.get(uid);
if (identifier !== undefined) {
tags.set(identifier, new UserTag(identifier, tag));
}
}
}
}
}
let json_cache = localStorage.getItem(StorageCacheKeyName);
if (json_cache !== null) {
let _cache = JSON.parse(json_cache);
if (_cache instanceof Array) {
for (let item of _cache) {
if (item instanceof Array && item.length === 2) {
let [uid, data] = item;
if (typeof uid === "number" && typeof data === "object") {
let data_map = new Map();
for (let [key, value] of Object.entries(data)) {
if (typeof key === "string") {
data_map.set(key, value);
}
}
cache.set(uid, data_map);
}
}
}
}
}
}
function save() {
let data = {
identifiers: Array.from(uidToIdentifier.values()).map((x) => x.dump()),
aliases: Array.from(aliases.values()).map((x) => x.dump()),
tags: Array.from(tags.values()).map((x) => x.dump()),
};
let json = JSON.stringify(data);
localStorage.setItem(StorageKeyName, json);
}
function saveCache() {
let cache_data = Array.from(cache.entries()).map(([uid, data]) => [
uid,
Object.fromEntries(data.entries()),
]);
let json_cache = JSON.stringify(cache_data);
localStorage.setItem(StorageCacheKeyName, json_cache);
}
(function () {
"use strict";
load();
//
// Your code here...
// “添加别名”设置块
let alias_box = new SettingBox("添加别名");
alias_box.items.push(new SettingBoxItemText("UID/用户名"));
alias_box.items.push(new SettingBoxItemText("别名"));
alias_box.handle((arr) => {
let uid_or_name = arr[0].getValue();
let alias = arr[1].getValue();
console.log(`${uid_or_name} -> ${alias}?`);
UserIdentifier.fromAny(uid_or_name).then((identifier) => {
console.log(`${identifier.uid} ${identifier.username} -> ${alias}`);
aliases.set(identifier, new UsernameAlias(identifier, alias));
alert(`为 ${identifier.username} (${identifier.uid}) 添加别名 ${alias}`);
save();
run();
});
});
// “添加标签”设置块
let tag_box = new SettingBox("添加标签");
tag_box.items.push(new SettingBoxItemText("UID/用户名"));
tag_box.items.push(new SettingBoxItemText("标签"));
tag_box.handle((arr) => {
let uid_or_name = arr[0].getValue();
let tag = arr[1].getValue();
UserIdentifier.fromAny(uid_or_name).then((identifier) => {
console.log(`${identifier.uid} ${identifier.username} -> tag ${tag}`);
tags.set(identifier, new UserTag(identifier, tag));
alert(`为 ${identifier.username} (${identifier.uid}) 添加标签 ${tag}`);
save();
run();
});
save();
});
// “还原用户”设置块
let restore_box = new SettingBox("还原用户");
restore_box.items.push(new SettingBoxItemText("UID/用户名"));
restore_box.handle((arr) => {
let uid_or_name = arr[0].getValue();
UserIdentifier.fromAny(uid_or_name).then((identifier) => {
let deleted_item = [];
if (aliases.has(identifier)) {
aliases.delete(identifier);
deleted_item.push("别名");
}
if (tags.has(identifier)) {
tags.delete(identifier);
deleted_item.push("标签");
}
if (deleted_item.length > 0) {
alert(`已删除 ${identifier.username} (${identifier.uid}) 的 ${deleted_item.join("和")}(刷新网页生效)`);
}
save();
});
});
console.log("Luogu Alias And Customize Tags");
// let prev_time = Date.now();
function run() {
// if (Date.now() - prev_time < Cooldown) return;
try {
restore_box.place();
tag_box.place();
alias_box.place();
}
catch (_) { }
for (let [_, alias] of aliases) {
alias.apply();
}
for (let [_, tag] of tags) {
tag.apply();
}
}
window.onload = () => {
// 创建 boxes-parent
function create_boxes_parent() {
let boxes_grand_parent = document.querySelectorAll(".am-g .am-u-lg-3");
if (boxes_grand_parent.length !== 1)
throw "cannot place boxes-parent";
let boxes_parent = document.createElement("div");
boxes_parent.id = Prefix + "boxes-parent";
boxes_grand_parent[0].insertBefore(boxes_parent, boxes_grand_parent[0].firstChild);
}
try {
create_boxes_parent();
}
catch (err) {
console.log("create_boxes_parent: ", err);
}
// 加入 style 标签
let new_style = document.createElement("style");
new_style.innerHTML = `
span.${Prefix}customized-tag {
display: inline-block;
color: #fff;
padding: 0.25em 0.625em;
font-size: min(0.8em, 1.3rem);
font-weight: 800;
/* margin-left: 0.3em; */
border-radius: 2px;
}`;
// console.log(new_style);
new_style.id = Prefix + "customized-tags-style";
document.head.appendChild(new_style);
/*
const observer = new MutationObserver(run);
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
});
setTimeout(run, Cooldown);
*/
setInterval(run, Cooldown);
};
})();