此处为全站公告。通常对读者进行申明或对该WIKI某些规则进行公告,请在确认后修改本通告。本WIKI编辑权限开放,欢迎收藏起来防止迷路,也希望有爱的小伙伴和我们一起编辑哟~

全站通知:

TextExtracts

阅读

    

2025-08-31更新

    

最新编辑:Lu_23333

阅读:

  

更新日期:2025-08-31

  

最新编辑:Lu_23333

来自12343WIKI_BWIKI_哔哩哔哩
跳到导航 跳到搜索
页面贡献者 :
Lu_23333

GPT5在阅读 Extension:TextExtracts 的核心代码后,给出的JS替代方案说明。

AI给出的方案

下面先把这段扩展(TextExtracts)的核心行为梳理出来,然后给出在无后端扩展时用纯 JS(可配合模板)复刻的技术路线,再给一份精简的需求/实施文档,便于你在 MediaWiki:Common.js / 站点小工具(Gadget)里实现与落地。


扩展现有行为(要点概览)

  1. 输入参数(前缀 ex)
    • exchars / exsentences:截断字符数 / 句子数(二选一)。
    • exintro:只取导言(first section)。
    • explaintext:输出纯文本(否则输出“有限 HTML”)。
    • exsectionformat:在纯文本模式下,章节标题如何呈现(wiki|plain|raw)。
    • exlimit:一次处理多个页面,但当不是 exintro 且传入多个页面时,会强制降到 1。
    • continue:分页用。
  2. 提取流程
    • 仅支持 wikitext 内容模型。
    • 先查 缓存(按 pageid、touched、语言变体、纯文/HTML、intro/full 组合键)。
    • 不命中 → 调 parser 拿 整页 HTML(或 section 0),然后:
      • 如果 explaintext
        • 从 DOM 中去掉 img|audio|video 等(及配置类名),
        • 在各级标题前插入隐形 分节标记\1\2<i>\2\1),
        • textContent,做空白/换行归一化与多余换行压缩。
      • 如果 HTML
        • 同样的过滤,扁平化 a 标签(保留文字,去掉链接)。
    • 截断
      • by chars:尽量不破坏标签(必要时 tidy 以闭合标签)。
      • by sentences:用兼顾中英文标点的正则按句号/问号/感叹号等边界截断。
    • 纯文本时可按 exsectionformat 把之前的分节标记转为:
      • wiki== 标题 == 形式
      • plain:只保留标题文本
      • raw:原样不处理
  3. 其他挂钩
    • OpenSearch/REST 搜索集成:为搜索建议/结果提供一小段截断描述。
    • 文件命名空间返回空 extract,并给 warning。

在没有后端扩展时的纯 JS 技术路线

目标:在不动后端的前提下,用前端脚本+模板,最大程度复刻上述能力,供页面内组件、搜索页增强、模板调用使用。

1)数据来源(替代 “解析与缓存”)

  • 使用 Action API
    • action=parse
      • 导言action=parse&prop=text&section=0&page=Title
      • 整页action=parse&prop=text&page=Title
      • 可同时请求 prop=sections(需要时),但一般导言用 section=0 直接返回即可。
    • 可补充:action=query&prop=info|pageprops&titles=... 获得 pageid/touched/revid 等元信息,用于前端缓存键。
  • (可选)使用 REST HTML(若你的站点开启 Parsoid/REST):
    • GET /rest.php/page/html/{Title} 拿到语义化 HTML,再本地处理。
    • 但在 MW 1.37 平台上,不一定有 REST/Parsoid,action=parse 为主

2)前端缓存策略(替代 WANObjectCache)

  • localStorageindexedDB
    • Key 组成:textextracts:v3:{pageid}:{touched}:{langVariant}:{mode}
      • mode = plaintext|html + intro|full
      • langVariant 可用 mw.config.get('wgUserLanguage'),若使用语言转换(大陆→繁体)较复杂,可退而求其次,用 wgUserLanguage + 站点语言。
    • TTL:保底 TTL(例如 1~7 天),同时当 touched 变化时强制失效。
  • 并发限制:使用请求队列/并发池(例如 4~6 并发),避免高并发刷 API。

