Greasy Fork

Greasy Fork is available in English.

班固米-条目职位自定义排序与折叠

对[动画]条目的制作人员信息进行职位的自定义排序与折叠,可在[设置-隐私]页面进行相关设置

当前为 2024-11-16 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         班固米-条目职位自定义排序与折叠
// @namespace    https://github.com/weiduhuo/scripts
// @version      1.2.1-1.0
// @description  对[动画]条目的制作人员信息进行职位的自定义排序与折叠,可在[设置-隐私]页面进行相关设置
// @author       weiduhuo
// @match        *://bgm.tv/subject/*
// @match        *://bgm.tv/settings/privacy
// @match        *://bangumi.tv/subject/*
// @match        *://bangumi.tv/settings/privacy
// @match        *://chii.in/subject/*
// @match        *://chii.in/settings/privacy
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';
  const SCRIPT_NAME = '班固米-职位排序组件';
  const TARGET_SUBJECT_TYPES = ['anime']; // ['anime', 'book', 'music', 'game', 'real'];

  /* 职位的排序列表 jobOrder 与默认折叠的职位 foldableJobs 的合并信息
   * 基本类型:
      type = [Job | [boolean | Job, ...Job[]]]
      Job = string | RegExp
   * 其中 boolean 表示子序列内的职位是否默认折叠,缺损值为 False,需位于子序列的首位才有意义
   * (下文 `,,` 表示插入 null 元素,用于输出格式化文本时标记换行 )
   */
  const staffMapList = [,
    "中文名", "类型", "适合年龄", /地区/, "语言", "对白", "话数", "总话数", [true, "季数"], ,
    "放送开始", "开始", "放送星期", "放送时间", "上映年度", /上映/, "发售日", "片长", /片长/, ,
    ,
    "原作", "原案", "人物原案", "原作插图", [true, "原作协力"], ,
    "团长", "总导演", "导演", "副导演", "执行导演", "主任导演", "联合导演", "系列监督", ,
    "系列构成", "脚本", "编剧", [true, /脚本|内容|故事|文艺|主笔/], ,
    "分镜", "OP・ED 分镜", "主演出", "演出", [true, "演出助理"], ,
    "人物设定", ,
    ,
    "总作画监督", [false, "作画监督"], [true, "作画监督助理"], "动作作画监督", "机械作画监督", "特效作画监督", /.*作画.*(监|导)/, ,
    "主动画师", "主要动画师", [true, "构图"], [true, "原画"], [true, "第二原画", "补间动画"], "数码绘图", /(原画|动画|動画)(?!制|检查)/, ,
    "动画检查", [true, /动画检查/], ,
    ,
    "设定", "背景设定", "道具设计", /(?<!色彩|美术|美術)(设|設)(?!.*制)/, /Design|デザイン|Motion Graphic|モーショングラフィック/, ,
    "色彩设计", [false, "色彩指定", "色指定"], [true, "上色", /(?<!角)色/], ,
    "美术监督", /(美术|美術).*导演/, [false, "美术设计"], "概念美术", "视觉概念", "概念艺术", ,
    [false, "背景美术"], [true, /景/], /ART WORK|美术|美術|艺术|工艺|创意|绘制/, ,
    ,
    /CG.*导演/, "3DCG", [false, /2D|3D|CG|コンピュータ/], "模型监督", "绑骨监督", [/建模|模型|动作|表情|骨/], ,
    "摄影监督", "副摄影监督", "后期监督", [true, "摄影", "特效", "照明", "特技", /摄影|モニター|特效|动效|合成|拍|Effect|技术/], ,
    "現像", /タイトル|标题|字幕/, ,
    [false, "剪辑", "编集"], [true, /(?<!音.*)剪辑/], "编辑", [true, "场记"], ,
    "监修", /监修|監修/, "顾问", /顾问/, ,
    ,
    "音响监督", [true, "音响", "音响制作", "音效", "拟音", /音响/], "录音", [true, "录音助理", "混声", /录音|声/], ,
    "配音", "主演", "キャスティング", [true, /配音|((?<!歌)演出)/], ,
    "音乐", "音乐制作", [true, "音乐制作人", "音乐助理"], [true, /音/], ,
    "主题歌演出", [true, "主题歌作词", "主题歌作曲", "主题歌编曲"], "插入歌演出", ,
    [true, "插入歌作词", "插入歌作曲", "插入歌编曲"], [true, "选曲"], /曲|歌/, ,
    ,
    "企画", [true, "企画协力"], "企划制作人", /企画|企划|出品|策划/, "监制", /监制/, ,
    "执行制片人", "总制片人", "制片人", "总制片", "制片", [true, "副制片人", "联合制片人", "助理制片人", /(?<!动画|動画)制片/], ,
    [true, /行政|审/, "责任编辑"], [true, /法务/], [true, "宣传", /宣传|宣伝|広報/], /市场|运营|衍生/, ,
    "制作", "製作", [true, "制作著作"], ,
    "动画制片人", [true, "制作管理", "制作统筹", "制作主任", "制作助理"], ,
    [true, "设定制作"], [true, "计划管理", "制作进行", "制作进行协力"], ,
    "制作协调", "制作协力", "制作助手", "协力", /协力|協力/, [true, /取材/], [true, "特别鸣谢", /鸣谢|Thanks/], ,
    ,
    "动画制作", [true, /制作|製作/], ,
    "别名", /.+名$/, ,
    "发行", "官方网站", "在线播放平台", "链接", "播放电视台", "其他电视台", "配给", /配(?!音)|連載|番組|版|播放(?!结束)/, ,
    "播放结束", "结束", ,
    ,
    "其他", /其他/, ,
    "===此处插入未被匹配的职位===", ,
    "Copyright",
  ];
  // 职位的排序列表
  const jobOrder = [];
  // 默认折叠的职位
  const foldableJobs = [];
  // 默认值的格式化文本的缓存
  let mapListTextBuffer = null;
  // 是否对职位信息进行了折叠 (依此判断 `更多制作人员` 开关的必要性)
  let hasFolded = false;

  if (location.pathname.startsWith('/subject/')) {
    trySortStaff();
  }
  else if (location.pathname === '/settings/privacy') {
    const ui = buildSettingUI({ id: 'staff-sorting' });
    document.getElementById('columnA').appendChild(ui);
    if (location.hash.slice(1) === 'staff-sorting') {
      ui.scrollIntoView({ behavior: 'smooth' });
    }
  }

  function trySortStaff() {
    if (isTargetSubjectType()) {
      loadMapList();
      if (staffMapList.length) {
        sortStaff();
      } else {
        addFoldableTag();
        console.log(`${SCRIPT_NAME}:自定义 staffMapList 数值为空,不进行职位排序`)
      }
      changeToToggleButton();
    }
  }

  function sortStaff() {
    // 职位信息字典
    const staffDict = getStaffDict();

    // 清空原始的 staff 列表
    const ul = document.querySelector('#infobox');
    ul.innerHTML = '';

    // 未能匹配职位的待插入位置
    let liAfterIntsert = null;
    let insterTag = false;
    let insertFold = false;

    // 按照预定顺序添加到 DOM
    jobOrder.forEach(item => {
      const matchingRoles = [];
      // 1.正则匹配
      if (item instanceof RegExp) {
        matchingRoles.push(...Object.keys(staffDict).filter(key => item.test(key)));
        if (matchingRoles.length) {
          console.log(`${SCRIPT_NAME}:使用正则表达式 "${item}" 成功匹配 \{${matchingRoles}\}`);
        } else return;
      } else if (typeof item === 'string') {
        // 2.键值匹配
        if (item && item in staffDict) {
          matchingRoles.push(item);
        // 3.特殊关键字处理
        } else if (item.startsWith('==')) {
          // 激活待插入位置
          insterTag = true;
          insertFold = foldableJobs.includes(item);
        } else return
      // 4.其余情形均忽略(且对于意外类型不报错)
      } else return;

      // 添加职位,并判断是否默认折叠
      matchingRoles.forEach(role => {
        const li = document.createElement('li');
        li.innerHTML = staffDict[role];
        if (typeof item === 'string' && foldableJobs.includes(role)
          || item instanceof RegExp && foldableJobs.includes(item)) {
          li.classList.add('folded', 'foldable');
          if (!hasFolded) hasFolded = true;
        }
        ul.appendChild(li);
        delete staffDict[role]; // 从字典中删除已处理的职位
        // 保存待插入位置
        if (insterTag) {
          liAfterIntsert = li;
          insterTag = false;
        }
      });
    });

    // 将剩余未被匹配的职位按原顺序添加到待插入位置
    const unmatchedJobs = Object.keys(staffDict);
    if (unmatchedJobs.length === 0) {
      return;
    }
    unmatchedJobs.forEach(role => {
      const li = document.createElement('li');
      li.innerHTML = staffDict[role];
      if (insertFold) li.classList.add('folded', 'foldable');
      if (liAfterIntsert) ul.insertBefore(li, liAfterIntsert);
      // 未设置待插入位置,则默认插入到末尾,且默认不折叠
      else ul.appendChild(li);
    });
    console.log(`${SCRIPT_NAME}:未能匹配到的职位 ${JSON.stringify(staffDict, null, 2)}`);
    if (liAfterIntsert) console.log(`${SCRIPT_NAME}:激活将未能匹配职位插入指定位置`);
  }

  function isTargetSubjectType() {
    return TARGET_SUBJECT_TYPES.includes(getSubjectType());
  }

  /* 巧妙地使用非常便捷的方法,获取当前条目的类型
   * 源自 https://bangumi.tv/dev/app/2723/gadget/1242
   * 替代了下方的原有方法
   */
  function getSubjectType() {
    const href = document.querySelector("#navMenuNeue .focus").getAttribute("href");
    return href.split("/")[1];
  }

  /*async function isTargetMediaType() {
    const smallTag = document.querySelector('h1.nameSingle > small.grey');
    if (smallTag) {
      // 优先通过网页内容判断
      const text = smallTag.innerText.trim();
      return ['TV', 'WEB', '剧场版', 'OVA'].includes(text);
    } else {
      // 通过API查询
      const urlParts = location.href.split('/');
      const subjectID = urlParts[urlParts.length - 1];
      const response = await fetch(`https://api.bgm.tv/v0/subjects/${subjectID}`);
      const subject = await response.json();
      return subject.type === 2; // 判断是否为动画类别
    }
  }*/

  // 获取一个字典来存储网页中的职位信息
  function getStaffDict() {
    const staffDict = {};
    const lis = document.querySelectorAll('#infobox > li');
    lis.forEach(li => {
      const tip = li.querySelector('span.tip');
      if (tip) {
        const role = tip.innerText.trim().slice(0, -1); // 去掉最后的冒号
        staffDict[role] = li.innerHTML;
      }
    });
    return staffDict;
  }

  // 为网页原有的 `folded` 类别添加 `foldable` 便签,用于实现切换
  function addFoldableTag() {
    const lis = document.querySelectorAll('#infobox > li');
    lis.forEach(li => {
      if (li.classList.contains('folded')) {
        li.classList.add('foldable');
        if (!hasFolded) hasFolded = true;
      }
    });
  }

  /* 将原本存在的 `更多制作人员` 一次性按钮,转绑新事件,并改为永久性开关
   * 使用网页原有的 `folded` 元素类别,实现对立于 sortStaff 功能
   * 添加不存在的 `更多制作人员` 按钮,否则一些职位信息将不可见
    <div class="infobox_expand">
      <a href="javascript:void(0)">更多制作人员 +</a>
      <!-- href 属性需保留,不然鼠标悬浮效果会失效 -->
    </div>
   */
  function changeToToggleButton() {
    const buttonValue = { on: '更多制作人员 +', off: '更多制作人员 -' };
    const parent = document.querySelector('.infobox_container');
    let moreLink = parent.querySelector('.infobox_expand a');
    if (!hasFolded) {
      // 无必要,不进行事件绑定与可能的添加,并将原有的开关隐藏
      if (moreLink) {
        moreLink.style.display = 'none';
        console.log(`${SCRIPT_NAME} - 将原有的 '${buttonValue.on}' 隐藏`);
      }
      return;
    }
    if (!moreLink) {
      moreLink = createElement('a', { href: 'javascript:void(0)' }, buttonValue.on);
      const expand = createElement('div', { class: 'infobox_expand' }, [moreLink]);
      parent.appendChild(expand);
      console.log(`${SCRIPT_NAME}:添加原不存在的 '${buttonValue.on}' 按钮`);
    }
    moreLink.addEventListener('click', function (event) {
      event.stopImmediatePropagation(); // 阻止其他事件的触发
      const foldedLis = document.querySelectorAll('.foldable');
      const isHidden = moreLink.innerText == buttonValue.on;
      foldedLis.forEach(li => {
        if (isHidden) {
          li.classList.remove('folded');
        } else {
          li.classList.add('folded');
        }
      });
      moreLink.innerText = isHidden ? buttonValue.off : buttonValue.on;
    }, { capture: true }); // 使事件处理函数在捕获阶段运行
  }

  /* 创建用户设置 UI 界面
   * 仿照 #columnA 中的同类元素进行构建,使用原有的结构与样式
    <table class="settings" style="margin-left: 5px">
      <colgroup>
        <col style="width: 90%" />
        <!-- 右侧留出的空间,是为了移动端可以同时选择滑动 textarea Or mainpage -->
        <col />
      </colgroup>
      <tbody>
        <tr>
          <td colspan="2">
            <h2 class="subtitle">条目职位排序 · 默认折叠的职位</h2>
          </td>
        </tr>
        <!-- 此处添加子模块 -->
      </tbody>
    </table>
   */
  function buildSettingUI(mainStyle) {
    const mainTitle = createElement('tr', null, [
      createElement('td', { colSpan: '2' }, [
        createElement('h2', { class: 'subtitle' }, '条目职位排序 · 默认折叠的职位')
      ])
    ]);
    const animeBlock = buildAnimeBlock();
    const ui = createElement('div', mainStyle, [
      createElement('table',
        { class: 'settings', style: { marginLeft: '5px' } }, [
        createElement('colgroup', null, [
          createElement('col', { style: { width: '90%' } }),
          createElement('col'),
        ]),
        createElement('tbody', null, [
          mainTitle, animeBlock
          // 可拓展其他类型条目的模块
        ])
      ])
    ]);
    return ui;
  }

  /* 创建 staffMapList 文本内容编辑界面
   * 对于 textarea, button 等控件仍然使用原有的结构与样式
    <tr>
      <td>
        <h2 class="subtitle">动画条目</h2>
        <div>
          <div class="markItUp">
            <textarea class="quick markItUpEditor hasEditor codeHighlight"
              id="staff_map_list" name="staff_map_list" style="line-height: 21px">
              <!-- staffMapListText -->
            </textarea>
          </div>
          <input class="inputBtn" type="submit" name="submit_context" value="保存" style="margin-right: 5px" />
          <input class="inputBtn" type="submit" name="reset_context" value="恢复默认" style="margin-right: 10px" />
          <p class="tip_j" style="display: none"><!-- message --></p>
        </div>
      </td>
      <td><!-- 为移动端预留的 mainpage 滑动空间 --></td>
    </tr>
  */
  function buildAnimeBlock() {
    // 搭建标题
    const subTitle = createElement('h2', { class: 'subtitle' }, '动画条目');
    // 搭建简易提示框
    const msgCntr = createElement('p', { class: 'tip_j', style: { display: 'none' } });

    // 搭建文本框
    let hasInputted = false;
    let {text, isDefault} = getMapListText(false);
    if (isDefault) setMessage(msgCntr, '现为默认设置'); // 初始化时,提醒用户已为默认设置
    const textArea = createElement('textarea', {
      class: 'quick markItUpEditor hasEditor codeHighlight', id: 'staff_map_list', name: 'staff_map_list',
      style: { fontSize: '13x', lineHeight: '21px' }
    },
      text, {
        input: () => {
          if (!hasInputted) hasInputted = true;
          if (isDefault) isDefault = false;
          // console.log("IS INPUTTING");
        }
    });
    // 搭建提交按钮
    const submitBtn = createElement('input',
      { class: 'inputBtn', type: 'submit', name: 'submit_context', value: '保存', style: { marginRight: '5px' } }, null, {
      click: () => {
        // 判断是否为重置后未对默认内容进行修改
        if (isDefault && !hasInputted) {
          resetMapList();
          setMessage(msgCntr, '保存成功!恢复默认设置');
          // 恢复初始状态
          hasInputted = false;
          return;
        }
        const [modifiedData, isModified] = modifyMapListJSON(textArea.value);
        // 强制将用户输入的文本外层嵌套 `[]`,若为重复嵌套可在 loadMapList 中识别并去除
        const savedDate = `[${modifiedData}]`;
        const parsedData = parseMapListJSON(savedDate);
        if (parsedData) {
          // 保存数据
          saveMapListText(savedDate);
          // 页面显示
          if (isModified) trySetText(textArea, msgCntr, modifiedData, '保存成功!并自动纠错', true);
          else setMessage(msgCntr, '保存成功!');
        } else setMessage(msgCntr, '保存失败!格式存在错误');
        // 恢复初始状态
        hasInputted = false;
      }
    });
    // 搭建重置按钮
    const resetBtn = createElement('input',
      { class: 'inputBtn', type: 'submit', name: 'reset_context', value: '恢复默认', style: { marginRight: '10px' } }, null, {
      click: async () => {
        if (isDefault) {
          setMessage(msgCntr, '已为默认内容');
          return;
        }
        await trySetText(textArea, msgCntr, getMapListText(true).text, '已恢复默认内容', false);
        // 需进行同步等待,由于 setText 可能会触发 input 事件
        isDefault = true;
        hasInputted = false;
      }
    });

    // 搭建外部结构
    const textCntr = createElement('div', { class: 'markItUp' }, [textArea]);
    const animeBlock = createElement('tr', null, [
      createElement('td', null, [
        subTitle,
        // 可拓展折叠效果
        createElement('div', null, [textCntr, submitBtn, resetBtn, msgCntr])
      ]),
      createElement('td')
    ]);
    return animeBlock;
  }

  /* 优先尝试使用 execCommand 方法改写文本框,使得改写前的用户历史记录不被浏览器清除
   * (虽然 execCommand 方法已被弃用...但仍然是实现该功能最便捷的途径)
   */
  async function trySetText(textArea, msgCntr, text, msg, isRestore, transTime = 100) {
    let [scrollVert, scrollHoriz, cursorPos] = savePos();
    try {
      setMessage(msgCntr);
      await clearAndSetTextarea(textArea, text, transTime);
      setMessage(msgCntr, `${msg},可快捷键撤销`, 0);
    } catch (e) {
      textArea.value = '';
      await new Promise(resolve => setTimeout(resolve, transTime));
      textArea.value = text;
      setMessage(msgCntr, msg, 0);
      console.log(`${SCRIPT_NAME}:浏览器不支持 execCommand 方法,改为直接重置文本框,将无法通过快捷键撤销重置`)
    }
    if (isRestore) restorePos();

    // 保存滚动位置和光标位置
    function savePos() {
      return [textArea.scrollTop, textArea.scrollLeft, textArea.selectionStart];
    }
    // 恢复滚动位置和光标位置
    function restorePos() {
      const currentTextLen = textArea.value.length;
      if (cursorPos > currentTextLen) cursorPos = currentTextLen;
      textArea.scrollTop = Math.min(scrollVert, textArea.scrollHeight);
      // textArea.scrollLeft = Math.min(scrollHoriz, textArea.scrollWidth - textArea.clientWidth);
      textArea.setSelectionRange(cursorPos, cursorPos);
    }
  }

  async function clearAndSetTextarea(textarea, newText, timeout = 100) {
    textarea.focus();
    // 全选文本框内容并删除
    textarea.setSelectionRange(0, textarea.value.length);
    document.execCommand('delete');
    // 延迟一段时间后,插入新的内容
    await new Promise(resolve => setTimeout(resolve, timeout));
    document.execCommand('insertText', false, newText);
  }

  async function setMessage(container, message, timeout = 100) {
    container.style.display = 'none';
    if (!message) return; // 无信息输入,则隐藏
    // 隐藏一段时间后,展现新内容
    if (timeout) await new Promise(resolve => setTimeout(resolve, timeout));
    container.textContent = message;
    container.style.display = 'inline';
  }

  function loadMapList() {
    // 读取可能的非默认设置
    let jsonString = localStorage.getItem('BangumiStaffSorting_animeStaffMapList');
    if (jsonString) {
      let parsedData = parseMapListJSON(jsonString);
      if (parsedData) {
        // 修复外层重复嵌套 `[]` 的形式 (忽略存在的漏洞,形如:[[true, ["a"], "b"]] )
        if (parsedData.length === 1 && Array.isArray(parsedData[0]) && typeof parsedData[0][0] !== 'boolean') {
          parsedData = parsedData[0];
        }
        staffMapList.length = 0;
        staffMapList.push(...parsedData);
      } else console.log(`${SCRIPT_NAME}:自定义 staffMapList 解析失败,将使用脚本默认的数据`);
    }
    // 将数据拆解为 jobOrder 与 foldableJobs
    staffMapList.forEach(item => {
      if (Array.isArray(item) && item.length) {
        // 对数组进行完全展平,提高对非标多层数组的兼容性
        item = item.flat(Infinity);
        // 对于标准格式,仅当 Boolean 为一级子序列的首元素时,对该子序列的全部元素生效
        // 此时更广义的表述为,仅当 Boolean 为一级子序列的最左节点时,对该子序列的全部元素生效
        if (typeof item[0] === 'boolean') {
          if (item[0]) foldableJobs.push(...item.slice(1));
          jobOrder.push(...item.slice(1));
        } else {
          jobOrder.push(...item);
        }
      } else if (typeof item !== 'undefined') {
        jobOrder.push(item);
      }
    });
  }

  function resetMapList() {
    localStorage.removeItem('BangumiStaffSorting_animeStaffMapList');
    console.log(`${SCRIPT_NAME}:删除自定义 staffMapList 数据,恢复默认设置`)
  }

  function saveMapListText(jsonStr) {
    localStorage.setItem('BangumiStaffSorting_animeStaffMapList', jsonStr);
    console.log(jsonStr);
    console.log(`${SCRIPT_NAME}:保存自定义 staffMapList 数据`);
  }

  function getMapListText(useDefault) {
    let jsonStr = null;
    if (!useDefault) {
      jsonStr = localStorage.getItem('BangumiStaffSorting_animeStaffMapList');
    }
    const isDefault = jsonStr === null;
    if (jsonStr) {
      jsonStr = jsonStr.slice(1, -1); // 消除首尾的 `[]`
    } else if (mapListTextBuffer) {
      jsonStr = mapListTextBuffer;
    } else {
      // 将默认数据转化为格式化文本
      jsonStr = JSON.stringify(staffMapList, regexReplacer, 1).replace(
        /(null,\n )|(\n\s+)/g, (match, g1, g2) => {
          if (g1) return '\n';
          if (g2) return ' ';
          return match;
        }).slice(3, -2); // 消除首部 `[ \n` 与尾部 `\n]`
      // 使得 `[ `->`[` 同时 ` ]`->`]`
      // jsonStr = JSON.stringify(staffMapList, regexReplacer, 1).replace(
      //   /(null,)|(?<!\[)(\n\s+)(?!])|(\[\s+)|(\s+],)/g, (match, g1, g2, g3, g4) => {
      //   if (g1) return '\n';
      //   if (g2) return ' ';
      //   if (g3) return '[';
      //   if (g4) return '],';
      //   return match;
      // }).slice(3, -2);
      mapListTextBuffer = jsonStr;
    }
    return {text: jsonStr, isDefault: isDefault};
  }

  /* 对用户输入可能的常见语法与格式错误,进行自动纠错,以满足 JSON 格式
   * 已基本兼容 JS 格式的文本数据,实现格式转化
   * group2 与 group4 致使正则表达式中不允许出现 [/'"] 三种字符
   */
  function modifyMapListJSON(text) {
    let flags = new Array(10).fill(false);
    const rslt = text.replace(
      /(,\s*(?=]|$))|(,\s*)+(?=,)|(')|(?<!'|")(\/[^\/'"]+\/[gimsuy]*)(?!'|")/g, (match, g1, g2, g3, g4) => {
        isTriggered(0, '删除序列末尾元素后的 `,` 逗号', g1);
        isTriggered(2, '删除连续重复的 `,` 逗号', g2);
        isTriggered(1, '将单引号替换为双引号', g3);
        isTriggered(3, '将正则表达式以双引号包裹', g4);
        if (g1 || g2) return '';
        if (g3) return '"';
        if (g4) return `"${match}"`;
        return match;
      });
    return [rslt, booleanOr(...flags)];

    function isTriggered(index, msg, ...groups) {
      if (!flags[index] && booleanOr(...groups)) {
        console.log(`${SCRIPT_NAME}:触发自动纠错 - ${msg}`);
        flags[index] = true;
      }
    }
    function booleanOr(...values) {
      return values.reduce((acc, val) => acc || val, false);
    }
  }

  /* 初步解析 staffMapList JSON 字符串
   * 仅检查:
   *   1.是否满足 JSON 格式
   *   2.是否为数组类型
   *   3.字符串样式的正则表达式,是否满足规定格式
   * 更进一步的解析,将在 loadMapList 中进行
   */
  function parseMapListJSON(text) {
    let parsedData;
    try {
      parsedData = JSON.parse(text, regexReviver);
    } catch (e) {
      console.error(`${SCRIPT_NAME}:staffMapList 解析失败 - ${e}`);
      return null;
    }
    if (!Array.isArray(parsedData)) {
      console.error(`${SCRIPT_NAME}:staffMapList 类型错误 - 非数类型`);
      return null;
    }
    return parsedData;
  }

  // 解析 JSON 字符串中的正则表达式
  function regexReviver(key, value) {
    if (typeof value === 'string' && value.startsWith('/')) {
      const regexParttern = /^\/(.+)\/([gimsuy]*)$/;
      const match = value.match(regexParttern);
      if (match) {
        try {
          return new RegExp(match[1], match[2]);
        } catch (e) {
          throw new Error(`正则表达式 "${value}" 非法 - ${e}`);
        }
      } else throw new Error(`正则表达式 "${value}" 不符合 ${regexParttern} 格式`);
    }
    return value;
  }

  // 将正则表达式转化为字符串,以满足 JSON 格式
  function regexReplacer(key, value) {
    if (value instanceof RegExp) {
      return value.toString();
    }
    return value;
  }

  function createElement(tagName, options, subElements, eventHandlers) {
    const element = document.createElement(tagName);
    if (options) {
      for (let opt in options) {
        if (opt === 'dataset' || opt === 'style') {
          for (let key in options[opt]) {
            element[opt][key] = options[opt][key];
          }
        } else if (opt === 'class') {
          element.className = options[opt];
        } else {
          element[opt] = options[opt];
        }
      }
    }
    if (subElements) {
      updateSubElements(element, subElements);
    }
    if (eventHandlers) {
      for (let e in eventHandlers) {
        element.addEventListener(e, eventHandlers[e]);
      }
    }
    return element;
  }

  function updateSubElements(parent, subElements, isReplace = false) {
    if (isReplace) parent.innerHTML = '';
    if (!subElements) return parent;
    if (typeof subElements === 'string') subElements = [subElements];
    for (let e of subElements) {
      parent.appendChild(typeof e === 'string' ? document.createTextNode(e) : e);
    }
    return parent;
  }
})();