您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
基于MyAnimeList的非官方API Jikan,获取条目的罗马字标题,并呈现于infobox
// ==UserScript== // @name Bangumi-To Romaji Title // @name:zh-CN 班固米-获取条目罗马字标题 // @version 0.4.1 // @description Retrieve the Romaji title of the subject and display it in the infobox // @author weiduhuo // @namespace https://github.com/weiduhuo/scripts // @match *://bgm.tv/ // @match *://bgm.tv/subject/* // @match *://bangumi.tv/ // @match *://bangumi.tv/subject/* // @match *://chii.in/ // @match *://chii.in/subject/* // @grant none // @license MIT // @description:zh-CN 基于MyAnimeList的非官方API Jikan,获取条目的罗马字标题,并呈现于infobox // ==/UserScript== (function () { 'use strict'; const SCRIPT_NAME = '班固米-罗马名获取组件'; const DB_NAME = "BangumiRomajiTitle"; const DB_VERSION = 1; /** 首页单次查询延迟 */ const HomePageQueryDelay = 300; /** 单次查询返回的结果数量上限 */ const QueryLimit = 10; /** 相关度的最低采用阈值 (含自身) */ const minRelThr = 2.5; /** 相关度的触发再尝阈值 (不含自身) */ const retryRelThr = 7.5; /** 枚举启用状态 */ const EnableState = { /** 全部 */ ALL_ENABLED: 'allEnabled', /** 仅中日 */ ONLY_CJ: 'onlyChinese&Japanese', /** 仅日文 */ ONLY_JAPANESE: 'onlyJapanese', }; /** 启用状态 */ let enableState = EnableState.ONLY_CJ; /** 所支持的条目类型 */ const SubjectType = ['anime']; /** 地区待选标签 */ const RegionTags = [ ['中国', '国产'], ['日本', '日本动画'], ['欧美', '美国', '法国', '英国', '韩国'] // , '俄罗斯', '苏联', '捷克', '马来西亚'], ]; const Region = { null: 0, cn: 1, jp: 2, other: 3, // 用于提前阻断'国产'标签的识别,比如'成龙历险记' parse(value) { for (const [k, v] of Object.entries(this)) if (value === v) return k; return 'null'; } }; /** 媒体类型映射 BGM to MAL */ const PlatformMap = { 'anime': { 'TV': 'tv', // ['tv','tv_special'] 将通过 subTags 尝试区分 '剧场版': 'movie', 'OVA': 'ova', 'WEB': '', // ['ona','music','special','cm','pv',...] 一对多,但 Jikan API 不支持多参数,因此空缺转而全范围搜索 // 'music' 将通过 subTags 尝试区分 '动态漫画': '', }, }; /** 匹配假名 */ const KanaRe = /[\p{sc=Hiragana}\p{sc=Katakana}]/u; /** 匹配汉字 */ const HanRe = /[\p{sc=Han}]/u; /** 匹配汉字与假名 */ const CnJpRe = /[\p{sc=Hiragana}\p{sc=Katakana}\u30FC\u31F0-\u31FF\uFF61-\uFF9F\p{sc=Han}]/u; /** 匹配仅包含拉丁字母与符号 */ // const OnlyLatinRe = /^[\s\u0020-\u00FF\u2000-\u206F\u2150-\u218F\u25A0-\u26FF\u3000-\u301E\uFE30-\uFF65\uFFE0-\uFFEF]+$/; // 上述分别匹配了空白字符、基本拉丁字母及补充、常用标点符号、数字形式、几何图形及杂项符号、CJK常见标点符号、 CJK兼容符号 /** 匹配标题前缀 */ const PrefTitleRe = /^(((劇場版?)?総集|特別|短)編|(映画|劇場|同人)版?)\s*|.?(Official )?Music Video.?/i; /** 匹配标题后缀 */ const SuffTitleRe = /\s[^\s]*(版)$/; // const SuffTitleRe = /\s[^\s]*([\d\u2150-\u218F][^\s]*|版)$/; /** 匹配标题短语 */ const PhrasesRe = /(\d+)|([a-z]{2,}|[\p{sc=Han}]+|[\p{sc=Hiragana}\u30FC]+|[\p{sc=Katakana}\u30FC\u31F0-\u31FF\uFF61-\uFF9F]+)/ug; // 注意 \p{scx=Han} 会匹配 '『』'符号 /** 匹配标题短语分层过滤 */ const PhrasesFilterRe = /^(映画|アニメ|第|st|nd|rd|th|season|章|編)$/; /** 匹配标题内无效符号 (需保留\u3005符号) */ const PunctRe = /[「」]/g; // const PunctRe = /[\u2000-\u206F\u25A0-\u26FF\u3000-\u301E\uFE30-\uFF65\uFFE0-\uFFEF]/g; class Database { static storeName = 'names'; constructor(db) { this._db = db; } static open() { return new Promise((resolve, reject) => { let request = window.indexedDB.open(DB_NAME, DB_VERSION); request.onerror = evt => reject(evt.target.errorCode); request.onsuccess = evt => resolve(new Database(evt.target.result)); request.onupgradeneeded = evt => { this._upgradeDatabase(evt.target.result, evt.oldVersion); }; }); } static _upgradeDatabase(db) { if (!db.objectStoreNames.contains(Database.storeName)) { db.createObjectStore(Database.storeName, { keyPath: 'id' }); } } _getActiveStore(store, mode) { let transaction = this._db.transaction([store], mode); return transaction.objectStore(store); } _querySingleStore(options) { return new Promise((resolve, reject) => { let store = this._getActiveStore(options.store, "readonly"); let request = options.onrequest(store); if (request) { request.onerror = evt => reject(evt.target.error); request.onsuccess = evt => { try { const result = options.onsuccess(evt.target.result); resolve(result); } catch (error) { reject(error); } }; } else { resolve(); } }); } getName(id) { return this._querySingleStore({ store: Database.storeName, onrequest(names) { return names.get(id); }, onsuccess(entry) { return entry?.value ?? null; }, }); } putName(id, value) { let names = this._getActiveStore(Database.storeName, "readwrite"); names.put({id, value}); } putNames(items) { let names = this._getActiveStore(Database.storeName, "readwrite"); for (let {id, value} of items) { names.put({id, value}); } } } class Store { static KEY_PREF = 'jikan-'; static get(id) { const key = `${this.KEY_PREF}${id}`; return sessionStorage.getItem(key); } static async set(id, data, db) { const key = `${this.KEY_PREF}${id}`; sessionStorage.setItem(key, JSON.stringify(data)); const slimData = { 'romaji': data.title, 'english': data.title_english, 'malId': data.mal_id, } db ??= await Database.open(); db.putName(+id, slimData); return slimData; } } /** 条目地区 */ let region; /** 文档对象 (真实/虚拟) */ let doc = document; function main() { const path = location.pathname; if (path === '/') { handerHomePage(); } else if (/^\/subject\/\d+(\/add_related\/person)?$/.test(path)) { handlerSubjectPage(); } } async function handerHomePage() { const style = document.createElement('style'); style.innerHTML = ` #prgManagerHeader #prgManagerMode #switchRomajiTitle { cursor: pointer; font-weight: 700; color: #c3c3c3; } #prgManagerHeader #prgManagerMode #switchRomajiTitle.on { color: #F09199; } #prgManagerHeader #prgManagerMode li a.focus { background: transparent; border-bottom: 2px solid #F09199; } @media (max-width: 400px) { /* 移动端样式 */ #prgManagerHeader ul.categoryTab li a { padding-left: 10px; padding-right: 10px; } } `; document.head.appendChild(style); const prg = document.querySelector('#prgManagerMode'); const aSwitch = document.createElement('a'); aSwitch.id = 'switchRomajiTitle'; aSwitch.title = '罗马字标题'; aSwitch.textContent = 'R'; const liSwitch = document.createElement('li'); liSwitch.appendChild(aSwitch); prg.prepend(liSwitch); const db = await Database.open(); const cloSubs = document.querySelectorAll('#cloumnSubjectInfo div[subject_type="2"] a.textTip[href^="/subject/"]'); const subQueueObj = {}; // 等待查询队列 for (const sub of cloSubs) { const id = sub.dataset.subjectId; const name = await db.getName(+id); if (!name) { subQueueObj[id] = [sub]; continue; } sub.dataset.subjectRomajiTitle = name.romaji; } const prgSubs = document.querySelectorAll('#prgSubjectList > li[subject_type="2"] > a.title'); for (let sub of prgSubs) { const id = sub.dataset.subjectId; const name = await db.getName(+id); if (!name) { subQueueObj[id].push(sub); continue; } sub.dataset.subjectRomajiTitle = name.romaji; } const dock = document.querySelector('#dock'); let onCn = false; aSwitch.addEventListener('click', () => { aSwitch.classList.toggle('on'); const onRomaji = aSwitch.classList.contains('on'); onCn = dock?.textContent.includes('◆'); for (const sub of cloSubs) { toggleName(sub, sub, onRomaji, onCn); } for (let sub of prgSubs) { const span = sub.querySelector("span"); toggleName(sub, span, onRomaji, onCn); } }); if (!Object.keys(subQueueObj).length) return; // 进行查询 const host = location.host; const protocol = location.protocol; const domParser = new DOMParser(); for (const [id, [cloSub, prgSub]] of Object.entries(subQueueObj)) { const res = await fetch(`${protocol}//${host}/subject/${id}`); if (!res.ok) continue; // 失败则跳过 const html = await res.text(); doc = domParser.parseFromString(html, 'text/html'); const data = await handlerSubjectPage(db, id); if (!data) continue; cloSub.dataset.subjectRomajiTitle = data.romaji; prgSub.dataset.subjectRomajiTitle = data.romaji; if (aSwitch.classList.contains('on')) { toggleName(cloSub, cloSub, true, onCn); const span = prgSub.querySelector("span"); toggleName(prgSub, span, true, onCn); } await new Promise(resolve => setTimeout(resolve, HomePageQueryDelay)); } } function toggleName(sub, title, onRomaji, onCn) { if (onRomaji && sub.dataset.subjectRomajiTitle) { title.textContent = sub.dataset.subjectRomajiTitle; } else if (onCn && sub.dataset.subjectNameCn) { title.textContent = sub.dataset.subjectNameCn; } else { title.textContent = sub.dataset.subjectName; } } async function handlerSubjectPage(db = null, id = null) { region = null; const subType = getSubjectType(); if (!SubjectType.includes(subType)) return; // 基于条目地区,判断是否启用功能 const infobox = doc.querySelector('#infobox'); const rawTitle = getSubjectTitle(); const isLatinTitle = !CnJpRe.test(rawTitle); // 判断标题是否仅包含拉丁字母 const subTags = getSubjectTags(); region = includeTargetTag(subTags, ...RegionTags); if (!region) { // 通过标题与角色名进行兜底 (公共标签未完全覆盖) if (KanaRe.test(rawTitle) || charNameHasKana() || KanaRe.test(getSubjectSummary())) { region = Region.jp; } } if (!region || region === Region.other) { if (enableState === EnableState.ALL_ENABLED) addTitle(infobox, region, isLatinTitle, rawTitle); return; } else if (region === Region.cn && enableState === EnableState.ONLY_JAPANESE) { return; } // 添加待定的名称 const titleLis = addTitle(infobox, region, isLatinTitle); // 尝试先通过 sessionStorage 获取已存储的数据 id ??= getSubjectId()[1]; let subData = Store.get(id); // subData = null; if (subData) { subData = JSON.parse(subData); updateTitle(titleLis, [subData.title, subData.title_english]); console.log(`${SCRIPT_NAME}:`, { 'relScore': subData.relScore, 'romaji': subData.title, 'english': subData.title_english, }); if(subData.url) console.log(`${SCRIPT_NAME}:`, subData.url); return; } else if (doc === document) { db = await Database.open(); const name = await db.getName(+id); if (name) { updateTitle(titleLis, [name.romaji, name.english]); } } // 初步解析网页数据 (用于API查询的数据优先) const rawPlatform = getPlatform(); let platform = rawPlatform in PlatformMap[subType] ? PlatformMap[subType][rawPlatform] : ''; if (includeTargetTag(subTags, ['MV'])) { platform = 'music'; } if (platform === 'tv' && includeTargetTag(subTags, ['OVA', 'SP', 'TVSP'], ['MV']) === 1) { platform = 'tv_special'; } const tips = infobox.querySelectorAll('span.tip'); const startDate = getStartDate(infobox, tips, rawPlatform); let episodes, notFirstPart; // 延后解析 // 尝试获取名称 let subs, titles, url, mainTitle, phraseSet; mainTitle = rawTitle.replace(PrefTitleRe, ''); // 修复 Jikan API 首字符匹配权重过大的问题 mainTitle = mainTitle.replace(PunctRe, ' '); // 修复 Jikan API 对诸如「」等符号匹配权重过大的问题 const queryStartDate = startDate ? `${startDate.year - 1}-01-01` : ''; // 保守起见,仅精确到年份,并回退一年 await handlerQuery(platform, queryStartDate); const data_1 = handleData(); if (subData.relScore >= minRelThr ) { updateTitle(titleLis, titles); } if (subData.relScore >= retryRelThr) { return Store.set(id, data_1, db); } // 相关度较低,扩大搜索范围 console.log(`${SCRIPT_NAME}:相关度较低,扩大搜索范围,再次尝试`); const preRelScore = subData.relScore; mainTitle = mainTitle.replace(SuffTitleRe, ''); // 删除如 'シーズン2' 的后缀,只保留主标题 await handlerQuery(); // 由于搜索的平台范围扩大,降低相关度得分 if (platform && subData.relScore) subData.relScore -= 0.5; const data_2 = handleData(); if (subData.relScore >= minRelThr && subData.relScore > preRelScore) { updateTitle(titleLis, titles); return Store.set(id, data_2, db); } else if (preRelScore >= minRelThr && subData.relScore <= preRelScore) { return Store.set(id, data_1, db); } else if (subData.relScore < minRelThr && preRelScore < minRelThr) { updateTitle(titleLis, [null, null]); return; } function handleData() { console.log(`${SCRIPT_NAME}:`, { 'relScore': subData.relScore / 10, 'romaji': titles[0], 'english': titles[1], }); if (url) console.log(`${SCRIPT_NAME}:`, url); return subData; } /** 执行一次查询 */ async function handlerQuery(_platform = '', _startDate ='') { const promise = querySubject(mainTitle, subType, _platform, _startDate); // 同步解析网页数据 (减少忙等API) episodes ??= getEpisodes(infobox, tips); // 判断首集序号是否为开头,防止 BGM 与 MyAnimeList 条目合并不同 notFirstPart ??= !isFirstPart(); phraseSet = getPhraseSet(mainTitle, region); if (!titles) { console.log(`${SCRIPT_NAME}:`, { 'bgmId': id, 'region': Region.parse(region), 'platform': platform, 'episodes': episodes, 'startDate': startDate, 'phraseSet': phraseSet, }); } subs = await promise; if (Array.isArray(subs)) { subData = searchSubject(subs, phraseSet, isLatinTitle, startDate, episodes); } if (subData) { titles = [subData.title, subData.title_english]; url = subData.url; } else { [titles, url] = [[null, null], null]; subData = {'relScore': 0}; } // 对于非首Part的条目的开播时间参考的相关度降低 if (notFirstPart) subData.relScore *= 0.75; titles = titles.map((title) => title?.replace(/\s\((TV|OVA)\)/, '')); // 删除后缀 } } /** * 通过条目原标题获取相关条目数据集 * @param {string} title 原标题 * @param {string} subType 条目类型 * @param {string} platform 媒体类型 * @param {string} startDate 起始日期 'Y-m-d' * @param {number} limit 指定返回的结果数量 * @returns {Promise<string | Array<Object>>} 条目数据集 */ async function querySubject(title, subType, platform, startDate, limit = QueryLimit) { const url = new URL(`https://api.jikan.moe/v4/${subType}`); url.searchParams.set('limit', limit); url.searchParams.set('q', title); if (platform) url.searchParams.set('type', platform); if (startDate) url.searchParams.set('start_date', startDate); try { // console.time(`Jikan API`); const response = await fetch(url); // console.timeEnd(`Jikan API`); const data = await response.json(); const subs = data.data; if (!subs || subs.length === 0) return null; else return subs; } catch (error) { console.error('Jikan API请求失败:', error); return null; } } /** * @param {Array<Object>} subs * @param {Set<string>} phraseSet * @param {boolean} isLatinTitle * @param {{year: number, month: number, day: number} | null} startDate 开播时间 * @param {number | null} episodes 集数 * @returns {{relScore: number, title: string, title_english: string, url: string} | null} * - `relScore`相关度 - 10分值,6分为原标题短语的匹配度,4分为开播时间与集数的匹配度 */ function searchSubject(subs, phraseSet, isLatinTitle, startDate, episodes) { const tmpSubs = []; console.groupCollapsed(`${SCRIPT_NAME}:详情`); subs.forEach((sub, index) => { let similarity; let phraseSet2; // 计算 jaccard 相似度 if (!isLatinTitle) { [similarity, phraseSet2] = jaccardSimilarity(phraseSet, sub.title_japanese); } else { // 当搜索词全为拉丁字母时,同时考虑罗马音标题与英文标题 [similarity, phraseSet2] = [ jaccardSimilarity(phraseSet, sub.title), jaccardSimilarity(phraseSet, sub.title_english) ].sort((a, b) => b[0] - a[0])[0]; // maxSimilarity } sub.relScore = similarity * 6; // 旧方法难以区分 '日常 Eテレ版' 2012-1 在 ['日常' 2011-4, '男子高校生の日常' 2012-1 ] /* const title = (isLatinTitle ? sub.title : sub.title_japanese).toLowerCase(); const simScore = phraseSet.keys().reduce((acc, val) => acc + title.includes(val), 0); sub.relScore = simScore * 6 / phraseSet.size; */ if (sub.relScore) tmpSubs.push(sub); sub.index = index; console.log({ 'index': index, 'simScore': sub.relScore, 'type': sub.type, 'startDate': sub.aired.from ? sub.aired.from.split('T')[0] : null, 'episodes': sub.episodes, 'japanese': sub.title_japanese, 'romanji': sub.title, 'english': sub.title_english, 'phraseSet': phraseSet2, 'url': sub.url, }); }); if (tmpSubs.length) { subs = tmpSubs; subs.sort((a, b) => b.relScore - a.relScore); } else { console.groupEnd(); return null; } if (!startDate) { return getResult(1, subs); } const sameYearSubs = subs.filter(sub => sub.aired.prop.from.year === startDate.year); if (sameYearSubs.length === 0) { return getResult(0, subs); } const sameMonthSubs = sameYearSubs.filter(sub => startDate.month && sub.aired.prop.from.month === startDate.month); if (sameMonthSubs.length === 0) { return getResult(2, sameYearSubs); } if (sameMonthSubs.length === 1) { return getResult(4, sameMonthSubs); } if (!episodes) { return getResult(3, sameMonthSubs); } // 开播时间相同的有多个,寻找集数差异最小的 let minDiff = Infinity, index = 0; sameMonthSubs.forEach((sub, _index) => { const diff = Math.abs(episodes - sub.episodes); if (diff < minDiff) { minDiff = diff; index = _index; } }); sameMonthSubs[index].relScore += 2; return getResult(2, sameMonthSubs); /** 计算最终相似度,并获取结果 */ function getResult(offset, _subs) { _subs.forEach(sub => { sub.relScore += offset }); subs.sort((a, b) => b.relScore - a.relScore); // console.groupEnd(); console.log('sortedByRelScore:', subs.map(sub => [sub.index, sub.relScore])); const data = subs[0]; console.log('result:', data.index); console.groupEnd(); // console.log(sub); // data.title_english ??= 'NULL'; return data; } } /** @returns {[number, Set<string>]} */ function jaccardSimilarity(set1, str2) { const set2 = getPhraseSet(str2?.replace(PrefTitleRe, '')); const intersection = new Set([...set1].filter(x => set2.has(x))); const union = new Set([...set1, ...set2]); return [intersection.size / union.size, set2]; } function getPhraseSet(title) { if (!title) return new Set(); let phrases = title.toLowerCase().match(PhrasesRe); if (region === Region.cn) { // 将国产类别标题中的汉字,再划分 phrases = phrases.map(p => HanRe.test(p) ? [...p] : p).flat(Infinity); } if (!phrases) return new Set(); return new Set(phrases .filter((s) => !PhrasesFilterRe.test(s)) ); } function getSubjectType() { return doc.querySelector('#navMenuNeue .focus').getAttribute('href').split('/')[1]; } function getSubjectTitle() { return doc.querySelector('#headerSubject > h1 > a').textContent.trim(); } function getSubjectTags() { return doc.querySelectorAll('.subject_tag_section > .inner span'); } function getSubjectSummary() { return doc.querySelector('#subject_summary').textContent; } function getSubjectId() { const urlPattern = /^\/(.+)\/(\d+)/; const match = window.location.pathname.match(urlPattern); if (!match) return [null, null]; const [, patternType, subId] = match; return [patternType, subId]; } /** * @param {NodeListOf<Element>} subTags * @param {...Array<string>} targetTypeTags 目标种类的标签 * @returns {number} 种类编号由1开始,0表不存在 */ function includeTargetTag(subTags, ...targetTypeTags) { for (const tag of subTags) { const _tag = tag.textContent.trim(); for (const [type, targetTags] of targetTypeTags.entries()) { if (targetTags.includes(_tag)) return type + 1; } } return 0; } function charNameHasKana() { const chars = doc.querySelectorAll('#browserItemList strong'); for (const char of chars) { if (KanaRe.test(char.innerText)) return true; } return false; } function getPlatform() { const smallTag = doc.querySelector('#headerSubject > h1 > small.grey'); if (smallTag) return smallTag.innerText.trim(); else return ''; } function isFirstPart() { const firstEp = doc.querySelector('#subject_detail > .subject_prg > .prg_list > li:first-child'); if (firstEp) { return ['00', '01'].includes(firstEp.innerText.trim()); } else return true; } /** * @param {HTMLElement} infobox * @param {NodeListOf<HTMLElement>} tips * @returns {number | null} */ function getEpisodes(infobox, tips) { const limit = 10; let ep = null; for (const [i, tip] of tips.entries()) { if (i > limit) return null; if (tip.innerText.trim() === '话数:') { ep = tip; break; } } if (!ep) return null; while (ep.parentElement !== infobox) { ep = ep.parentElement; } const match = ep.textContent.match(/(\d+)/); if (match) return +match[1]; else return null; } /** * @param {HTMLElement} infobox * @param {NodeListOf<HTMLElement>} tips * @param {string} rawPlatform * @returns {{year: number, month: number, day: number} | null} */ function getStartDate(infobox, tips, rawPlatform) { const reParts = [ // 放送开始 '(开始|(?:放送|播出)(?:开始|日期))', // 上映年度 (剧场版) '([上公]映(?!许可))', // 发售日 (OVA) '(发售)', ]; const priorityEnd = 4; const regexMapping = (...ps) => RegExp(ps.map(i => `${reParts[i]}`).join('|')); const regexMap = { 'TV': regexMapping(0, 1, 2), '剧场版': regexMapping(1, 0, 2), 'OVA': regexMapping(2, 0, 1), }; // 按不同类别的优先级匹配 let regex = regexMap[rawPlatform in regexMap ? rawPlatform : 'TV']; let date = null; let preIndex = 10, index; for (const tip of tips) { const match = tip.innerText.match(regex); if (match) { for (const [i, m] of match.slice(1, priorityEnd).entries()) if (m) { index = i + 1; break; } if (index < preIndex) { date = tip; // 仅优先级更高的才可覆盖 preIndex = index; } if (index === 1) break; // 最高优先级会截断任务 } } if (!date) return null; while (date.parentElement !== infobox) { date = date.parentElement; } const dateText = date.textContent; let match; const dataReStr = '(\\d{4})[-/年]?(\\d{1,2})?[-/月]?(\\d{1,2})?[-/日]?'; if (region === Region.jp) { // 优先匹配日本时间 match = dateText.match(RegExp(`日本[^))]*${dataReStr}`)); match ??= dateText.match(RegExp(`${dataReStr}[\\s((]+日本`)); } match ??= dateText.match(RegExp(dataReStr)); if (match) { return { year: +match[1], month: +match[2], day: +match[3] }; } else return null; } /** * @param {HTMLElement} infobox * @param {number} region * @param {boolean} isLatinTitle * @param {string} [title='···'] * @returns {[HTMLElement]} */ function addTitle(infobox, region, isLatinTitle, title = '···') { if (doc !== document) return; const romajiLi = doc.createElement('li'); let romajiTip, englishLi = null; if (!region || region === Region.other) { romajiTip = '索引名'; } else { if (isLatinTitle) { romajiTip = '索引名'; } else if (region === Region.jp) { romajiTip = '罗马名'; } else if (region === Region.cn) { romajiTip = '拼音名'; } englishLi = doc.createElement('li'); englishLi.className = 'folded'; englishLi.innerHTML = `<span class="tip" style="user-select: none">英文名: </span>${title}`; } romajiLi.innerHTML = `<span class="tip" style="user-select: none">${romajiTip}: </span>${title}`; const firstLi = infobox.children[0]; const tip = firstLi.querySelector('span.tip'); const ref = tip && tip.innerText.trim() === '中文名:' ? firstLi.nextSibling : firstLi; infobox.insertBefore(romajiLi, ref); if (englishLi) { infobox.insertBefore(englishLi, ref); return [romajiLi, englishLi]; } else { return [romajiLi]; } } /** * @param {[HTMLElement]} lis * @param {[string]} titles */ function updateTitle(lis, titles) { if (doc !== document) return; lis.forEach((li, index) => { li.childNodes[1].textContent = titles[index] ?? 'NULL'; }) } main(); })();