3)DOM 处理与文本/HTML 输出(替代 ExtractFormatter)

  • 取到 parse.text['*'](HTML 字串)后:
    • DOMParser.parseFromString(html, 'text/html') 得到 documentbody
    • 过滤
      • 移除标签:img|audio|video(及站点配置要求隐藏的 class,如 infobox、坐标、导航、模板锚点等)。
      • 清理所有 spanclass/style 属性(与扩展一致)。
    • 纯文本路径
      • 在每个 h1~h6插入分节标记(和扩展一致的魔术串)。
      • body.textContent,做:
        • NBSP→空格
        • \r\n
        • 合并 3 连以上换行为 2 连换行
        • trim()
      • 根据 exsectionformat 把标记替换为:
        • wiki== 标题 ==(级数按 h 的数字)
        • plain:仅行首输出标题文本(或带一个换行)
        • raw:不替换
    • HTML 路径
      • “扁平化 a”:把 a 子节点提升,移除 a 自身(仅保留文字/子节点)。
      • 返回 body.innerHTML.trim()
  • (可选安全)解析后的 HTML 已由 MW 过滤,通常可直接信任;如需更保险,可引入轻量 sanitize 步骤(例如纯白名单移除 style/class/on*)。

4)截断(替代 TextTruncator)

  • 按句截断(纯文本/HTML 都可先转纯文本做决策):
    • 使用与扩展相近的正则:
      • 英文:句点(避免大写缩写后紧随)、问号、感叹号
      • 中文:。 . ! ? 。
    • 命中不足时,退化为取第一行。
  • 按字符截断
    • 如果是纯文本:直接 slice(0, n)(避免破字:可按 Unicode 字素或空白边界回退)。
    • 如果是 HTML:**推荐“节点遍历 + 预算削减”**策略,而不是直接裁字符串:
      • 深度优先遍历可见文本节点,累加字符数,超过限制时在当前文本节点按剩余额度截断并 break
      • 输出时使用临时容器接回已遍历的节点 → innerHTML,从而天然闭合标签,不必再 tidy。
    • 如果做到了截断(少于全文):末尾加 (取 mw.msg('ellipsis') 或自定义)。

5)多页面批量(替代 exlimit)

  • 扩展对多页有上限与 continue,前端可做简化版
    • 批量列表(≤20)并发请求;大于 20 做分批。
    • intro 且多个页面 → 按扩展行为降级为 1(或给出控制台告警)。
    • 实际业务中:列表卡片、搜索结果增强就是典型批量场景。

6)“导言 only”(替代 exintro)

  • 使用 action=parse&section=0,比在整页 HTML 里 “找到第一个标题前的块” 更稳。
  • 如果页面没有二级标题(全在导言里),section=0 仍能返回整段导言。

7)搜索集成(替代 Hooks)

  • 建议两个增强点(纯前端,无需服务器 Hook):
    1. Special\:Search 页面增强
      • 拦截搜索结果渲染后(监听 mw.hook('wikipage.content') 或轮询结果 DOM),对每条结果标题调用上面的提取器(intro + plaintext + sentences<=2 + 150 chars),把描述注入到结果摘要区。
    2. 站点头部搜索建议组件(可选)
      • 如果平台已有 Opensearch 建议,前端可在用户停顿 200–300ms 时,对候选标题异步补全导言摘要,展示在自定义下拉层里(避免替换原生 input 行为)。
      • 若要完全复刻后端 Hook 的“把摘要也放到 opensearch 响应里”,纯前端无法改后端响应,但可在 UI 层达到等效体验

8)配套模板(可选)

  • 提供 模板:Extract 模板:
    • 模板只输出一个占位 DOM:
          <div class="mw-extract"
               data-title="{{{1|{{PAGENAME}}}}}"
               data-chars="{{{chars|}}}"
               data-sentences="{{{sentences|}}}"
               data-intro="{{{intro|1}}}"
               data-plain="{{{plain|1}}}"
               data-sectionformat="{{{sectionformat|wiki}}}">
            <span class="mw-extract__loading">加载中…</span>
          </div>
      
    • 前端脚本在 DOMReady 时扫描 .mw-extract,调用提取器,填充内容。
    • 这样作者端只会用模板,不写 JS

需求 / 实施文档(精简版)

一、目标与范围

  • 目标:在无 TextExtracts 扩展的站点,提供页面摘要/导言/有限 HTML提取能力,并支持截断搜索结果增强
  • 范围
    • 支持参数:chars / sentencesintroplaintextsectionformat
    • 批量:列表/搜索页增强常见量级(≤ 100 条,分批请求)。
    • 组件化:可用于模板 模板:Extract、卡片列表、搜索页增强。

