Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 94 additions & 18 deletions auto-attribution.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const path = require('path');
const prisma = require('./lib/prisma');
const config = require('./wikitdb.config.js');
const { fetchAttributionPage, aggregateAuthorScores, normalizePageKey } = require('./utils/attribution');
const { fetchCromSiteRanking, fetchCromSitePages, toCromHttpBase } = require('./utils/crom');

const httpAgent = new http.Agent({ keepAlive: true, maxSockets: 10 });
const httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 10 });
Expand Down Expand Up @@ -80,7 +81,7 @@ async function processSite(siteConfig) {
return;
}

// 1. 全量同步归属记录
// 1. 全量同步归属记录(用于详情页展示作者归属页面列表)
await prisma.$transaction([
prisma.authorAttribution.deleteMany({ where: { siteParam: wikiParam } }),
prisma.authorAttribution.createMany({
Expand All @@ -89,31 +90,106 @@ async function processSite(siteConfig) {
})
]);

// 2. 获取页面评分(listpages)
const allPages = await fetchAllPages(siteConfig);
const baseUrl = siteConfig.URL.replace(/\/$/, '');
const cromEnabled = !!siteConfig.CROM_API;
const cromHttpBase = cromEnabled ? toCromHttpBase(baseUrl) : '';

// 2. 对开启 CROM_API 的站点:统一使用 crom 作为权威源
// - fetchCromSiteRanking: 作者排行榜 (author_score)
// - fetchCromSitePages : 页面评分 + 搜索索引 (page_scores / pages_index)
// 未开启或 crom 失败:fallback 到 listpages + 归属页聚合
let cromScores = null;
let pageScoreRows = null;
let pagesIndexRows = null;
let allPages = [];
let scoreSource = 'attribution';
let pagesSource = 'listpages';

if (cromEnabled) {
// 串行(先排行再页面)+ 保守并发,避免同时触发 crom 服务器限流
// (crom complexity + 频率检查叠加后,并发跑两边经常被 ban 几十秒)
try {
const data = await fetchCromSiteRanking(baseUrl, {
request,
endpoint: siteConfig.CROM_API,
concurrency: 3,
batchSleepMs: 500,
onPage: (p) => { if (p.rank % 100 === 1) logLine(`[归属] ${wikiParam} crom 排行 rank=${p.rank} 已得 ${p.fetched} 作者`); }
});
if (data && Object.keys(data).length > 0) {
cromScores = data;
scoreSource = 'crom';
}
} catch (e) {
logLine(`[归属] ${wikiParam} crom 作者排行失败: ${e.message}`);
}
try {
const data = await fetchCromSitePages(cromHttpBase, {
request,
endpoint: siteConfig.CROM_API,
pageSize: 50,
batchSleepMs: 300,
onProgress: (p) => { if (p.pageNum % 10 === 1) logLine(`[归属] ${wikiParam} crom 页面已拉 ${p.pages} 条 (第${p.pageNum}批)`); }
});
if (data && data.scores && data.scores.length > 0) {
pageScoreRows = data.scores;
pagesIndexRows = data.index;
pagesSource = 'crom';
}
} catch (e) {
logLine(`[归属] ${wikiParam} crom 页面抓取失败: ${e.message}`);
}
}

// 3. 保存页面评分
const pageScoreRows = allPages.map(p => ({
page: p.page, title: p.title, rating: p.rating,
upvotes: p.upvotes, downvotes: p.downvotes
}));
await prisma.setting.upsert({
where: { key: `page_scores:${wikiParam}` },
update: { value: JSON.stringify(pageScoreRows) },
create: { key: `page_scores:${wikiParam}`, value: JSON.stringify(pageScoreRows) }
});
// 3. crom 未启用或失败时,fallback 到 listpages 拿页面评分
if (!pageScoreRows) {
allPages = await fetchAllPages(siteConfig).catch(e => {
logLine(`[归属] ${wikiParam} listpages 抓取失败: ${e.message}`);
return [];
});
pageScoreRows = allPages.map(p => ({
page: p.page, title: p.title, rating: p.rating,
upvotes: p.upvotes, downvotes: p.downvotes
}));
}

// 4. 保存页面评分(page_scores 用于详情页页面列表)
if (pageScoreRows && pageScoreRows.length > 0) {
await prisma.setting.upsert({
where: { key: `page_scores:${wikiParam}` },
update: { value: JSON.stringify(pageScoreRows) },
create: { key: `page_scores:${wikiParam}`, value: JSON.stringify(pageScoreRows) }
});
}

// 4b. 保存搜索索引(crom 源才保存,因为 listpages 不含 component/theme/art 等页,搜索本来就不全;
// 非 crom 站点仍走 Wikit GraphQL articles 接口)
if (pagesIndexRows && pagesIndexRows.length > 0) {
await prisma.setting.upsert({
where: { key: `pages_index:${wikiParam}` },
update: { value: JSON.stringify(pagesIndexRows) },
create: { key: `pages_index:${wikiParam}`, value: JSON.stringify(pagesIndexRows) }
});
}

// 4. 按归属聚合作者分数(每个归属用户获得页面全分)
const pageRatings = new Map();
for (const pg of allPages) pageRatings.set(normalizePageKey(pg.page), { rating: pg.rating });
const authorScores = aggregateAuthorScores(attrRecords, pageRatings);
// 5. 生成 author_score:
// 优先 crom(含组件/版式/艺术等所有归属页面,与作者页/官方排行一致);
// crom 不可用时 fallback 到归属页 + listpages 聚合。
let authorScores;
if (cromScores) {
authorScores = cromScores;
} else {
const pageRatings = new Map();
for (const pg of (pageScoreRows || [])) pageRatings.set(normalizePageKey(pg.page), { rating: pg.rating });
authorScores = aggregateAuthorScores(attrRecords, pageRatings);
}
await prisma.setting.upsert({
where: { key: `author_score:${wikiParam}` },
update: { value: JSON.stringify(authorScores) },
create: { key: `author_score:${wikiParam}`, value: JSON.stringify(authorScores) }
});

logLine(`[归属] ${wikiParam} 归属 ${attrRecords.length} 条 | 页面评分 ${allPages.length} | 作者 ${Object.keys(authorScores).length} 位`);
logLine(`[归属] ${wikiParam} 归属 ${attrRecords.length} 条 | 页面评分 ${pageScoreRows.length}(来源: ${pagesSource}) | 作者 ${Object.keys(authorScores).length} 位(来源: ${scoreSource})`);
}

