此处为全站公告。通常对读者进行申明或对该WIKI某些规则进行公告,请在确认后修改本通告。
本WIKI编辑权限开放,欢迎收藏起来防止迷路,也希望有爱的小伙伴和我们一起编辑哟~
编辑全站公告 • 编辑帮助:目录 • BWIKI反馈留言板
TextExtracts
阅读
2025-08-31更新
最新编辑:Lu_23333
阅读:
更新日期:2025-08-31
最新编辑:Lu_23333
GPT5在阅读 Extension:TextExtracts 的核心代码后,给出的JS替代方案说明。
AI给出的方案
下面先把这段扩展(TextExtracts)的核心行为梳理出来,然后给出在无后端扩展时用纯 JS(可配合模板)复刻的技术路线,再给一份精简的需求/实施文档,便于你在 MediaWiki:Common.js
/ 站点小工具(Gadget)里实现与落地。
扩展现有行为(要点概览)
- 输入参数(前缀 ex)
exchars
/exsentences
:截断字符数 / 句子数(二选一)。exintro
:只取导言(first section)。explaintext
:输出纯文本(否则输出“有限 HTML”)。exsectionformat
:在纯文本模式下,章节标题如何呈现(wiki|plain|raw
)。exlimit
:一次处理多个页面,但当不是exintro
且传入多个页面时,会强制降到 1。continue
:分页用。
- 提取流程
- 仅支持
wikitext
内容模型。 - 先查 缓存(按 pageid、touched、语言变体、纯文/HTML、intro/full 组合键)。
- 不命中 → 调 parser 拿 整页 HTML(或 section 0),然后:
- 如果
explaintext
:- 从 DOM 中去掉
img|audio|video
等(及配置类名), - 在各级标题前插入隐形 分节标记(
\1\2<i>\2\1
), - 取
textContent
,做空白/换行归一化与多余换行压缩。
- 从 DOM 中去掉
- 如果
HTML
:- 同样的过滤,扁平化 a 标签(保留文字,去掉链接)。
- 如果
- 截断:
- by chars:尽量不破坏标签(必要时 tidy 以闭合标签)。
- by sentences:用兼顾中英文标点的正则按句号/问号/感叹号等边界截断。
- 纯文本时可按
exsectionformat
把之前的分节标记转为:wiki
:== 标题 ==
形式plain
:只保留标题文本raw
:原样不处理
- 仅支持
- 其他挂钩
- OpenSearch/REST 搜索集成:为搜索建议/结果提供一小段截断描述。
- 文件命名空间返回空 extract,并给 warning。
在没有后端扩展时的纯 JS 技术路线
目标:在不动后端的前提下,用前端脚本+模板,最大程度复刻上述能力,供页面内组件、搜索页增强、模板调用使用。
1)数据来源(替代 “解析与缓存”)
- 使用 Action API:
action=parse
- 导言:
action=parse&prop=text§ion=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)
localStorage
或indexedDB
:- Key 组成:
textextracts:v3:{pageid}:{touched}:{langVariant}:{mode}
mode
=plaintext|html
+intro|full
。langVariant
可用mw.config.get('wgUserLanguage')
,若使用语言转换(大陆→繁体)较复杂,可退而求其次,用wgUserLanguage
+ 站点语言。
- TTL:保底 TTL(例如 1~7 天),同时当
touched
变化时强制失效。
- Key 组成:
- 并发限制:使用请求队列/并发池(例如 4~6 并发),避免高并发刷 API。
3)DOM 处理与文本/HTML 输出(替代 ExtractFormatter)
- 取到
parse.text['*']
(HTML 字串)后:- 用
DOMParser.parseFromString(html, 'text/html')
得到document
→body
。 - 过滤:
- 移除标签:
img|audio|video
(及站点配置要求隐藏的 class,如 infobox、坐标、导航、模板锚点等)。 - 清理所有
span
的class/style
属性(与扩展一致)。
- 移除标签:
- 纯文本路径:
- 在每个
h1~h6
前 插入分节标记(和扩展一致的魔术串)。 - 取
body.textContent
,做:- NBSP→空格
\r
→\n
- 合并 3 连以上换行为 2 连换行
trim()
- 根据
exsectionformat
把标记替换为:wiki
:== 标题 ==
(级数按h
的数字)plain
:仅行首输出标题文本(或带一个换行)raw
:不替换
- 在每个
- HTML 路径:
- “扁平化 a”:把
a
子节点提升,移除a
自身(仅保留文字/子节点)。 - 返回
body.innerHTML.trim()
。
- “扁平化 a”:把
- 用
- (可选安全)解析后的 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§ion=0
,比在整页 HTML 里 “找到第一个标题前的块” 更稳。 - 如果页面没有二级标题(全在导言里),
section=0
仍能返回整段导言。
7)搜索集成(替代 Hooks)
- 建议两个增强点(纯前端,无需服务器 Hook):
- Special\:Search 页面增强:
- 拦截搜索结果渲染后(监听
mw.hook('wikipage.content')
或轮询结果 DOM),对每条结果标题调用上面的提取器(intro + plaintext + sentences<=2 + 150 chars
),把描述注入到结果摘要区。
- 拦截搜索结果渲染后(监听
- 站点头部搜索建议组件(可选):
- 如果平台已有 Opensearch 建议,前端可在用户停顿 200–300ms 时,对候选标题异步补全导言摘要,展示在自定义下拉层里(避免替换原生 input 行为)。
- 若要完全复刻后端 Hook 的“把摘要也放到 opensearch 响应里”,纯前端无法改后端响应,但可在 UI 层达到等效体验。
- Special\:Search 页面增强:
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。
- 模板只输出一个占位 DOM:
需求 / 实施文档(精简版)
一、目标与范围
- 目标:在无 TextExtracts 扩展的站点,提供页面摘要/导言/有限 HTML提取能力,并支持截断与搜索结果增强。
- 范围:
- 支持参数:
chars
/sentences
、intro
、plaintext
、sectionformat
。 - 批量:列表/搜索页增强常见量级(≤ 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[]>
- 内部自动分批与并发控制。
五、实现细节
- 请求层:封装
mw.Api()
;导言用section=0
;整页用prop=text
。 - 缓存:
- key:
textextracts:v3:{pageid}:{touched}:{lang}:{mode}
。 - 值:序列化后的 ExtractResult。
- 写入策略:成功解析后写;读取时先比对
touched
。
- key:
- DOM 处理:
DOMParser
→ 过滤选择器 → 插分节标记(纯文本) → 输出(text/html)。 - 截断:
- 句子:正则片段与扩展保持一致(中英文标点)。
- 字符(HTML):基于节点遍历的字符预算截断,避免破坏标签。
- 末尾省略:追加
…
。
- 错误与边界:
- 文件命名空间:直接返回空字符串。
- 内容模型非 wikitext:前端无法判断内容模型,可在 parse 失败或结果异常时返回空并告警。
- 未命中标题:返回空并告警。
- 样式:
.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)的页面(确认过滤正常)。
chars
与sentences
二者互斥的行为。- 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 增强基本可以达到“可感知一致”。