二、非目标

  • 不提供后端 API 改造、也不改变 Opensearch 响应结构。
  • 不追求与原扩展字节级一致,以可视一致为准。

三、依赖

  • MediaWiki 1.37+ 的 action=parse(同域即可,不涉 CORS)。
  • MediaWiki:Common.js 或站点 Gadget(ResourceLoader 模块化)。
  • 可选:DOMPurify(如有更严格的安全需求)。

四、接口设计(前端提取器)

type ExtractParams = {
  title: string;             // 页面名(或 pageid 二选一)
  pageid?: number;
  intro?: boolean;           // exintro
  plaintext?: boolean;       // explaintext
  sectionformat?: 'wiki'|'plain'|'raw';
  chars?: number;            // exchars
  sentences?: number;        // exsentences
  html?: boolean;            // 便捷:= !plaintext
  removeSelectors?: string[];// 额外要移除的选择器(默认含 img|audio|video)
};

type ExtractResult = {
  title: string;
  pageid: number;
  html?: string;     // 当 plaintext=false
  text?: string;     // 当 plaintext=true
  fromCache: boolean;
};
  • 函数getExtract(params: ExtractParams): Promise<ExtractResult>
  • 批量getExtracts(batchParams: ExtractParams[]): Promise<ExtractResult[]>
    • 内部自动分批与并发控制。

五、实现细节

  1. 请求层:封装 mw.Api();导言用 section=0;整页用 prop=text
  2. 缓存
    • key:textextracts:v3:{pageid}:{touched}:{lang}:{mode}
    • 值:序列化后的 ExtractResult。
    • 写入策略:成功解析后写;读取时先比对 touched
  3. DOM 处理DOMParser → 过滤选择器 → 插分节标记(纯文本) → 输出(text/html)。
  4. 截断
    • 句子:正则片段与扩展保持一致(中英文标点)。
    • 字符(HTML):基于节点遍历的字符预算截断,避免破坏标签。
    • 末尾省略:追加
  5. 错误与边界
    • 文件命名空间:直接返回空字符串。
    • 内容模型非 wikitext:前端无法判断内容模型,可在 parse 失败或结果异常时返回空并告警。
    • 未命中标题:返回空并告警。
  6. 样式
    • .mw-extract 容器默认最小样式(行高、颜色、loading 态)。
    • 主题可覆写。

六、集成点

  • 模板 模板:Extract:输出占位 DOM + data-*;JS 扫描填充。
  • 列表卡片:在卡片 data-title 上批量调用 getExtracts,减少请求轮次。
  • Special\:Search:检测搜索结果块(如 .mw-search-result-heading a),批量拉取导言纯文本(如 sentences=2),注入 .searchresult 内。

七、配置项(可在 MediaWiki:Common.js 顶部集中)

window.ExtractsConfig = {
  defaultRemoveSelectors: ['img','audio','video','.infobox','.navbox','.mw-empty-elt'],
  defaultPlainSectionFormat: 'wiki',
  maxBatchSize: 20,
  maxConcurrency: 6,
  cacheTTL: 7 * 24 * 3600 * 1000 // 7 天
};

八、测试清单(要点)

  • 中英文页面、长导言/无导言页面。
  • 含大量模板(infobox/navbox)的页面(确认过滤正常)。
  • charssentences 二者互斥的行为。
  • HTML 截断后标签闭合性(通过浏览器 DOM 验证)。
  • 搜索页 10/20/50 项结果的性能与节流。

代码骨架(可直接放 Gadget / Common.js)

下面是骨架与关键流程,方便你粘贴后继续完善;已包含节点遍历式 HTML 截断与纯文本分节标记处理思路(省略了一些健壮性与样式细节)。