let isRunning = false;
Expand Down
38 changes: 27 additions & 11 deletions auto-crawler.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ const { buildBackupArgs, getWikiName } = require('./utils/wikitBackup');
const fs = require('fs');
const path = require('path');

const httpAgent = new http.Agent({ keepAlive: true, maxSockets: 10 });
const httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 10 });
const httpAgent = new http.Agent({ keepAlive: true, maxSockets: 20 });
const httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 20 });
const request = axios.create({
httpAgent,
httpsAgent,
Expand All @@ -20,6 +20,9 @@ const request = axios.create({

const sleep = ms => new Promise(r => setTimeout(r, ms));

// 跨页面/跨站点复用 UserLookup 结果,避免对同一作者反复查询 wikidot
let userIdCache = {};

const CRAWLER_LOG_FILE = path.join(process.cwd(), 'crawler.log');

/** 同时输出到控制台和 crawler.log,方便管理后台查看 */
Expand Down Expand Up @@ -130,13 +133,22 @@ async function runCrawler() {
finishedAt: null,
currentSite: null,
currentStage: '',
overall: { totalSites: config.SUPPORT_WIKI.length, doneSites: 0 },
overall: { totalSites: config.SUPPORT_WIKI.filter(s => !s.CROM_API).length, doneSites: 0 },
sites: config.SUPPORT_WIKI.map(buildSiteStatus),
lastRun: crawlStatus.lastRun
};
await persistCrawlStatus();
for (const siteConfig of config.SUPPORT_WIKI) {
const wikiParam = siteConfig.PARAM;

// crom 站点由 auto-attribution.js 独立处理(crom API 拿作者分数 + 页面评分),
// auto-crawler 逐页爬取对 8k+ 页面的站点效率极低且会与 attribution 争抢 DB 连接,
// 因此跳过已配置 CROM_API 的站点,仅保留非 crom 站点的讨论区/投票抓取
if (siteConfig.CROM_API) {
logLine(`[${new Date().toLocaleString()}] 跳过 ${wikiParam}(crom 站点,由 attribution 服务处理)`);
continue;
}

const actualWikiName = siteConfig.URL.replace(/^https?:\/\//i, '').split('.')[0];
const baseUrl = siteConfig.URL.replace(/\/$/, '');

Expand Down Expand Up @@ -186,7 +198,7 @@ async function runCrawler() {
} catch (e) {
await sleep(3000);
}
await sleep(1000);
await sleep(300);
}

if (siteStatus) {
Expand All @@ -197,7 +209,7 @@ async function runCrawler() {

let userVotesMap = {};
let count = 0;
const CONCURRENCY = 3;
const CONCURRENCY = 5;

for (let i = 0; i < allPages.length; i += CONCURRENCY) {
const batch = allPages.slice(i, i + CONCURRENCY);
Expand Down Expand Up @@ -234,7 +246,6 @@ async function runCrawler() {
const { data: forumHtml } = await request.get(forumUrl, { headers: baseHeaders });
const $forum = cheerio.load(forumHtml);
const posts = [];
const userIdCache = {};

const postElements = $forum('.post').toArray();
for (const el of postElements) {
Expand Down Expand Up @@ -276,7 +287,7 @@ async function runCrawler() {
userid = userIdCache[author];
} else {
try {
const lookupRes = await axios.get(`https://www.wikidot.com/quickmodule.php?module=UserLookupQModule&q=${encodeURIComponent(author)}`, { timeout: 5000 });
const lookupRes = await axios.get(`https://www.wikidot.com/quickmodule.php?module=UserLookupQModule&q=${encodeURIComponent(author)}`, { timeout: 3000 });
if (lookupRes.data && lookupRes.data.users && lookupRes.data.users.length > 0) {
userid = lookupRes.data.users[0].user_id;
userIdCache[author] = userid;
Expand Down Expand Up @@ -390,7 +401,7 @@ async function runCrawler() {
siteStatus.errors = siteErrors;
await persistCrawlStatus();
}
for (const [user, newVotes] of Object.entries(userVotesMap)) {
await Promise.all(Object.entries(userVotesMap).map(async ([user, newVotes]) => {
const key = `user_votes_${user.toLowerCase().replace(/_/g, '-').replace(/ /g, '-')}`;
const record = await prisma.setting.findUnique({ where: { key } });
let existingMap = new Map();
Expand All @@ -417,10 +428,10 @@ async function runCrawler() {
update: { value: JSON.stringify(truncatedVotes) },
create: { key, value: JSON.stringify(truncatedVotes) }
});
}
}));
userVotesMap = {};
}
await sleep(2500);
await sleep(600);
}

if (siteStatus) {
Expand Down Expand Up @@ -459,7 +470,12 @@ async function runCrawler() {
}

cron.schedule('0 */3 * * *', () => runCrawler());
runCrawler();
// 启动时是否立即执行一轮全量抓取。
// 默认开启(与原行为一致);低内存服务器建议在 systemd 里设 RUN_CRAWLER_ON_START=false,
// 避免「抓取 → OOM 被杀 → 重启 → 再抓取」的循环把整个服务器拖垮。
if (String(process.env.RUN_CRAWLER_ON_START).toLowerCase() !== 'false') {
runCrawler();
}

let isBackupRunning = false;

Expand Down
91 changes: 82 additions & 9 deletions components/AuthorActivityChart.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,95 @@ import React, { useEffect, useRef, useState } from 'react';
// 核心修复:直接使用 auto 全自动注册,彻底解决漏引组件导致的致命闪退
import Chart from 'chart.js/auto';

export default function AuthorActivityChart({ data = [] }) {
/**
* 把「单页面」数组(每篇一页,含 created_at + rating)按月聚合成
* 组件绘图所需的 { date: YYYY-MM, pages: 发布数, rating: 当月评分和 } 结构。
*/
function aggregateByMonth(pages) {
if (!Array.isArray(pages) || pages.length === 0) return [];
const bucket = new Map(); // YYYY-MM -> { pages, rating }
for (const p of pages) {
const rawDate = p && (p.created_at || p.createdAt || p.date);
let d;
if (!rawDate) continue;
// created_at 可能是 "YYYY-MM-DDTHH:mm:ss" 或 "YYYY-MM-DD"
const s = String(rawDate);
const first = s.split(/[T\s]/)[0]; // YYYY-MM-DD
const parts = first.split('-');
if (parts.length < 2) {
// 尝试 Date 兜底解析
d = new Date(s);
if (isNaN(d.getTime())) continue;
parts = [d.getFullYear(), d.getMonth() + 1];
}
const yyyy = Number(parts[0]);
const mm = Number(parts[1]);
if (!yyyy || !mm) continue;
const key = `${yyyy}-${String(mm).padStart(2, '0')}`;
if (!bucket.has(key)) bucket.set(key, { pages: 0, rating: 0 });
const b = bucket.get(key);
b.pages += 1;
const r = Number(p && p.rating != null ? p.rating : 0) || 0;
b.rating += r;
}
const arr = Array.from(bucket.entries()).map(([date, v]) => ({
date,
pages: v.pages,
rating: Math.round(v.rating * 100) / 100
}));
arr.sort((a, b) => String(a.date).localeCompare(String(b.date)));
return arr;
}

export default function AuthorActivityChart({ pages = [], data /* 兼容老调用 */ }) {
const canvasRef = useRef(null);
const chartInstance = useRef(null);
const [chartError, setChartError] = useState(null);

// 优先使用 pages(调用方 pages/authors.js 传 pages={data.pages}),
// 若外部传了已聚合好的 data 则直接用(向前兼容)
const rawInput = Array.isArray(pages) && pages.length > 0 ? pages : (data || []);

useEffect(() => {
setChartError(null);
if (!canvasRef.current || !data || data.length === 0) return;
if (!canvasRef.current) return;
if (!rawInput || rawInput.length === 0) return;

if (chartInstance.current) {
chartInstance.current.destroy();
chartInstance.current = null;
}

try {
// 格式必须为 { date: "YYYY-MM", pages: num, rating: num }
const sortedData = [...data].sort((a, b) => String(a.date).localeCompare(String(b.date)));

// 先判断输入是否已经是「按月聚合」的 {date, pages, rating},
// 还是「单页列表」(需要聚合)
const firstItem = rawInput[0] || {};
const alreadyAggregated =
typeof firstItem === 'object' &&
firstItem !== null &&
typeof (firstItem.date || firstItem.month) === 'string' &&
/\d{4}-\d{2}/.test(firstItem.date || firstItem.month) &&
('pages' in firstItem || 'pageCount' in firstItem || 'rating' in firstItem);

// 格式要求:{ date: "YYYY-MM", pages: num, rating: num }
const monthly = alreadyAggregated
? rawInput.map(x => ({
date: String(x.date || x.month).slice(0, 7),
pages: Number(x.pages != null ? x.pages : x.pageCount) || 0,
rating: Number(x.rating != null ? x.rating : 0) || 0
}))
: aggregateByMonth(rawInput);

if (!monthly.length) return;

const sortedData = [...monthly].sort((a, b) => String(a.date).localeCompare(String(b.date)));

const minDateStr = sortedData[0].date;
const maxDateStr = sortedData[sortedData.length - 1].date;

let [minY, minM] = minDateStr.split('-').map(Number);
let [maxY, maxM] = maxDateStr.split('-').map(Number);
let [minY, minM] = (minDateStr || '').split('-').map(Number);
let [maxY, maxM] = (maxDateStr || '').split('-').map(Number);
if (!minY || !minM || !maxY || !maxM) return;

const labels = [];
const pagesData = [];
Expand Down Expand Up @@ -103,7 +170,13 @@ export default function AuthorActivityChart({ data = [] }) {
y: {
beginAtZero: true,
grid: { color: 'rgba(255, 255, 255, 0.05)' },
ticks: { color: 'rgb(110, 118, 129)', stepSize: 1 }
ticks: {
color: 'rgb(110, 118, 129)',
stepSize: (() => {
const maxP = Math.max(1, ...pagesData);
return maxP <= 10 ? 1 : undefined;
})()
}
}
}
}
Expand All @@ -113,7 +186,7 @@ export default function AuthorActivityChart({ data = [] }) {
setChartError(err.message);
}

}, [data]);
}, [rawInput]);

return (
<div className="w-full h-full relative min-h-[260px]">
Expand Down
1 change: 1 addition & 0 deletions content/about.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ WikitDB 是一个 **面向 Wikidot 社区生态的非营利性同人项目**,
| 世界机构联合体 | pin | 世界观共创 |
| SCP 基金会 Minecraft 分部 | scp-wiki-mc | SCP + MC 融合 |
| The Backrooms 中文维基 | brcn | Backrooms 主分社区 |
| The Nationarea | na | 架空国家地区世界观创作 |

> 如需申请收录更多站点,请通过社区渠道联系管理员。

Expand Down
Loading