User:PexEric/InCatAssess.js
外观
注意:保存之后,你必须清除浏览器缓存才能看到做出的更改。Google Chrome、Firefox、Microsoft Edge及Safari:按住⇧ Shift键并单击工具栏的“刷新”按钮。参阅Help:绕过浏览器缓存以获取更多帮助。
(function () {
'use strict';
// 配置项
const CONFIG = {
maxConcurrentSaves: 2, // 最大并发保存数
prefetchCount: 3, // 预取条目数
linkThreshold: 2, // 省级专题链接出现阈值
retryLimit: 1, // 保存失败重试次数
enableAutoWPBS: false, // 允许自动新建WPBS
enableImportanceFill: false, // 允许补全importance=Low
enableProvinceDetection: false, // 启用省级专题自动识别
markAsMinorEdit: false, // 将编辑标记为小编辑
};
// 省级行政区映射表
const PROVINCE_MAP = {
// 直辖市
'北京': '北京', '北京市': '北京',
'天津': '天津', '天津市': '天津',
'上海': '上海', '上海市': '上海',
'重庆': '重庆', '重庆市': '重庆',
// 省
'河北': '河北', '河北省': '河北',
'山西': '山西', '山西省': '山西',
'辽宁': '辽宁', '辽宁省': '辽宁',
'吉林': '吉林', '吉林省': '吉林',
'黑龙江': '黑龙江', '黑龙江省': '黑龙江',
'江苏': '江苏', '江苏省': '江苏',
'浙江': '浙江', '浙江省': '浙江',
'安徽': '安徽', '安徽省': '安徽',
'福建': '福建', '福建省': '福建',
'江西': '江西', '江西省': '江西',
'山东': '山东', '山东省': '山东',
'河南': '河南', '河南省': '河南',
'湖北': '湖北', '湖北省': '湖北',
'湖南': '湖南', '湖南省': '湖南',
'广东': '广东', '广东省': '广东',
'海南': '海南', '海南省': '海南',
'四川': '四川', '四川省': '四川',
'贵州': '贵州', '贵州省': '贵州',
'云南': '云南', '云南省': '云南',
'陕西': '陕西', '陕西省': '陕西',
'甘肃': '甘肃', '甘肃省': '甘肃',
'青海': '青海', '青海省': '青海',
//'台湾': '台湾', '台湾省': '台湾', '臺灣': '台湾',
// 自治区
'内蒙古': '内蒙古', '内蒙古自治区': '内蒙古',
'广西': '广西', '广西壮族自治区': '广西',
'西藏': '西藏', '西藏自治区': '西藏',
'宁夏': '宁夏', '宁夏回族自治区': '宁夏',
'新疆': '新疆', '新疆维吾尔自治区': '新疆'
// 特别行政区
//'香港': '香港', '香港特别行政区': '香港',
//'澳门': '澳门', '澳门特别行政区': '澳门', '澳門': '澳门'
};
/**
* 异步保存队列管理器
*/
class SaveQueueManager {
constructor(api, maxConcurrent = 2) {
this.api = api;
this.maxConcurrent = maxConcurrent;
this.queue = []; // 待保存队列
this.processing = new Map(); // 正在处理的任务
this.completed = []; // 已完成任务
this.failed = []; // 失败任务
this.onStatusChange = null; // 状态变化回调
}
async add(task) {
this.queue.push({
...task,
retryCount: 0,
status: 'pending'
});
this.processNext();
this.notifyStatusChange();
}
async processNext() {
if (this.processing.size >= this.maxConcurrent) {
return;
}
const task = this.queue.shift();
if (!task) {
return;
}
task.status = 'processing';
this.processing.set(task.item.talkTitle, task);
this.notifyStatusChange();
try {
await this.executeTask(task);
task.status = 'completed';
this.completed.push(task);
this.processing.delete(task.item.talkTitle);
mw.notify(`已保存: ${task.summary}`, {
type: 'success',
autoHide: true,
tag: 'rating-save'
});
} catch (error) {
task.error = error.message;
if (task.retryCount < CONFIG.retryLimit) {
task.retryCount++;
task.status = 'retrying';
this.queue.unshift(task);
this.processing.delete(task.item.talkTitle);
setTimeout(() => {
this.processNext();
}, 2000 * task.retryCount);
} else {
task.status = 'failed';
this.failed.push(task);
this.processing.delete(task.item.talkTitle);
mw.notify(`保存失败 (${task.item.talkTitle}): ${error.message}`, {
type: 'error',
autoHide: false,
tag: 'rating-error'
});
}
}
this.notifyStatusChange();
this.processNext();
}
async executeTask(task) {
const talkContent = await this.getTalkPageContent(task.item.talkTitle);
let province = null;
if (CONFIG.enableProvinceDetection) {
province = await this.identifyProvince(task.item.mainTitle);
}
const processor = new WPBSProcessor();
const result = processor.process(talkContent.content, {
classRating: task.rating,
province: province,
enableImportanceFill: CONFIG.enableImportanceFill
});
const summary = this.buildEditSummary(task.rating, result.addedProvince, result.importanceFilled);
task.summary = summary;
await this.api.postWithToken('csrf', {
action: 'edit',
title: task.item.talkTitle,
text: result.text,
summary: summary,
minor: CONFIG.markAsMinorEdit,
watchlist: 'nochange',
basetimestamp: talkContent.timestamp,
starttimestamp: talkContent.starttimestamp,
format: 'json'
});
return result;
}
async getTalkPageContent(title) {
const response = await this.api.get({
action: 'query',
prop: 'revisions|info',
titles: title,
rvslots: 'main',
rvprop: 'content|timestamp',
format: 'json'
});
const page = Object.values(response.query.pages)[0];
if (page.missing) {
return {
content: '',
timestamp: null,
starttimestamp: new Date().toISOString()
};
}
return {
content: page.revisions[0].slots.main['*'],
timestamp: page.revisions[0].timestamp,
starttimestamp: new Date().toISOString()
};
}
async identifyProvince(articleTitle) {
try {
const wikitextResponse = await this.api.get({
action: 'query',
prop: 'revisions',
titles: articleTitle,
rvslots: 'main',
rvprop: 'content',
format: 'json'
});
const pages = wikitextResponse.query.pages;
const page = Object.values(pages)[0];
if (page.missing || !page.revisions || !page.revisions[0]) {
return null;
}
const wikitext = page.revisions[0].slots.main['*'];
const provinceCounts = this.analyzeProvinceLinks(wikitext);
let maxProvince = null;
let maxCount = 0;
for (const [province, count] of Object.entries(provinceCounts)) {
if (count > maxCount && count >= CONFIG.linkThreshold) {
maxProvince = province;
maxCount = count;
}
}
return maxProvince;
} catch (error) {
return null;
}
}
analyzeProvinceLinks(wikitext) {
const provinceCounts = {};
const linkRegex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
let match;
while ((match = linkRegex.exec(wikitext)) !== null) {
const linkTarget = match[1].trim();
const normalized = this.normalizeProvinceName(linkTarget);
if (normalized) {
provinceCounts[normalized] = (provinceCounts[normalized] || 0) + 1;
}
}
const textCounts = this.analyzeProvinceText(wikitext);
for (const [province, count] of Object.entries(textCounts)) {
if (provinceCounts[province]) {
provinceCounts[province] += Math.floor(count * 0.3);
} else {
provinceCounts[province] = Math.floor(count * 0.3);
}
}
return provinceCounts;
}
analyzeProvinceText(wikitext) {
const provinceCounts = {};
let cleanText = wikitext
.replace(/\[\[[^\]]+\]\]/g, '')
.replace(/\{\{[^}]+\}\}/g, '')
.replace(/<[^>]+>/g, '')
.replace(/\{\|[\s\S]*?\|\}/g, '');
for (const [fullName, shortName] of Object.entries(PROVINCE_MAP)) {
const regex = new RegExp(`\\b${this.escapeRegex(fullName)}\\b`, 'g');
const matches = cleanText.match(regex);
if (matches) {
provinceCounts[shortName] = (provinceCounts[shortName] || 0) + matches.length;
}
}
return provinceCounts;
}
escapeRegex(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
normalizeProvinceName(title) {
let cleanTitle = title.replace(/^[^:]+:/, '').trim();
if (PROVINCE_MAP[cleanTitle]) {
return PROVINCE_MAP[cleanTitle];
}
for (const [key, value] of Object.entries(PROVINCE_MAP)) {
if (cleanTitle === key) {
return value;
}
if (cleanTitle.includes(key) || key.includes(cleanTitle)) {
const keyWords = ['省', '市', '自治区', '特别行政区', '行政区'];
const hasAdminSuffix = keyWords.some(suffix =>
cleanTitle.includes(key + suffix) || cleanTitle === key
);
if (hasAdminSuffix || cleanTitle === key) {
return value;
}
}
}
const specialPatterns = [
{ pattern: /^(.+?)(?:省|市|自治区|特别行政区)/, extract: 1 },
{ pattern: /^(.+?)(?:地区|盟|州)/, extract: 1 }
];
for (const { pattern, extract } of specialPatterns) {
const match = cleanTitle.match(pattern);
if (match && PROVINCE_MAP[match[extract]]) {
return PROVINCE_MAP[match[extract]];
}
}
return null;
}
buildEditSummary(rating, addedProvince, importanceCount) {
const parts = [`[[User:PexEric/InCatAssess|分类内评级]]:${this.getRatingLabel(rating)}级`];
if (addedProvince) {
parts.push(`+{{${addedProvince}专题}}`);
}
if (importanceCount > 0) {
parts.push(`补全${importanceCount}个专题重要度`);
}
return parts.join(';');
}
getRatingLabel(rating) {
const reverseMap = {
'BList': '乙级列表',
'CList': '丙级列表',
'List': '列表',
'SList': '小列表',
'B': '乙',
'C': '丙',
'D': '丁',
'Start': '初',
'Stub': '小作品'
};
return reverseMap[rating] || rating;
}
notifyStatusChange() {
if (this.onStatusChange) {
this.onStatusChange({
pending: this.queue.length,
processing: this.processing.size,
completed: this.completed.length,
failed: this.failed.length
});
}
}
getStatus() {
return {
pending: this.queue.length,
processing: this.processing.size,
completed: this.completed.length,
failed: this.failed.length,
total: this.queue.length + this.processing.size + this.completed.length + this.failed.length
};
}
}
class CategoryRatingTool {
constructor() {
this.api = new mw.Api();
this.queue = []; // 待处理队列
this.currentIndex = 0; // 当前处理索引
this.processedCount = 0; // 已处理计数
this.prefetchCache = {}; // 预取缓存
this.vueApp = null; // Vue应用实例
this.currentItem = null; // 当前处理的条目
this.saveQueue = null; // 保存队列管理器
}
canRun() {
const ns = mw.config.get('wgNamespaceNumber');
const title = mw.config.get('wgTitle');
if (ns !== 14) return false;
const pattern = /^(未评级|未評級).+(条|條)目$/;
return pattern.test(title);
}
addPortletLink() {
const self = this;
const link = mw.util.addPortletLink(
'p-cactions',
'#',
'分类内评级',
'ca-category-rating',
'为分类内条目批量评级'
);
if (link) {
$(link).click(function (e) {
e.preventDefault();
self.start();
});
}
}
async start() {
try {
await mw.loader.using([
'mediawiki.api',
'mediawiki.util',
'@wikimedia/codex',
'vue'
]);
this.saveQueue = new SaveQueueManager(this.api, CONFIG.maxConcurrentSaves);
this.saveQueue.onStatusChange = (status) => {
this.updateSaveStatus(status);
};
mw.notify('正在加载分类成员...', { type: 'info' });
await this.loadCategoryMembers();
if (this.queue.length === 0) {
mw.notify('该分类没有待处理的条目', { type: 'warning' });
return;
}
await this.createDialog();
this.startPrefetching();
await this.showNextItem();
} catch (error) {
mw.notify('启动评级工具失败: ' + error.message, { type: 'error' });
}
}
async loadCategoryMembers() {
const categoryTitle = mw.config.get('wgPageName');
let cmcontinue = null;
this.queue = [];
do {
const params = {
action: 'query',
list: 'categorymembers',
cmtitle: categoryTitle,
cmnamespace: 1,
cmtype: 'page',
cmlimit: 'max',
format: 'json'
};
if (cmcontinue) {
params.cmcontinue = cmcontinue;
}
const response = await this.api.get(params);
if (response.query && response.query.categorymembers) {
for (const member of response.query.categorymembers) {
this.queue.push({
talkTitle: member.title,
mainTitle: member.title.replace(/^Talk:/, '').replace(/^讨论:/, '')
});
}
}
cmcontinue = response.continue?.cmcontinue;
} while (cmcontinue);
}
async createDialog() {
const self = this;
return new Promise((resolve) => {
mw.loader.using(['@wikimedia/codex', 'vue'], function (require) {
const Vue = require('vue');
const Codex = require('@wikimedia/codex');
self._createDialogWithModules(Vue, Codex);
resolve();
});
});
}
_createDialogWithModules(Vue, Codex) {
const self = this;
const mountPoint = document.body.appendChild(document.createElement('div'));
mountPoint.id = 'rating-dialog-mount';
this.vueApp = Vue.createMwApp({
data() {
return {
showDialog: true,
currentIndex: 0,
totalItems: self.queue.length,
currentItem: null,
currentItemInfo: null, // XTools信息
previewContent: '加载中...',
statusText: '准备就绪',
saveStatusText: '保存队列:待处理 0 | 处理中 0 | 已完成 0 | 失败 0',
progress: 0,
buttonsDisabled: false,
showEditDialog: false,
customListText: '',
isCustomList: false,
ratings: [
{ label: '小作品', value: 'Stub' },
{ label: '初', value: 'Start' },
{ label: '丁', value: 'D' },
{ label: '丙', value: 'C' },
{ label: '乙', value: 'B' },
{ label: '小列表', value: 'SList' },
{ label: '列表', value: 'List' },
{ label: '丙级列表', value: 'CList' },
{ label: '乙级列表', value: 'BList' }
]
};
},
computed: {
dialogTitle() {
if (this.isCustomList) {
return '分类内评级 - 自定义列表';
}
return '分类内评级 - ' + mw.config.get('wgTitle');
},
itemInfoHtml() {
if (!this.currentItem) return '';
const articleUrl = mw.util.getUrl(this.currentItem.mainTitle);
const talkUrl = mw.util.getUrl(this.currentItem.talkTitle);
const editUrl = mw.util.getUrl(this.currentItem.mainTitle, { action: 'edit' });
const historyUrl = mw.util.getUrl(this.currentItem.mainTitle, { action: 'history' });
let basicInfo = `<strong>当前条目:</strong>` +
`<a href="${articleUrl}" target="_blank">${mw.html.escape(this.currentItem.mainTitle)}</a> ` +
`<span style="font-size: 0.9em;">` +
`(<a href="${talkUrl}" target="_blank">论</a>` +
` · <a href="${editUrl}" target="_blank">编</a>` +
` · <a href="${historyUrl}" target="_blank">历</a>)` +
`</span>`;
if (this.currentItemInfo) {
if (this.currentItemInfo.loading) {
basicInfo += `<span style="font-size: 0.9em; color: #999; margin-left: 8px;">` +
`正在获取条目统计信息...` +
`</span>`;
} else if (this.currentItemInfo.error) {
basicInfo += `<span style="font-size: 0.9em; color: #d33; margin-left: 8px;">` +
`统计信息获取失败: ${this.currentItemInfo.errorMessage}` +
`</span>`;
} else {
const info = this.currentItemInfo;
basicInfo += `<span style="font-size: 0.9em; color: #666; margin-left: 8px;">` +
`由<a href="https://${mw.config.get('wgServerName')}/wiki/User:${info.creator}" target="_blank">${info.creator}</a>` +
`于${info.createdDate}创建,共${info.revisions}次修订,` +
`最近修订于${info.daysSinceLastEdit}天前。` +
`共${info.editors}位编辑者,` +
`最近${info.pageviewsOffset}天共${info.pageviews}浏览数。` +
`<a href="${info.pageinfoUrl}" target="_blank">查看完整统计</a>。` +
`</span>`;
}
}
return basicInfo;
}
},
watch: {
currentItemInfo: {
handler(newVal, oldVal) {
this.$forceUpdate();
},
deep: true,
immediate: true
}
},
template: `
<cdx-dialog
v-model:open="showDialog"
:title="dialogTitle"
:use-close-button="true"
class="rating-dialog"
>
<div class="rating-content">
<cdx-button @click="openEditDialog" style="float: right; margin-bottom: 10px;">编辑列表</cdx-button>
<!-- 状态信息区 -->
<div class="status-section">
<div class="status-text">{{ statusText }}</div>
<div class="save-status-text">{{ saveStatusText }}</div>
<div class="progress-container">
<div class="progress-bar" :style="{ width: progress + '%' }"></div>
</div>
<div class="item-info" v-html="itemInfoHtml"></div>
</div>
<!-- 预览区域 -->
<div class="preview-section">
<div class="preview-content" v-html="previewContent"></div>
</div>
<!-- 评级按钮区 -->
<div class="rating-buttons">
<cdx-button
v-for="rating in ratings"
:key="rating.value"
:disabled="buttonsDisabled"
action="progressive"
@click="handleRating(rating.value)"
class="rating-btn"
>
{{ rating.label }}
</cdx-button>
<cdx-button
:disabled="buttonsDisabled"
action="destructive"
@click="handleSkip"
class="skip-btn"
>
跳过
</cdx-button>
</div>
<div class="shortcut-hint">
<small>快捷键:0-8 对应评级由左到右,S 跳过<!-- ,← → 切换条目--></small>
</div>
</div>
<cdx-dialog v-model:open="showEditDialog" title="编辑自定义列表" :primary-action="{ label: '保存', actionType: 'progressive' }" :default-action="{ label: '取消' }" @primary="saveCustomList" @default="closeEditDialog">
<p>每行输入一个项目</p>
<cdx-text-area v-model="customListText" rows="10" style="width: 100%;"></cdx-text-area>
</cdx-dialog>
</cdx-dialog>
`,
methods: {
handleRating(rating) {
self.handleRating(rating);
},
handleSkip() {
self.handleSkip();
},
openEditDialog() {
this.customListText = self.queue.map(item => item.mainTitle).join('\n');
this.showEditDialog = true;
},
saveCustomList() {
const titles = this.customListText.split('\n').map(t => t.trim()).filter(t => t);
self.queue = titles.map(title => ({
talkTitle: 'Talk:' + title,
mainTitle: title
}));
self.currentIndex = 0;
self.processedCount = 0;
this.totalItems = self.queue.length;
this.isCustomList = true;
this.showEditDialog = false;
self.showNextItem();
},
closeEditDialog() {
this.showEditDialog = false;
},
async rate(rating) {
if (!this.currentItem) return;
this.buttonsDisabled = true;
await this.saveQueue.add({
item: this.currentItem,
rating: rating
});
await this.showNextItem();
},
},
mounted() {
},
unmounted() {
}
})
.component('cdx-button', Codex.CdxButton)
.component('cdx-dialog', Codex.CdxDialog)
.component('cdx-text-area', Codex.CdxTextArea)
.mount(mountPoint);
// 添加样式
this.addDialogStyles();
// 绑定键盘事件
this.bindKeyboardEvents();
}
/**
* 添加对话框样式
*/
addDialogStyles() {
const style = document.createElement('style');
style.textContent = `
.cdx-dialog {
max-width: 64rem;
height: 100%;
}
.rating-dialog .cdx-dialog__body {
overflow-y: auto;
}
.rating-content {
padding: 16px;
}
.status-section {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid #a2a9b1;
}
.status-text {
font-weight: bold;
margin-bottom: 8px;
}
.save-status-text {
font-size: 0.9em;
color: #666;
margin-bottom: 8px;
}
.progress-container {
width: 100%;
height: 8px;
background-color: #eaecf0;
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
}
.progress-bar {
height: 100%;
background-color: #36c;
transition: width 0.3s ease;
}
.item-info {
font-size: 0.95em;
}
.preview-section {
margin-bottom: 16px;
}
.preview-content {
height: calc(100vh - 300px);
min-height: 300px;
max-height: 600px;
border: 1px solid #a2a9b1;
background: #fff;
padding: 16px;
overflow-y: auto;
border-radius: 2px;
}
.rating-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
margin-bottom: 16px;
}
.rating-btn {
min-width: 80px;
}
.skip-btn {
margin-left: 16px;
}
.shortcut-hint {
text-align: center;
color: #666;
}
`;
document.head.appendChild(style);
}
/**
* 绑定键盘事件
*/
bindKeyboardEvents() {
const self = this;
this.keyboardHandler = function (e) {
// 检查是否在输入框中
if ($(e.target).is('input, textarea')) {
return;
}
const keyMap = {
'0': 'Stub',
'1': 'Start',
'2': 'D',
'3': 'C',
'4': 'B',
'5': 'SList',
'6': 'List',
'7': 'CList',
'8': 'BList',
's': 'skip',
'S': 'skip'
};
if (keyMap[e.key]) {
e.preventDefault();
if (keyMap[e.key] === 'skip') {
self.handleSkip();
} else {
self.handleRating(keyMap[e.key]);
}
}
};
document.addEventListener('keydown', this.keyboardHandler);
}
/**
* 解绑键盘事件
*/
unbindKeyboardEvents() {
if (this.keyboardHandler) {
document.removeEventListener('keydown', this.keyboardHandler);
this.keyboardHandler = null;
}
}
/**
* 更新保存状态显示
*/
updateSaveStatus(status) {
if (this.vueApp) {
const text = `保存队列:待处理 ${status.pending} | 处理中 ${status.processing} | 已完成 ${status.completed} | 失败 ${status.failed}`;
this.vueApp.saveStatusText = text;
}
}
async showNextItem() {
if (this.currentIndex >= this.queue.length) {
if (this.vueApp) {
this.vueApp.statusText = '所有条目已处理完毕!';
this.vueApp.buttonsDisabled = true;
}
const saveStatus = this.saveQueue?.getStatus();
if (saveStatus && (saveStatus.pending > 0 || saveStatus.processing > 0)) {
this.vueApp.statusText = '等待所有保存完成...';
} else {
mw.notify('所有条目已处理完毕!', { type: 'success' });
}
return false;
}
const item = this.queue[this.currentIndex];
this.currentItem = item;
this.currentIndex++;
if (!this.vueApp) {
return false;
}
const progress = ((this.currentIndex - 1) / this.queue.length) * 100;
this.vueApp.progress = progress;
this.vueApp.statusText = `进度:${this.currentIndex} / ${this.queue.length}`;
this.vueApp.currentItem = item;
this.vueApp.currentItemInfo = { loading: true };
this.vueApp.previewContent = '<div style="padding: 20px; text-align: center;">加载中...</div>';
this.vueApp.buttonsDisabled = true;
this.getXToolsInfo(item.mainTitle).then(info => {
if (this.vueApp && this.vueApp.currentItem && this.vueApp.currentItem.mainTitle === item.mainTitle) {
this.vueApp.currentItemInfo = info;
this.vueApp.$forceUpdate();
}
}).catch(error => {
if (this.vueApp && this.vueApp.currentItem && this.vueApp.currentItem.mainTitle === item.mainTitle) {
this.vueApp.currentItemInfo = {
error: true,
errorMessage: error.message || '获取统计信息失败'
};
}
});
try {
const preview = await this.getArticlePreview(item.mainTitle);
this.vueApp.previewContent = preview;
this.vueApp.buttonsDisabled = false;
} catch (error) {
this.vueApp.previewContent =
'<div style="color: red; padding: 20px;">加载条目失败: ' +
mw.html.escape(error.message) + '</div>';
this.vueApp.buttonsDisabled = false;
}
return true;
}
async handleRating(rating) {
if (!this.currentItem) {
return;
}
await this.saveQueue.add({
item: this.currentItem,
rating: rating
});
await this.showNextItem();
}
async handleSkip() {
await this.showNextItem();
}
async getXToolsInfo(pageName) {
function safeToLocaleString(num) {
if (typeof num === 'number' && !isNaN(num)) {
return num.toLocaleString();
}
return '0';
}
try {
const serverName = mw.config.get('wgServerName');
const encodedPageName = encodeURIComponent(pageName);
const apiUrl = `https://xtools.wmcloud.org/api/page/pageinfo/${serverName}/${encodedPageName}`;
const pageInfo = await $.get(apiUrl);
const project = pageInfo.project;
const pageEnc = encodeURIComponent(pageInfo.page);
const pageinfoUrl = `https://xtools.wmcloud.org/pageinfo/${project}/${pageEnc}`;
const createdDate = new Date(pageInfo.created_at).toISOString().split('T')[0];
const revisionsText = safeToLocaleString(pageInfo.revisions);
const editorsText = safeToLocaleString(pageInfo.editors);
const pageviewsText = safeToLocaleString(pageInfo.pageviews);
const days = Math.round(pageInfo.secs_since_last_edit / 86400);
return {
creator: pageInfo.creator,
createdDate: createdDate,
revisions: revisionsText,
editors: editorsText,
pageviews: pageviewsText,
daysSinceLastEdit: days,
pageviewsOffset: pageInfo.pageviews_offset,
pageinfoUrl: pageinfoUrl
};
} catch (error) {
return null;
}
}
cleanup() {
this.unbindKeyboardEvents();
if (this.vueApp) {
this.vueApp.unmount();
this.vueApp = null;
}
const mountPoint = document.getElementById('rating-dialog-mount');
if (mountPoint) {
mountPoint.remove();
}
}
async getArticlePreview(title) {
if (this.prefetchCache[title]) {
const cached = this.prefetchCache[title];
delete this.prefetchCache[title];
return cached;
}
try {
const response = await this.api.get({
action: 'parse',
page: title,
prop: 'text',
disablelimitreport: true,
format: 'json'
});
if (response.parse && response.parse.text) {
return response.parse.text['*'];
} else {
throw new Error('无法获取页面内容');
}
} catch (error) {
throw error;
}
}
async startPrefetching() {
const prefetchNext = async () => {
const startIdx = this.currentIndex;
const endIdx = Math.min(startIdx + CONFIG.prefetchCount, this.queue.length);
for (let i = startIdx; i < endIdx; i++) {
const item = this.queue[i];
if (item && !this.prefetchCache[item.mainTitle]) {
try {
const response = await this.api.get({
action: 'parse',
page: item.mainTitle,
prop: 'text',
disablelimitreport: true,
format: 'json'
});
if (response.parse && response.parse.text) {
this.prefetchCache[item.mainTitle] = response.parse.text['*'];
}
} catch (error) {
}
}
}
};
this.prefetchTimer = setInterval(prefetchNext, 2000);
prefetchNext();
}
}
class WPBSProcessor {
process(text, options) {
const result = {
text: text,
addedProvince: null,
importanceFilled: 0
};
// 查找WPBS模板
const wpbsMatch = this.findWPBS(text);
if (wpbsMatch) {
// 更新现有WPBS
result.text = this.updateWPBS(text, wpbsMatch, options, result);
} else if (CONFIG.enableAutoWPBS) {
// 创建新WPBS
result.text = this.createWPBS(text, options, result);
}
return result;
}
findWPBS(text) {
const patterns = [
/\{\{\s*WikiProject[\s_]+banner[\s_]+shell/i,
/\{\{\s*WPBS/i,
/\{\{\s*PJBS/i,
/\{\{\s*Wikiproject[\s_]+banner[\s_]+shell/i,
/\{\{\s*wikiproject[\s_]+banner[\s_]+shell/i
];
for (const pattern of patterns) {
const match = text.match(pattern);
if (match) {
const start = match.index;
const end = this.findTemplateEnd(text, start);
if (end > start) {
return {
start: start,
end: end,
content: text.substring(start, end + 2)
};
}
}
}
return null;
}
findTemplateEnd(text, start) {
let depth = 0;
let inTemplate = false;
try {
for (let i = start; i < text.length - 1; i++) {
if (text[i] === '{' && text[i + 1] === '{') {
depth++;
inTemplate = true;
i++;
} else if (text[i] === '}' && text[i + 1] === '}') {
depth--;
if (depth === 0 && inTemplate) {
return i + 1;
}
i++;
}
}
return text.length - 1;
} catch (error) {
return text.length - 1;
}
}
updateWPBS(text, wpbsMatch, options, result) {
const wpbsContent = wpbsMatch.content;
const parseResult = this.parseWPBSTemplate(wpbsContent);
if (!parseResult) {
return this.createWPBS(text, options, result);
}
const { templateName, templateBody } = parseResult;
const params = this.parseTemplateParams(templateBody);
params.class = options.classRating;
let projectsContent = '';
if (params['1'] !== undefined) {
projectsContent = params['1'];
} else {
const anonymousContent = this.extractAnonymousContent(templateBody);
if (anonymousContent) {
projectsContent = anonymousContent;
}
}
if (options.province) {
const updatedProjects = this.updateProvinceProject(projectsContent, options.province);
if (updatedProjects.added) {
result.addedProvince = options.province;
}
projectsContent = updatedProjects.content;
}
if (options.enableImportanceFill && projectsContent) {
const filled = this.fillImportance(projectsContent);
projectsContent = filled.text;
result.importanceFilled = filled.count;
}
let newWPBS = `{{${templateName}`;
if (params.class) {
newWPBS += ` |class=${params.class}`;
}
for (const [key, value] of Object.entries(params)) {
if (key !== 'class' && key !== '1' && key !== '_anonymous') {
newWPBS += ` |${key}=${value}`;
}
}
if (projectsContent) {
newWPBS += ` |1=\n${projectsContent.trim()}\n`;
}
newWPBS += '}}';
const beforeWPBS = text.substring(0, wpbsMatch.start);
const afterWPBS = text.substring(wpbsMatch.end + 1);
let preservedSpacing = '';
let cleanAfterWPBS = afterWPBS;
const newlineMatch = afterWPBS.match(/^\n+/);
if (newlineMatch) {
preservedSpacing = newlineMatch[0];
cleanAfterWPBS = afterWPBS.substring(newlineMatch[0].length);
}
return beforeWPBS + newWPBS + preservedSpacing + cleanAfterWPBS;
}
parseWPBSTemplate(wpbsContent) {
let content = wpbsContent.trim();
if (!content.startsWith('{{') || !content.endsWith('}}')) {
return null;
}
content = content.slice(2, -2).trim();
let templateName = '';
let templateBody = '';
let depth = 0;
let nameEnd = -1;
for (let i = 0; i < content.length; i++) {
const char = content[i];
const next = content[i + 1];
if (char === '{' && next === '{') {
depth++;
i++;
} else if (char === '}' && next === '}') {
depth--;
i++;
} else if (char === '|' && depth === 0) {
nameEnd = i;
break;
}
}
if (nameEnd === -1) {
templateName = content.trim();
templateBody = '';
} else {
templateName = content.substring(0, nameEnd).trim();
templateBody = content.substring(nameEnd).trim();
}
if (!templateName) {
return null;
}
return {
templateName: templateName,
templateBody: templateBody
};
}
extractAnonymousContent(templateBody) {
if (!templateBody || !templateBody.trim()) return '';
if (templateBody.trim().startsWith('|')) {
const parts = this.splitByTopLevelPipe(templateBody.substring(1));
for (let i = 0; i < parts.length; i++) {
const part = parts[i].trim();
if (part && !part.includes('=')) {
return part;
} else if (part && part.includes('=')) {
const eqIndex = part.indexOf('=');
const paramName = part.substring(0, eqIndex).trim();
if (paramName.includes('{{') || paramName.includes('}}')) {
return part;
}
}
}
} else {
if (!templateBody.includes('=') || this.isTemplateContent(templateBody)) {
return templateBody.trim();
}
}
return '';
}
isTemplateContent(text) {
return text.includes('{{') && text.includes('}}');
}
updateProvinceProject(projectsContent, province) {
const result = {
content: projectsContent,
added: false
};
const provinceRegex = new RegExp(`\\{\\{\\s*${province}(?:专题|專題)\\s*(?:\\||\\}\\})`, 'i');
if (provinceRegex.test(projectsContent)) {
result.content = this.ensureProjectImportance(projectsContent, province);
} else {
const newProject = `{{${province}专题|importance=Low}}`;
result.content = projectsContent ?
projectsContent.trim() + '\n' + newProject :
newProject;
result.added = true;
}
return result;
}
ensureProjectImportance(content, provinceName) {
const templates = this.findTopLevelTemplates(content);
let result = content;
templates.forEach(template => {
const isTargetProject = new RegExp(`\\{\\{\\s*${provinceName}(?:专题|專題)`, 'i')
.test(template.content);
if (isTargetProject && !/\|\s*importance\s*=/i.test(template.content)) {
const newContent = template.content.replace(/\}\}$/, '|importance=Low}}');
result = result.replace(template.content, newContent);
}
});
return result;
}
parseTemplateParams(templateBody) {
const params = {};
if (!templateBody || !templateBody.trim()) {
return params;
}
try {
// 移除开头的 | 如果有
const content = templateBody.trim().startsWith('|') ?
templateBody.trim().substring(1) :
templateBody.trim();
// 按顶层 | 分割
const parts = this.splitByTopLevelPipe(content);
let hasNamedParams = false;
let anonymousContent = '';
parts.forEach((part) => {
const trimmedPart = part.trim();
if (!trimmedPart) return;
const eqIndex = trimmedPart.indexOf('=');
// 检查是否是有效的参数(参数名不应包含模板标记)
if (eqIndex > 0) {
const paramName = trimmedPart.substring(0, eqIndex).trim();
// 检查参数名是否有效(不包含模板标记或特殊字符)
if (!paramName.includes('{{') && !paramName.includes('}}') &&
!paramName.includes('|') && !paramName.includes('\n')) {
// 这是一个具名参数
const paramValue = trimmedPart.substring(eqIndex + 1).trim();
params[paramName] = paramValue;
hasNamedParams = true;
} else {
// 这看起来像是内容,不是参数
if (anonymousContent) {
anonymousContent += '\n' + trimmedPart;
} else {
anonymousContent = trimmedPart;
}
}
} else {
// 没有等号,这是匿名内容
if (anonymousContent) {
anonymousContent += '\n' + trimmedPart;
} else {
anonymousContent = trimmedPart;
}
}
});
// 如果有匿名内容,将其作为参数1
if (anonymousContent) {
params['1'] = anonymousContent;
}
return params;
} catch (error) {
return params;
}
}
splitByTopLevelPipe(text) {
const parts = [];
let current = '';
let depth = 0;
let inLink = false;
try {
for (let i = 0; i < text.length; i++) {
const char = text[i];
const next = text[i + 1];
// 处理模板
if (char === '{' && next === '{') {
depth++;
current += char + next;
i++;
} else if (char === '}' && next === '}') {
depth--;
current += char + next;
i++;
}
// 处理链接
else if (char === '[' && next === '[') {
inLink = true;
current += char + next;
i++;
} else if (char === ']' && next === ']') {
inLink = false;
current += char + next;
i++;
}
// 顶层管道符
else if (char === '|' && depth === 0 && !inLink) {
parts.push(current);
current = '';
} else {
current += char;
}
}
if (current) {
parts.push(current);
}
return parts;
} catch (error) {
return [text];
}
}
fillImportance(content) {
let result = content;
let count = 0;
// 查找所有顶层模板
const templates = this.findTopLevelTemplates(content);
templates.forEach(template => {
// 跳过已有importance参数的模板
if (!/\|\s*importance\s*=/i.test(template.content)) {
// 在模板末尾添加importance参数
const newContent = template.content.replace(/\}\}$/, '|importance=Low}}');
result = result.replace(template.content, newContent);
count++;
}
});
return { text: result, count: count };
}
findTopLevelTemplates(text) {
const templates = [];
let depth = 0;
let start = -1;
for (let i = 0; i < text.length - 1; i++) {
if (text[i] === '{' && text[i + 1] === '{') {
if (depth === 0) {
start = i;
}
depth++;
i++;
} else if (text[i] === '}' && text[i + 1] === '}') {
depth--;
if (depth === 0 && start >= 0) {
templates.push({
start: start,
end: i + 1,
content: text.substring(start, i + 2)
});
start = -1;
}
i++;
}
}
return templates;
}
createWPBS(text, options, result) {
const params = {
'class': options.classRating
};
if (options.province) {
params['1'] = `{{${options.province}专题|importance=Low}}`;
result.addedProvince = options.province;
}
const wpbs = this.buildTemplate('WikiProject banner shell', params);
// 插入到页面顶部
return wpbs + '\n' + text;
}
buildTemplate(name, params) {
let template = `{{${name}`;
// 添加class参数
if (params['class']) {
template += ` |class=${params['class']}`;
}
// 添加其他参数
for (const [key, value] of Object.entries(params)) {
if (key !== 'class') {
if (key === '1') {
template += ` |1=\n${value}\n`;
} else {
template += ` |${key}=${value}`;
}
}
}
template += '}}';
return template;
}
}
$(document).ready(function () {
const tool = new CategoryRatingTool();
if (tool.canRun()) {
mw.loader.using(['mediawiki.util'], function () {
tool.addPortletLink();
});
}
});
})();