/* global mw */
(function () {
  'use strict';

  // ---- 配置 ----
  const CFG = Object.assign({
    defaultRemoveSelectors: ['img','audio','video'],
    defaultPlainSectionFormat: 'wiki',
    maxBatchSize: 20,
    maxConcurrency: 6,
    cacheTTL: 7 * 24 * 3600 * 1000
  }, window.ExtractsConfig || {});

  const SECTION_MARKER_START = '\x01\x02';
  const SECTION_MARKER_END   = '\x02\x01';

  const api = new mw.Api();
  const userLang = mw.config.get('wgUserLanguage') || 'en';

  // ---- 工具:缓存键 ----
  function cacheKey(meta, mode) {
    // meta: {pageid, touched}
    return `textextracts:v3:${meta.pageid}:${meta.touched}:${userLang}:${mode}`;
  }
  function readCache(key) {
    try {
      const raw = localStorage.getItem(key);
      if (!raw) return null;
      const obj = JSON.parse(raw);
      if (Date.now() - obj._ts > CFG.cacheTTL) return null;
      return obj;
    } catch (e) { return null; }
  }
  function writeCache(key, value) {
    try {
      localStorage.setItem(key, JSON.stringify(Object.assign({_ts: Date.now()}, value)));
    } catch (e) {}
  }

  // ---- 工具:获取 meta(pageid/touched)----
  async function getMeta(title) {
    const data = await api.get({
      action: 'query',
      prop: 'info',
      titles: title,
      formatversion: 2
    });
    const p = (data.query.pages || [])[0];
    if (!p || p.invalid) throw new Error('title-invalid');
    return { pageid: p.pageid, touched: p.touched || p.lastrevid || 0, title: p.title };
  }

  // ---- DOM 处理:移除元素 / 扁平化链接 ----
  function filterBody(body, removeSelectors) {
    removeSelectors.forEach(sel => body.querySelectorAll(sel).forEach(el => el.remove()));
    body.querySelectorAll('span').forEach(sp => { sp.removeAttribute('class'); sp.removeAttribute('style'); });
  }
  function flattenLinks(body) {
    body.querySelectorAll('a').forEach(a => {
      while (a.firstChild) a.parentNode.insertBefore(a.firstChild, a);
      a.remove();
    });
  }

  // ---- 插入分节标记 ----
  function insertSectionMarkers(body) {
    body.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach(h => {
      const i = h.tagName.slice(1);
      const marker = body.ownerDocument.createTextNode('\n\n' + SECTION_MARKER_START + i + SECTION_MARKER_END);
      h.parentNode.insertBefore(marker, h);
    });
  }

  // ---- 纯文本输出 + sectionformat ----
  function toPlainText(body, sectionFormat) {
    insertSectionMarkers(body);
    let text = body.textContent || '';
    text = text.replace(/\u00A0/g, ' ').replace(/\r/g, '\n').replace(/\n{3,}/g, '\n\n').trim();

    if (sectionFormat === 'raw') return text;
    const re = new RegExp(SECTION_MARKER_START + '(\\d)' + SECTION_MARKER_END + '(.*)');
    return text.replace(new RegExp(re, 'g'), (_, level, title) => {
      const t = String(title).trim();
      if (sectionFormat === 'plain') return '\n' + t;
      if (sectionFormat === 'wiki') {
        const bars = '='.repeat(Number(level));
        return `\n${bars} ${t} ${bars}`;
      }
      return '\n' + t;
    });
  }

  // ---- 句子截断(中英混合)----
  function truncateBySentences(text, n) {
    if (n <= 0) return '';
    const re = /(?:\P{Lu}\.(?=[ \n]|$)|[!?](?=[ \n]|$)|。|.|!|?|。)+/u;
    let count = 0, i = 0;
    while (true) {
      const m = re.exec(text.slice(i));
      if (!m) {
        // 退化:取第一行
        return text.split('\n')[0].trim();
      }
      i += m.index + m[0].length;
      count++;
      if (count >= n) return text.slice(0, i).trim();
    }
  }

  // ---- HTML 安全截断(按字符预算)----
  function truncateHtmlByChars(container, maxChars) {
    if (maxChars <= 0) return '';
    const clone = container.cloneNode(true);
    let remain = maxChars;
    const walker = document.createTreeWalker(clone, NodeFilter.SHOW_TEXT, null);
    const toTrim = [];

    while (walker.nextNode()) {
      const node = walker.currentNode;
      const len = node.nodeValue.length;
      if (remain >= len) {
        remain -= len;
      } else {
        node.nodeValue = node.nodeValue.slice(0, Math.max(0, remain));
        remain = 0;
        // 记录后续兄弟/节点用于删除(确保不超)
        let cur;
        // 从当前文本节点的下一个可见节点开始删
        toTrim.push(node);
        break;
      }
    }
    if (remain === 0) {
      // 删除当前节点之后的所有兄弟/后代
      const cut = toTrim[0];
      if (cut) {
        // 深度剪裁:从 cut 起向上,清除之后的节点
        let stop = false;
        function pruneAfter(n) {
          while (n && n.nextSibling) n.parentNode.removeChild(n.nextSibling);
          if (n.parentNode && n.parentNode !== clone) pruneAfter(n.parentNode);
        }
        pruneAfter(cut);
      }
    }
    return clone.innerHTML.trim();
  }

  // ---- 主函数:getExtract ----
  async function getExtract(params) {
    const {
      title,
      intro = true,
      plaintext = true,
      sectionformat = 'wiki',
      chars,
      sentences,
      removeSelectors = CFG.defaultRemoveSelectors
    } = params;

    const meta = await getMeta(title);
    const mode = `${plaintext ? 'plaintext' : 'html'}:${intro ? 'intro' : 'full'}`;
    const key = cacheKey(meta, mode);
    const cached = readCache(key);
    if (cached) return Object.assign({ fromCache: true }, cached);

    const parseParams = {
      action: 'parse',
      prop: 'text',
      page: meta.title,
      formatversion: 2
    };
    if (intro) parseParams.section = 0;

    const data = await api.get(parseParams);
    const html = data.parse?.text || '';
    const doc = new DOMParser().parseFromString(html, 'text/html');
    const body = doc.body;

    // 过滤
    filterBody(body, removeSelectors);

    let resultText = null;
    let resultHtml = null;

    if (plaintext) {
      let text = toPlainText(body, sectionformat);
      // 截断(句/字 二选一)
      if (sentences) {
        text = truncateBySentences(text, Number(sentences));
      } else if (chars) {
        text = text.slice(0, Number(chars));
      }
      resultText = text;
    } else {
      // 扁平化链接
      flattenLinks(body);
      let outHtml = body.innerHTML.trim();
      if (chars) {
        outHtml = truncateHtmlByChars(body, Number(chars));
      } else if (sentences) {
        // 句子截断基于纯文本判定,再从 HTML 上做近似字符预算(可选改进)
        const asText = body.textContent || '';
        const truncated = truncateBySentences(asText, Number(sentences));
        outHtml = truncateHtmlByChars(body, truncated.length);
      }
      resultHtml = outHtml;
    }

    const ret = {
      title: meta.title,
      pageid: meta.pageid,
      text: resultText || undefined,
      html: resultHtml || undefined,
      fromCache: false
    };
    writeCache(key, ret);
    return ret;
  }

  // 暴露到全局,模板/其它脚本可直接调用
  window.Extracts = {
    getExtract
  };

  // ---- 自动扫描占位:支持 {{Extract}} 模板占位 ----
  function initAutoFill() {
    document.querySelectorAll('.mw-extract').forEach(async el => {
      if (el.__filled) return;
      el.__filled = true;

      const p = {
        title: el.dataset.title || mw.config.get('wgPageName'),
        intro: el.dataset.intro !== '0',
        plaintext: el.dataset.plain !== '0',
        sectionformat: el.dataset.sectionformat || 'wiki',
        chars: el.dataset.chars ? Number(el.dataset.chars) : undefined,
        sentences: el.dataset.sentences ? Number(el.dataset.sentences) : undefined
      };

      try {
        const res = await getExtract(p);
        el.innerHTML = p.plaintext !== false && res.text ? 
          mw.html.escape(res.text) : 
          (res.html || '');
      } catch (e) {
        el.textContent = '';
        el.classList.add('mw-extract--error');
      }
    });
  }

  mw.hook('wikipage.content').add(initAutoFill);
  if (document.readyState !== 'loading') initAutoFill();
  else document.addEventListener('DOMContentLoaded', initAutoFill);
})();

落地建议

  • 第一步:把上面的骨架作为 Gadget 发布(如 MediaWiki:Gadget-Extracts.js),并在 MediaWiki:Gadgets-definition 加上依赖 mediawiki.api
  • 第二步:创建 Template:Extract 输出占位 DOM(见上),供页面/卡片调用。
  • 第三步(可选):为 Special:Search 写一个小型增强脚本,批量扫描结果并注入导言(调用 window.Extracts.getExtract),注意节流与批量。

有了这些,你就能在不装 TextExtracts 扩展的前提下,复刻 80–90% 的使用场景:卡片摘要、导言摘录、搜索页描述等。剩余差异主要在后端 Hook 与服务端缓存,这部分前端无法做到 1:1,但通过 UI 增强基本可以达到“可感知一致”。