diff --git a/auto-attribution.js b/auto-attribution.js
index b48cf8b..1f6ef00 100644
--- a/auto-attribution.js
+++ b/auto-attribution.js
@@ -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 });
@@ -80,7 +81,7 @@ async function processSite(siteConfig) {
return;
}
- // 1. 全量同步归属记录
+ // 1. 全量同步归属记录(用于详情页展示作者归属页面列表)
await prisma.$transaction([
prisma.authorAttribution.deleteMany({ where: { siteParam: wikiParam } }),
prisma.authorAttribution.createMany({
@@ -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;
diff --git a/auto-crawler.js b/auto-crawler.js
index cb4d9dd..18ba96e 100644
--- a/auto-crawler.js
+++ b/auto-crawler.js
@@ -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,
@@ -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,方便管理后台查看 */
@@ -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(/\/$/, '');
@@ -186,7 +198,7 @@ async function runCrawler() {
} catch (e) {
await sleep(3000);
}
- await sleep(1000);
+ await sleep(300);
}
if (siteStatus) {
@@ -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);
@@ -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) {
@@ -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;
@@ -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();
@@ -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) {
@@ -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;
diff --git a/components/AuthorActivityChart.js b/components/AuthorActivityChart.js
index d599218..3bc27c4 100644
--- a/components/AuthorActivityChart.js
+++ b/components/AuthorActivityChart.js
@@ -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 = [];
@@ -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;
+ })()
+ }
}
}
}
@@ -113,7 +186,7 @@ export default function AuthorActivityChart({ data = [] }) {
setChartError(err.message);
}
- }, [data]);
+ }, [rawInput]);
return (
diff --git a/content/about.md b/content/about.md
index 198b3f3..8a660ed 100644
--- a/content/about.md
+++ b/content/about.md
@@ -58,6 +58,7 @@ WikitDB 是一个 **面向 Wikidot 社区生态的非营利性同人项目**,
| 世界机构联合体 | pin | 世界观共创 |
| SCP 基金会 Minecraft 分部 | scp-wiki-mc | SCP + MC 融合 |
| The Backrooms 中文维基 | brcn | Backrooms 主分社区 |
+| The Nationarea | na | 架空国家地区世界观创作 |
> 如需申请收录更多站点,请通过社区渠道联系管理员。
diff --git a/lib/prisma.js b/lib/prisma.js
index adcaac4..68e4410 100644
--- a/lib/prisma.js
+++ b/lib/prisma.js
@@ -2,11 +2,20 @@ const { PrismaClient } = require('@prisma/client');
let prismaBase;
+function withPool(url) {
+ const sep = url.includes('?') ? '&' : '?';
+ return url + sep + 'connection_limit=20&pool_timeout=30';
+}
+
if (process.env.NODE_ENV === 'production') {
- prismaBase = new PrismaClient();
+ prismaBase = new PrismaClient({
+ datasources: { db: { url: withPool(process.env.DATABASE_URL) } }
+ });
} else {
if (!global.__prisma) {
- global.__prisma = new PrismaClient();
+ global.__prisma = new PrismaClient({
+ datasources: { db: { url: withPool(process.env.DATABASE_URL) } }
+ });
}
prismaBase = global.__prisma;
}
diff --git a/pages/admin.js b/pages/admin.js
index f406588..22974c2 100644
--- a/pages/admin.js
+++ b/pages/admin.js
@@ -383,6 +383,7 @@ export default function AdminDashboard() {
done: ['bg-emerald-950 text-emerald-300 border-emerald-800', '完成'],
pending: ['bg-neutral-800 text-neutral-400 border-neutral-700', '等待'],
error: ['bg-red-950 text-red-300 border-red-800', '异常'],
+ skipped: ['bg-sky-950 text-sky-300 border-sky-800', '归属服务'],
};
const [cls, label] = map[status] || map.pending;
return
{label};
diff --git a/pages/api/admin/crawler-status.js b/pages/api/admin/crawler-status.js
index ba4c40a..4f91a44 100644
--- a/pages/api/admin/crawler-status.js
+++ b/pages/api/admin/crawler-status.js
@@ -29,7 +29,31 @@ async function handler(req, res) {
const merged = configuredSites.map(cfg => {
const existing = recorded.find(s => s && s.param === cfg.PARAM);
- if (existing) return { ...existing, name: cfg.NAME || existing.name };
+ if (existing) {
+ // crom 站点由 attribution 服务处理,auto-crawler 跳过;
+ // 显示为 'skipped' 避免用户困惑(否则会一直显示 pending)
+ if (cfg.CROM_API) {
+ return { ...existing, status: 'skipped', name: cfg.NAME || existing.name };
+ }
+ return { ...existing, name: cfg.NAME || existing.name };
+ }
+ // 新站点:crom 标记的直接显示为 skipped
+ if (cfg.CROM_API) {
+ return {
+ param: cfg.PARAM,
+ name: cfg.NAME || cfg.PARAM,
+ status: 'skipped',
+ pagesFound: 0,
+ pagesProcessed: 0,
+ votes: 0,
+ discussions: 0,
+ errors: 0,
+ startedAt: null,
+ finishedAt: null,
+ lastRun: null,
+ error: null
+ };
+ }
return {
param: cfg.PARAM,
name: cfg.NAME || cfg.PARAM,
diff --git a/pages/api/authors.js b/pages/api/authors.js
index 003795d..b7fc36a 100644
--- a/pages/api/authors.js
+++ b/pages/api/authors.js
@@ -23,8 +23,61 @@ async function handler(req, res) {
const queryName = name.trim();
const cacheKey = `author:${queryName.toLowerCase()}`;
- const authorData = await singleFlight(cacheKey, () =>
- cached(cacheKey, async () => {
+ // DEBUG MODE: 用 __diag=1 绕过缓存直接输出 attribution join 诊断信息
+ if (req.query.__diag === '1') {
+ const lower = queryName.toLowerCase();
+ const resp = {};
+ resp.queryName = queryName;
+ resp.lower = lower;
+
+ const scoreSettings = await prisma.setting.findMany({ where: { key: { startsWith: 'author_score:' } } }).catch(() => []);
+ resp.author_score_keys = scoreSettings.map(s => s.key);
+ const relevantSiteParams = new Set();
+ const matchedScores = [];
+ for (const s of scoreSettings) {
+ const sp = s.key.replace('author_score:', '');
+ const siteCfg = config.SUPPORT_WIKI.find(w => w.PARAM === sp);
+ if (siteCfg && !siteCfg.ATTRIBUTION_PAGE && !siteCfg.CROM_API) continue;
+ const obj = s.value || {};
+ const e = obj[lower];
+ if (e) {
+ matchedScores.push({ siteParam: sp, entry: e });
+ relevantSiteParams.add(sp);
+ }
+ }
+ resp.author_score_matches = matchedScores;
+ resp.relevantSiteParams = Array.from(relevantSiteParams);
+
+ const attrRows = await prisma.authorAttribution.findMany({ where: { username: lower } }).catch(e => ({ error: e.message }));
+ resp.author_attributions_all_sites = Array.isArray(attrRows) ? {
+ total: attrRows.length,
+ bySiteParamCount: attrRows.reduce((acc, r) => { acc[r.siteParam] = (acc[r.siteParam]||0) + 1; return acc; }, {}),
+ sample: attrRows.slice(0, 12)
+ } : attrRows;
+
+ if (Array.isArray(attrRows) && relevantSiteParams.size > 0) {
+ const filtered = attrRows.filter(r => relevantSiteParams.has(r.siteParam));
+ resp.author_attributions_filtered = {
+ count: filtered.length,
+ sample: filtered.slice(0, 12)
+ };
+ const neededScoreKeys = Array.from(new Set(filtered.map(r => `page_scores:${r.siteParam}`)));
+ resp.neededPageScoreKeys = neededScoreKeys;
+ const pageScoreSettings = neededScoreKeys.length > 0
+ ? await prisma.setting.findMany({ where: { key: { in: neededScoreKeys } } }).catch(e => [])
+ : [];
+ resp.page_scores_summary = pageScoreSettings.map(ps => {
+ const sp = ps.key.replace('page_scores:', '');
+ const arr = Array.isArray(ps.value) ? ps.value : [];
+ return { key: ps.key, count: arr.length, sample: arr.slice(0, 3).map(r => ({page:r.page, title:r.title, rating:r.rating})) };
+ });
+ }
+ return res.status(200).json(resp);
+ }
+
+ // DEBUG BYPASS: __nocache=1 强制跳过 singleFlight + TTL 缓存,实时重算(用于 build 升级后缓存清旧版)
+ const bypassCache = req.query.__nocache === '1';
+ const authorCompute = async () => {
const accountName = queryName.toLowerCase().replace(/_/g, '-').replace(/ /g, '-');
let globalRank = '无记录';
@@ -121,18 +174,26 @@ async function handler(req, res) {
}
// 归属分数(作者名下,跨站点聚合自站点归属资料页)
+ // 仅统计配置了归属资料页 / CROM_API 的站点(如 brcn、na),其他站点(rule、dfc 等)走 Wikit
let attribution = { score: 0, pages: 0, average: 0, sites: [] };
let fromAttribution = false;
+ // 归属页面清单:从 author_attributions + page_scores 合并而来,
+ // 用于「所有发布页面」列表 + 作者活力图(即使是归属-only 作者也能看到东西)
+ let attributionPages = [];
try {
const scoreSettings = await prisma.setting.findMany({ where: { key: { startsWith: 'author_score:' } } });
const sites = [];
let totalScore = 0, totalAttPages = 0;
+ // 收集需要拉归属页面清单的站点 PARAM 集合
+ const relevantSiteParams = new Set();
for (const s of scoreSettings) {
const siteParam = s.key.replace('author_score:', '');
+ const siteCfg = config.SUPPORT_WIKI.find(w => w.PARAM === siteParam);
+ // 跳过未配置 crom / 归属页的站点
+ if (siteCfg && !siteCfg.ATTRIBUTION_PAGE && !siteCfg.CROM_API) continue;
const scores = s.value; // lib/prisma 已自动解析为对象
const entry = scores && scores[queryName.toLowerCase()];
if (entry) {
- const siteCfg = config.SUPPORT_WIKI.find(w => w.PARAM === siteParam);
sites.push({
site: siteParam,
siteName: siteCfg ? siteCfg.NAME : siteParam,
@@ -142,6 +203,7 @@ async function handler(req, res) {
});
totalScore += entry.score || 0;
totalAttPages += entry.pages || 0;
+ relevantSiteParams.add(siteParam);
}
}
attribution = {
@@ -151,11 +213,258 @@ async function handler(req, res) {
sites
};
if (totalAttPages > 0) fromAttribution = true;
+
+ // ===== 构建归属页面清单(author_attributions + author_score.pageNames + 可选 CROM 实时补全) =====
+ // 先读 page_scores:${siteParam} 所有需要的站点,一次性缓存,避免重复读
+ // 也一次性建立 siteParam -> WIKIT_ID 映射
+ const paramToWikiId = new Map(); // siteParam -> WIKIT_ID
+ const paramToSiteCfg = new Map();
+ for (const w of config.SUPPORT_WIKI) {
+ paramToWikiId.set(w.PARAM, w.WIKIT_ID || w.PARAM);
+ paramToSiteCfg.set(w.PARAM, w);
+ }
+ // 预先打包每个站点的 author_score 作者条目(包含 pageNames 字段)
+ const entryBySite = new Map(); // siteParam -> entry object
+ const scoreSettingsBySp = new Map();
+ for (const s of scoreSettings) {
+ const sp = s.key.replace('author_score:', '');
+ scoreSettingsBySp.set(sp, s);
+ const scores = s.value || {};
+ const entry = scores[queryName.toLowerCase()];
+ if (entry && relevantSiteParams.has(sp)) entryBySite.set(sp, entry);
+ }
+ if (relevantSiteParams.size > 0) {
+ // 1. author_attributions 表(来自归属资料页 scrape 后落库的每条作者-页面-角色三元组)
+ const attrRows = await prisma.authorAttribution.findMany({
+ where: {
+ username: queryName.toLowerCase(),
+ siteParam: { in: Array.from(relevantSiteParams) }
+ }
+ }).catch(e => { console.error('[authors] 查归属页面清单失败:', e.message); return []; });
+
+ // 把需要 page_scores 查询的站点 PARAM 收集齐:
+ // - attrRows 中出现过的站点
+ // - 拥有 author_score.entry.pageNames[] 的站点(即使没 author_attributions 行也要查 page_scores 拼 title)
+ const needPageScoreFor = new Set();
+ for (const r of (attrRows || [])) needPageScoreFor.add(r.siteParam);
+ for (const [sp, entry] of entryBySite) {
+ if (Array.isArray(entry.pageNames) && entry.pageNames.length > 0) needPageScoreFor.add(sp);
+ }
+ const pageScoreKeys = Array.from(needPageScoreFor).map(sp => `page_scores:${sp}`);
+ const pageScoreSettings = pageScoreKeys.length > 0
+ ? await prisma.setting.findMany({ where: { key: { in: pageScoreKeys } } }).catch(() => [])
+ : [];
+ const pageScoreBySite = new Map(); // siteParam -> Map(pageSlug -> {title, rating, upvotes, downvotes})
+ const pageScoreIndexBySite = new Map(); // siteParam -> Map(pageSlug -> createdAt ISO)
+ for (const ps of pageScoreSettings) {
+ const sp = ps.key.replace('page_scores:', '');
+ const arr = Array.isArray(ps.value) ? ps.value : [];
+ const m = new Map();
+ for (const r of arr) {
+ const slug = String(r && (r.page || '')).trim();
+ if (!slug) continue;
+ m.set(slug.toLowerCase(), {
+ title: String(r.title || r.page || slug),
+ rating: Number(r.rating != null ? r.rating : 0) || 0,
+ upvotes: Number(r.upvotes != null ? r.upvotes : 0) || 0,
+ downvotes: Number(r.downvotes != null ? r.downvotes : 0) || 0
+ });
+ }
+ pageScoreBySite.set(sp, m);
+ }
+ // 查 pages_index(若有)拿到页面 createdAt 精确时间(用于活力图时间轴)
+ try {
+ const idxKeys = Array.from(needPageScoreFor).map(sp => `pages_index:${sp}`);
+ if (idxKeys.length) {
+ const idxSettings = await prisma.setting.findMany({ where: { key: { in: idxKeys } } }).catch(() => []);
+ for (const ix of idxSettings) {
+ const sp = ix.key.replace('pages_index:', '');
+ const arr = Array.isArray(ix.value) ? ix.value : [];
+ const mm = new Map();
+ for (const r of arr) {
+ const slug = String(r && (r.page || '')).trim();
+ if (!slug || !r.createdAt) continue;
+ mm.set(slug.toLowerCase(), String(r.createdAt));
+ }
+ pageScoreIndexBySite.set(sp, mm);
+ }
+ }
+ } catch (_) { /* 忽略 pages_index 不影响功能 */ }
+
+ const seen = new Set(); // `${sp}|${slugNorm}`
+ const pushPage = (sp, pageSlug, type, dateIsh, fallbackCreatedAt) => {
+ if (!pageSlug) return;
+ const slugNorm = String(pageSlug).trim().toLowerCase();
+ if (!slugNorm) return;
+ const dedupeKey = `${sp}|${slugNorm}`;
+ if (seen.has(dedupeKey)) return;
+ seen.add(dedupeKey);
+ const scoreMap = pageScoreBySite.get(sp);
+ const scoreInfo = scoreMap ? (scoreMap.get(slugNorm) || scoreMap.get(String(pageSlug).trim())) : null;
+ const idxMap = pageScoreIndexBySite.get(sp);
+ const idxDate = idxMap ? (idxMap.get(slugNorm) || idxMap.get(String(pageSlug).trim())) : null;
+ const wikiId = paramToWikiId.get(sp) || sp;
+ // created_at 优先级:pages_index.createdAt(精确) > 归属行 date (YYYY-MM-DD) > 归属行 createdAt > fallback > 空串
+ let isoDate = idxDate && String(idxDate).trim();
+ if (!isoDate) isoDate = dateIsh && String(dateIsh).trim();
+ if (isoDate && /^\d{4}-\d{1,2}-\d{1,2}$/.test(isoDate)) isoDate = `${isoDate}T00:00:00+00:00`;
+ if (!isoDate && fallbackCreatedAt) isoDate = fallbackCreatedAt;
+ if (isoDate && isoDate.startsWith('/')) isoDate = '';
+ // 安全兜底:不能 new Date().getTime() 出 NaN
+ try { if (isoDate && isNaN(new Date(isoDate).getTime())) isoDate = ''; } catch (_) { isoDate = ''; }
+ const title = scoreInfo && scoreInfo.title ? scoreInfo.title : String(pageSlug);
+ const rating = scoreInfo ? scoreInfo.rating : 0;
+ attributionPages.push({
+ page: String(pageSlug),
+ title,
+ wiki: wikiId,
+ rating,
+ created_at: isoDate,
+ _attributionType: type || '作者',
+ _attributionSiteParam: sp
+ });
+ };
+
+ // A: author_attributions 逐条入列
+ for (const r of (attrRows || [])) {
+ const fallback = r.createdAt ? new Date(r.createdAt).toISOString() : '';
+ pushPage(r.siteParam, r.page, r.type || '作者', r.date || '', fallback);
+ }
+
+ // B: author_score entry.pageNames 补全(fallback 聚合路径: rule 等非 CROM 站点通常有此字段)
+ // 解决:作者归属贡献页补录了但 author_attributions 未写入(罕见)或被抹掉的情况
+ for (const [sp, entry] of entryBySite) {
+ const pns = Array.isArray(entry.pageNames) ? entry.pageNames : [];
+ for (const pageName of pns) {
+ // date 未知:留空(后续若有 pages_index.createdAt 会被优先用)
+ pushPage(sp, pageName, '作者', '', null);
+ }
+ }
+
+ // C: 对 CROM-authoritative 站点(brcn/na),若物理归属行数不足 crom 统计 aggregate,则尝试 LIVE CROM 查该作者作品页清单
+ // 注意:CROM 强要求 User-Agent 头;有复杂限流(返回 "Please wait for N seconds")
+ // 必须按建议时间 sleep 后重试,否则一律被 ban 拿不到任何页面。每作者最多 3 次重试。
+ const cromEntries = [];
+ for (const [sp, entry] of entryBySite) {
+ const siteCfg = paramToSiteCfg.get(sp);
+ if (siteCfg && siteCfg.CROM_API) {
+ const aggregatePages = Number(entry.pages || 0) | 0;
+ const currentForSite = attributionPages.filter(p => p._attributionSiteParam === sp).length;
+ if (aggregatePages > 0 && currentForSite < aggregatePages) {
+ cromEntries.push({ sp, entry, siteCfg, aggregatePages, currentForSite });
+ }
+ }
+ }
+ if (cromEntries.length > 0) {
+ const throttleRe = /too often|rate.?limit|Please wait for/i;
+ const waitRe = /wait for (\d+) seconds?/i;
+ try {
+ const cromRequests = cromEntries.map(async ({ sp, entry, siteCfg }) => {
+ const cromHttpBase = (siteCfg.URL || '').replace(/\/+$/, '').replace(/^https:/, 'http:');
+ const uname = entry.unixName || entry.name || queryName;
+ // CROM complexity 上限 600:first=500 + wikidotInfo 3 字段 ≈ 1000 直接被拒。
+ // 与 utils/crom.js fetchCromSitePages 一致使用 first=50,配合 hasNextPage+endCursor 分页循环拉全量。
+ const q = `{ user(name: ${JSON.stringify(String(uname))}) { attributedPages(first: 50, filter: {url: {startsWith: ${JSON.stringify(cromHttpBase + '/')}}}) { edges { node { url wikidotInfo { title rating createdAt } } } pageInfo { hasNextPage endCursor } } } }`;
+ const endpoint = siteCfg.CROM_API || 'https://api.crom.avn.sh/graphql';
+ const headers = {
+ 'User-Agent': 'WikitDB-AuthorsAPI/1.0 (+https://www.wikitdb.cn)'
+ };
+ let added = 0;
+ const MAX_ATT = 3;
+ let cursor = null;
+ let pagesFetchedTotal = 0;
+ let totalCountSeen = null;
+ const attempts = [];
+ for (let round = 0; (round === 0 || cursor); round++) {
+ let queryThisRound = q;
+ if (cursor) {
+ queryThisRound = `{ user(name: ${JSON.stringify(String(uname))}) { attributedPages(first: 50, after: ${JSON.stringify(cursor)}, filter: {url: {startsWith: ${JSON.stringify(cromHttpBase + '/')}}}) { edges { node { url wikidotInfo { title rating createdAt } } } pageInfo { hasNextPage endCursor } } } }`;
+ }
+ let succ = false;
+ for (let att = 0; att < MAX_ATT; att++) {
+ let res;
+ try {
+ res = await axios.post(endpoint, { query: queryThisRound }, { timeout: 30000, headers, validateStatus: () => true });
+ } catch (e) {
+ attempts.push({ round, att, ax_err: String(e.message || e).slice(0,120) });
+ await new Promise(r => setTimeout(r, 3000 + att * 1500));
+ continue;
+ }
+ const errs = res && res.data && res.data.errors;
+ const errTxt = errs ? JSON.stringify(errs) : '';
+ const status = res && res.status;
+ attempts.push({ round, att, status, http_body_bytes: (res && res.data ? JSON.stringify(res.data).length : 0), err_preview: errTxt ? errTxt.slice(0,200) : '' });
+ if (errs && throttleRe.test(errTxt)) {
+ const mw = waitRe.exec(errTxt);
+ const waitSec = mw ? parseInt(mw[1], 10) : 30;
+ await new Promise(r => setTimeout(r, (waitSec + 5) * 1000));
+ continue;
+ }
+ if (errs) {
+ await new Promise(r => setTimeout(r, 1500 * (att + 1)));
+ continue;
+ }
+ const conn = res && res.data && res.data.data && res.data.data.user && res.data.data.user.attributedPages;
+ const edges = conn && conn.edges ? conn.edges : [];
+ // attributedPages PageConnection 没有 totalCount 字段(与 top-level pages Connection 不同);跳过赋值,保留 null 即可
+ for (const e of edges) {
+ const node = e && e.node; if (!node) continue;
+ const url = node.url || '';
+ const idx = url.lastIndexOf('/');
+ const slug = idx >= 0 ? decodeURIComponent(url.slice(idx + 1)) : '';
+ if (!slug) continue;
+ const info = node.wikidotInfo || {};
+ const rating = typeof info.rating === 'number' ? info.rating : 0;
+ const title = info.title || slug;
+ const cat = info.createdAt;
+ let isoDate = cat ? String(cat) : '';
+ if (isoDate && /^\d{4}-\d{1,2}-\d{1,2}$/.test(isoDate)) isoDate = `${isoDate}T00:00:00+00:00`;
+ try { if (isoDate && isNaN(new Date(isoDate).getTime())) isoDate = ''; } catch (_) { isoDate = ''; }
+ const slugNorm = slug.toLowerCase();
+ const dedupeKey = `${sp}|${slugNorm}`;
+ if (seen.has(dedupeKey)) continue;
+ seen.add(dedupeKey);
+ attributionPages.push({
+ page: slug,
+ title: String(title),
+ wiki: paramToWikiId.get(sp) || sp,
+ rating,
+ created_at: isoDate,
+ _attributionType: '作者',
+ _attributionSiteParam: sp,
+ _source: 'crom-live'
+ });
+ added++;
+ }
+ pagesFetchedTotal += edges.length;
+ const pi = conn && conn.pageInfo;
+ if (pi && pi.hasNextPage && pi.endCursor) cursor = pi.endCursor; else cursor = null;
+ succ = true;
+ break;
+ }
+ if (!succ) break;
+ }
+ if (added > 0 || pagesFetchedTotal > 0 || bypassCache) {
+ console.info('[authors CROM live]', {
+ author: queryName, siteParam: sp, unixName: entry.unixName || entry.name || null,
+ aggregate: entry.pages, totalCount_fromCrom: totalCountSeen,
+ edgesFetched: pagesFetchedTotal, uniqueAdded: added,
+ attempts
+ });
+ }
+ return { sp, added, fetched: pagesFetchedTotal, totalCountSeen };
+ });
+ await Promise.all(cromRequests);
+ } catch (e) {
+ console.error('[authors] CROM 补全作者作品页失败:', e.message);
+ }
+ }
+ }
} catch (e) {
console.error('获取归属分数失败:', e.message);
}
- // Wikit 无数据但归属资料存在:仍返回该作者的归属档案
+ // Wikit 无数据但归属资料存在:仍返回该作者的归属档案 + 归属页面列表
if (!parsedFromRankApi && articlesData.length === 0) {
if (fromAttribution) {
const accountName2 = queryName.toLowerCase().replace(/_/g, '-').replace(/ /g, '-');
@@ -169,7 +478,7 @@ async function handler(req, res) {
siteStats: attribution.sites.map(s => ({ wiki: s.siteName, rank: '归属', rating: s.score, count: s.pages })),
attribution,
fromAttribution: true,
- pages: [],
+ pages: attributionPages,
voteRecords: [],
favoriteAuthors: []
};
@@ -201,6 +510,22 @@ async function handler(req, res) {
? `http://www.wikidot.com/avatar.php?userid=${userid}`
: `https://www.wikidot.com/avatar.php?account=${accountName}`;
+ // Wikit articlesData 与归属归属 attributionPages 合并(按 wiki+page 去重)
+ // 场景:Wikit 只收录了「正式页面」,crom/归属页还登记了 component / art / fragment 等组件页,
+ // 合起来让作者列表更完整,活力图月份更多柱。
+ let mergedPages = Array.isArray(articlesData) ? [...articlesData] : [];
+ if (attributionPages && attributionPages.length > 0) {
+ const wikitKey = new Set(
+ mergedPages.map(p => `${String(p.wiki || '').toLowerCase()}|${String(p.page || '').trim().toLowerCase()}`)
+ );
+ for (const ap of attributionPages) {
+ const k = `${String(ap.wiki || '').toLowerCase()}|${String(ap.page || '').trim().toLowerCase()}`;
+ if (wikitKey.has(k)) continue;
+ wikitKey.add(k);
+ mergedPages.push(ap);
+ }
+ }
+
return {
name: queryName,
avatar: avatarUrl,
@@ -210,12 +535,20 @@ async function handler(req, res) {
averageRating,
siteStats,
attribution,
- pages: articlesData,
+ pages: mergedPages,
voteRecords,
favoriteAuthors
};
- }, 5 * 60 * 1000)
- );
+ };
+
+ const authorData = bypassCache
+ ? (await (async () => {
+ try { return await authorCompute(); }
+ catch (e) { if (e.message === 'NOT_FOUND') return 'NOT_FOUND'; throw e; }
+ })())
+ : await singleFlight(cacheKey, () =>
+ cached(cacheKey, authorCompute, 5 * 60 * 1000)
+ );
if (authorData === 'NOT_FOUND') {
return res.status(404).json({
diff --git a/pages/api/ranking.js b/pages/api/ranking.js
index 7204e9d..d3d857d 100644
--- a/pages/api/ranking.js
+++ b/pages/api/ranking.js
@@ -6,6 +6,57 @@ const { singleFlight } = require('../../utils/singleFlight');
const { wikitLimiter } = require('../../utils/rateLimiter');
import { withLogging } from '../../utils/logRequest';
+function isCromEnabled(site) {
+ // 只有配置了 CROM_API 的站点才走 crom 排行(brcn、na)
+ // rule、if 等仅有 ATTRIBUTION_PAGE 的站点走 Wikit CIL + 归属补录
+ if (site === 'global') return true;
+ const w = config.SUPPORT_WIKI.find(x => x.PARAM === site);
+ return !!(w && w.CROM_API);
+}
+
+/** 读取归属分数(原始对象 { [username小写]: { name, score, pages, average } }),无数据返回 null */
+async function loadAttributionScores(site) {
+ if (!isCromEnabled(site)) return null;
+ if (site === 'global') {
+ const settings = await prisma.setting.findMany({ where: { key: { startsWith: 'author_score:' } } });
+ const agg = {};
+ for (const s of settings) {
+ const scores = s.value; // lib/prisma 已自动解析
+ if (!scores) continue;
+ for (const [k, v] of Object.entries(scores)) {
+ if (!agg[k]) agg[k] = { name: v.name, score: 0 };
+ agg[k].score += v.score || 0;
+ }
+ }
+ return Object.keys(agg).length > 0 ? agg : null;
+ }
+
+ const rec = await prisma.setting.findUnique({ where: { key: `author_score:${site}` } });
+ if (rec && rec.value && Object.keys(rec.value).length > 0) return rec.value;
+ return null;
+}
+
+/**
+ * 合并归属分数与 Wikit 全量排行榜:
+ * - 归属页登记过的作者:使用归属分数(修正后的权威分数)
+ * - 其余活跃作者:使用 Wikit 评分补齐,保证所有活跃作者都在榜
+ */
+function mergeRanking(attributionScores, wikitRanking) {
+ const merged = {};
+ for (const [k, v] of Object.entries(attributionScores)) {
+ merged[k] = { name: v.name || k, value: v.score || 0 };
+ }
+ for (const item of wikitRanking || []) {
+ const key = String(item.name || '').toLowerCase();
+ if (key && !merged[key]) {
+ merged[key] = { name: item.name, value: Number(item.value) || 0 };
+ }
+ }
+ return Object.values(merged)
+ .sort((a, b) => b.value - a.value)
+ .map((v, i) => ({ rank: i + 1, name: v.name, value: Math.round(v.value * 100) / 100 }));
+}
+
async function handler(req, res) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
@@ -14,40 +65,12 @@ async function handler(req, res) {
const { site = 'global' } = req.query;
try {
- // 优先使用站点归属资料分数生成排行榜(配置了归属页的站点)
- let attributionRanking = null;
+ // 1. 读取归属分数(归属页登记的作者,可能为 null)
+ let attributionScores = null;
try {
- if (site === 'global') {
- const settings = await prisma.setting.findMany({ where: { key: { startsWith: 'author_score:' } } });
- const agg = {};
- for (const s of settings) {
- const scores = s.value; // lib/prisma 已自动解析
- if (!scores) continue;
- for (const [k, v] of Object.entries(scores)) {
- if (!agg[k]) agg[k] = { name: v.name, score: 0 };
- agg[k].score += v.score || 0;
- }
- }
- if (Object.keys(agg).length > 0) {
- attributionRanking = Object.values(agg)
- .sort((a, b) => b.score - a.score)
- .map((v, i) => ({ rank: i + 1, name: v.name, value: Math.round(v.score * 100) / 100 }));
- }
- } else {
- const rec = await prisma.setting.findUnique({ where: { key: `author_score:${site}` } });
- if (rec && rec.value && Object.keys(rec.value).length > 0) {
- attributionRanking = Object.values(rec.value)
- .sort((a, b) => b.score - a.score)
- .map((v, i) => ({ rank: i + 1, name: v.name, value: Math.round(v.score * 100) / 100 }));
- }
- }
+ attributionScores = await loadAttributionScores(site);
} catch (e) {
- console.error('[ranking] 归属排行榜计算失败:', e.message);
- }
-
- if (attributionRanking) {
- res.setHeader('Cache-Control', 's-maxage=600, stale-while-revalidate');
- return res.status(200).json({ site, source: 'attribution', ranking: attributionRanking });
+ console.error('[ranking] 归属分数读取失败:', e.message);
}
const fetchGraphQL = async (queryStr, variables, endpoint = DEFAULT_GQL_ENDPOINT) => {
@@ -81,39 +104,107 @@ async function handler(req, res) {
}
};
- // 缓存 10 分钟 + 请求去重
- const cacheKey = `ranking:${site}`;
- const rankingData = await singleFlight(cacheKey, () =>
- cached(cacheKey, async () => {
- if (site === 'global') {
- return fetchGraphQL(`query { authorRanking(by: RATING) { rank name value } }`);
- }
+ // 2. Wikit 全量作者排行榜(归属分数不完整时用它补齐所有活跃作者)
+ // 缓存 10 分钟 + 请求去重
+ let wikitRanking = null;
+ try {
+ const cacheKey = `ranking:${site}`;
+ wikitRanking = await singleFlight(cacheKey, () =>
+ cached(cacheKey, async () => {
+ if (site === 'global') {
+ return fetchGraphQL(`query { authorRanking(by: RATING) { rank name value } }`);
+ }
- const wikiConfig = config.SUPPORT_WIKI.find(w => w.PARAM === site);
- if (!wikiConfig) throw new Error('NOT_FOUND');
+ const wikiConfig = config.SUPPORT_WIKI.find(w => w.PARAM === site);
+ if (!wikiConfig) throw new Error('NOT_FOUND');
- let actualWikiName = '';
- try {
- const urlObj = new URL(wikiConfig.URL);
- actualWikiName = urlObj.hostname.replace(/^www\./i, '').split('.')[0];
- } catch (e) {
- actualWikiName = wikiConfig.URL.replace(/^https?:\/\//i, '').replace(/^www\./i, '').split('.')[0];
- }
+ let actualWikiName = '';
+ try {
+ const urlObj = new URL(wikiConfig.URL);
+ actualWikiName = urlObj.hostname.replace(/^www\./i, '').split('.')[0];
+ } catch (e) {
+ actualWikiName = wikiConfig.URL.replace(/^https?:\/\//i, '').replace(/^www\./i, '').split('.')[0];
+ }
+
+ return fetchGraphQL(
+ `query($wiki: String!) { authorRanking(wiki: $wiki, by: RATING) { rank name value } }`,
+ { wiki: actualWikiName },
+ getGraphQLEndpoint(wikiConfig)
+ );
+ }, 10 * 60 * 1000)
+ );
+ } catch (error) {
+ if (error && error.message === 'NOT_FOUND') {
+ return res.status(404).json({ error: '未找到指定的站点配置' });
+ }
+ console.error('[ranking] Wikit 排行榜获取失败:', error && error.message);
+ wikitRanking = null;
+ }
- return fetchGraphQL(
- `query($wiki: String!) { authorRanking(wiki: $wiki, by: RATING) { rank name value } }`,
- { wiki: actualWikiName },
- getGraphQLEndpoint(wikiConfig)
- );
- }, 10 * 60 * 1000)
- );
+ // 2.5 非 crom 站点(rule、if 等):虽然排行榜主排序依据为 Wikit CIL,
+ // 但仍需把归属页登记过的作者并入榜单,避免归属作者因 Wikit 活跃分不足而被「消失」。
+ // 规则:Wikit 已收录的作者,若 Wikit value=0 且归属分数>0,则用归属分数替代;
+ // Wikit 未收录的作者,补录进来并使用归属分数。
+ // 数据源:author_score:{site}(与作者详情页归属展示同一条 setting 记录)
+ if (!isCromEnabled(site) && site !== 'global' && Array.isArray(wikitRanking)) {
+ try {
+ const attrRec = await prisma.setting.findUnique({ where: { key: `author_score:${site}` } });
+ const value = attrRec && attrRec.value;
+ if (value && typeof value === 'object' && Object.keys(value).length > 0) {
+ // 避免污染缓存里的原数组
+ wikitRanking = [...wikitRanking];
+ const existing = new Map(
+ wikitRanking.map(a => [String(a.name || '').toLowerCase(), a])
+ );
+ for (const [k, v] of Object.entries(value)) {
+ const displayName = (v && v.name) || k;
+ const key = String(displayName).toLowerCase() || String(k).toLowerCase();
+ if (!key) continue;
+ const attScore = Number(v && typeof v === 'object' ? (v.score || 0) : 0);
+ if (existing.has(key)) {
+ // Wikit 已收录:保留 Wikit 的排名和 value,
+ // 只有当 Wikit value 为 0 且归属分数 > 0 时,才用归属分数替代
+ // (避免归属登记的作者挂 0 分看起来像没分数)
+ const cur = existing.get(key);
+ const wikitValue = Number(cur.value || 0);
+ if (wikitValue === 0 && attScore > 0) {
+ cur.value = Math.round(attScore * 100) / 100;
+ }
+ } else {
+ // Wikit 未收录:补录进来,value 使用归属分数(而非 0),
+ // 这样作者不会被错误显示为「没分数」。
+ existing.set(key, {
+ name: displayName,
+ value: Math.round(attScore * 100) / 100
+ });
+ wikitRanking.push(existing.get(key));
+ }
+ }
+ // 重新按 value 降序并重新编号 rank
+ wikitRanking
+ .sort((a, b) => Number(b.value || 0) - Number(a.value || 0))
+ .forEach((a, i) => { a.rank = i + 1; });
+ }
+ } catch (e) {
+ console.error(`[ranking] ${site}归属作者补录失败(不影响Wikit主榜):`, e.message);
+ }
+ }
- if (rankingData === 'NOT_FOUND') {
- return res.status(404).json({ error: '未找到指定的站点配置' });
+ // 3. 合并:归属分数优先,未登记归属的活跃作者用 Wikit 评分补齐
+ let ranking;
+ let source;
+ if (attributionScores) {
+ // Wikit 不可用时仅返回归属榜,仍保证可用
+ ranking = mergeRanking(attributionScores, wikitRanking || []);
+ source = 'attribution';
+ } else if (wikitRanking) {
+ ranking = wikitRanking;
+ } else {
+ return res.status(500).json({ error: '排行榜数据获取失败' });
}
res.setHeader('Cache-Control', 's-maxage=600, stale-while-revalidate');
- res.status(200).json({ site, ranking: rankingData });
+ res.status(200).json(source ? { site, source, ranking } : { site, ranking });
} catch (error) {
if (error.message === 'NOT_FOUND') {
@@ -124,3 +215,4 @@ async function handler(req, res) {
}
export default withLogging(handler);
+
diff --git a/pages/api/search.js b/pages/api/search.js
index a288237..bdf2a14 100644
--- a/pages/api/search.js
+++ b/pages/api/search.js
@@ -1,116 +1,160 @@
const config = require('../../wikitdb.config.js');
-const { getGraphQLEndpoint } = require('../../utils/graphql');
+import prisma from '../../lib/prisma';
+import { withLogging } from '../../utils/logRequest';
const { cached } = require('../../utils/cache');
const { singleFlight } = require('../../utils/singleFlight');
const { wikitLimiter } = require('../../utils/rateLimiter');
-import { withLogging } from '../../utils/logRequest';
-
-async function handler(req, res) {
- const { site, q, p } = req.query;
-
- if (!site) return res.status(400).json({ error: '缺少 site 参数' });
-
- const wikiConfig = config.SUPPORT_WIKI.find(w => w.PARAM === site);
- if (!wikiConfig) return res.status(404).json({ error: '未找到该站点配置' });
+const { getGraphQLEndpoint } = require('../../utils/graphql');
- let actualWikiName = '';
+function getActualWikiName(wikiConfig) {
try {
const urlObj = new URL(wikiConfig.URL);
- actualWikiName = urlObj.hostname.replace(/^www\./i, '').split('.')[0];
+ return urlObj.hostname.replace(/^www\./i, '').split('.')[0];
} catch (e) {
- actualWikiName = wikiConfig.URL.replace(/^https?:\/\//i, '').replace(/^www\./i, '').split('.')[0];
+ return wikiConfig.URL.replace(/^https?:\/\//i, '').replace(/^www\./i, '').split('.')[0];
}
+}
+
+async function searchWithCromIndex(wikiParam, wikiConfig, keyword, currentPage, pageSize) {
+ // 从 DB 取缓存好的 pages_index
+ const setting = await prisma.setting.findUnique({ where: { key: `pages_index:${wikiParam}` } });
+ if (!setting || !setting.value) return null;
+ let pages = setting.value;
+ // prisma.setting 的扩展在写入/读取时自动做 JSON 序列化/反序列化,
+ // 但这里再加一层保险(防止老数据字符串、或扩展没生效)
+ if (typeof pages === 'string') {
+ try { pages = JSON.parse(pages); } catch (_) { return null; }
+ }
+ if (!Array.isArray(pages) || pages.length === 0) return null;
+
+ let filtered = pages;
+ if (keyword) {
+ const lowerQ = keyword.toLowerCase();
+ filtered = pages.filter(n => {
+ const t = (n.title || '').toLowerCase();
+ const p = (n.page || '').toLowerCase();
+ if (t.includes(lowerQ) || p.includes(lowerQ)) return true;
+ if (Array.isArray(n.tags)) {
+ for (const tg of n.tags) if (String(tg).toLowerCase().includes(lowerQ)) return true;
+ }
+ return false;
+ });
+ }
+
+ // 同 Wikit 原接口:按 created_at 倒序
+ filtered.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0));
+ const total = filtered.length;
+ const start = (currentPage - 1) * pageSize;
+ const sliced = filtered.slice(start, start + pageSize).map(n => ({
+ title: n.title,
+ page: n.page,
+ wiki: getActualWikiName(wikiConfig),
+ rating: n.rating,
+ created_at: n.createdAt
+ }));
+ return { nodes: sliced, total };
+}
+
+async function searchWithWikit(wikiConfig, keyword, currentPage, pageSize, withLimiter) {
+ const actualWikiName = getActualWikiName(wikiConfig);
+ if (withLimiter) await wikitLimiter.wait(8000);
+
+ const variables = { wiki: [actualWikiName], page: currentPage, pageSize };
+ let queryStr;
+ if (keyword) {
+ queryStr = `query($wiki: [String!]!, $title: String, $page: Int, $pageSize: Int) { articles(wiki: $wiki, title: $title, page: $page, pageSize: $pageSize) { nodes { title page wiki rating created_at } pageInfo { total } } }`;
+ variables.title = `%${keyword}%`;
+ } else {
+ queryStr = `query($wiki: [String!]!, $page: Int, $pageSize: Int) { articles(wiki: $wiki, page: $page, pageSize: $pageSize) { nodes { title page wiki rating created_at } pageInfo { total } } }`;
+ }
+ const gqlRes = await fetch(getGraphQLEndpoint(wikiConfig), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query: queryStr, variables }),
+ cache: 'no-store'
+ });
+ if (!gqlRes.ok) throw new Error('Wikit API 网络异常');
+ const gqlJson = await gqlRes.json();
+ if (gqlJson.errors) throw new Error(gqlJson.errors[0].message);
+ let nodes = gqlJson.data?.articles?.nodes || [];
+ let total = gqlJson.data?.articles?.pageInfo?.total || 0;
+ nodes.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0));
+ return { nodes, total };
+}
+
+async function searchWithWikitFallback(wikiConfig, keyword, currentPage, pageSize, withLimiter) {
+ const actualWikiName = getActualWikiName(wikiConfig);
+ if (withLimiter) await wikitLimiter.wait(8000);
+ const fallbackRes = await fetch(getGraphQLEndpoint(wikiConfig), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ query: `query($wiki: [String!]!) { articles(wiki: $wiki, page: 1, pageSize: 2000) { nodes { title page wiki rating created_at } } }`,
+ variables: { wiki: [actualWikiName] }
+ }),
+ cache: 'no-store'
+ });
+ const fallbackJson = await fallbackRes.json();
+ let nodes = fallbackJson.data?.articles?.nodes || [];
+ if (keyword) {
+ const lowerQ = keyword.toLowerCase();
+ nodes = nodes.filter(n =>
+ (n.title && n.title.toLowerCase().includes(lowerQ)) ||
+ (n.page && n.page.toLowerCase().includes(lowerQ))
+ );
+ }
+ nodes.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0));
+ const total = nodes.length;
+ const sliced = nodes.slice((currentPage - 1) * pageSize, currentPage * pageSize);
+ return { nodes: sliced, total };
+}
+async function handler(req, res) {
+ const { site, q, p } = req.query;
+ if (!site) return res.status(400).json({ error: '缺少 site 参数' });
+ const wikiConfig = config.SUPPORT_WIKI.find(w => w.PARAM === site);
+ if (!wikiConfig) return res.status(404).json({ error: '未找到该站点配置' });
+ const wikiParam = wikiConfig.PARAM;
const keyword = q ? q.trim().slice(0, 100) : '';
const currentPage = parseInt(p, 10) || 1;
const pageSize = 50;
-
const cacheKey = `search:${site}:${keyword}:${currentPage}`;
+ const cromEnabled = !!wikiConfig.CROM_API;
try {
const result = await singleFlight(cacheKey, () =>
cached(cacheKey, async () => {
- await wikitLimiter.wait(8000);
-
- const variables = { wiki: [actualWikiName], page: currentPage, pageSize };
- let queryStr;
- if (keyword) {
- queryStr = `query($wiki: [String!]!, $title: String, $page: Int, $pageSize: Int) { articles(wiki: $wiki, title: $title, page: $page, pageSize: $pageSize) { nodes { title page wiki rating created_at } pageInfo { total } } }`;
- variables.title = `%${keyword}%`;
- } else {
- queryStr = `query($wiki: [String!]!, $page: Int, $pageSize: Int) { articles(wiki: $wiki, page: $page, pageSize: $pageSize) { nodes { title page wiki rating created_at } pageInfo { total } } }`;
+ if (cromEnabled) {
+ // 优先本地 crom 索引(无外部依赖、收录更全)
+ const r = await searchWithCromIndex(wikiParam, wikiConfig, keyword, currentPage, pageSize);
+ if (r) return r;
}
-
- const gqlRes = await fetch(getGraphQLEndpoint(wikiConfig), {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ query: queryStr, variables }),
- cache: 'no-store'
- });
-
- if (!gqlRes.ok) throw new Error('Wikit API 网络异常');
-
- const gqlJson = await gqlRes.json();
-
- if (gqlJson.errors) {
- throw new Error(gqlJson.errors[0].message);
- }
-
- let nodes = gqlJson.data?.articles?.nodes || [];
- let total = gqlJson.data?.articles?.pageInfo?.total || 0;
-
- nodes.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0));
-
- return { nodes, total };
+ return await searchWithWikit(wikiConfig, keyword, currentPage, pageSize, true);
}, 3 * 60 * 1000)
);
-
res.status(200).json({
siteName: wikiConfig.NAME,
results: result.nodes,
- currentPage: currentPage,
- totalPages: Math.ceil(result.total / pageSize),
+ currentPage,
+ totalPages: Math.ceil(result.total / pageSize) || 1,
totalCount: result.total
});
-
} catch (error) {
try {
- await wikitLimiter.wait(8000);
-
- const fallbackRes = await fetch(getGraphQLEndpoint(wikiConfig), {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- query: `query($wiki: [String!]!) { articles(wiki: $wiki, page: 1, pageSize: 2000) { nodes { title page wiki rating created_at } } }`,
- variables: { wiki: [actualWikiName] }
- }),
- cache: 'no-store'
- });
-
- const fallbackJson = await fallbackRes.json();
- let nodes = fallbackJson.data?.articles?.nodes || [];
-
- if (keyword) {
- const lowerQ = keyword.toLowerCase();
- nodes = nodes.filter(n =>
- (n.title && n.title.toLowerCase().includes(lowerQ)) ||
- (n.page && n.page.toLowerCase().includes(lowerQ))
- );
+ let result;
+ if (cromEnabled) {
+ // crom 站点即使在异常路径也尝试本地 crom 索引
+ result = await searchWithCromIndex(wikiParam, wikiConfig, keyword, currentPage, pageSize);
+ }
+ if (!result) {
+ result = await searchWithWikitFallback(wikiConfig, keyword, currentPage, pageSize, !cromEnabled);
}
-
- nodes.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0));
-
- const total = nodes.length;
- const totalPages = Math.ceil(total / pageSize);
-
- const slicedNodes = nodes.slice((currentPage - 1) * pageSize, currentPage * pageSize);
-
res.status(200).json({
siteName: wikiConfig.NAME,
- results: slicedNodes,
- currentPage: currentPage,
- totalPages: totalPages === 0 ? 1 : totalPages,
- totalCount: total
+ results: result.nodes,
+ currentPage,
+ totalPages: Math.ceil(result.total / pageSize) || 1,
+ totalCount: result.total
});
} catch (err) {
res.status(500).json({ error: '搜索执行失败' });
diff --git a/pages/authors.js b/pages/authors.js
index 4f763de..a858e57 100644
--- a/pages/authors.js
+++ b/pages/authors.js
@@ -87,7 +87,7 @@ const AuthorProfile = () => {
setRankingCache(prev => ({
...prev,
- [tabParam]: result.ranking
+ [tabParam]: (result.ranking || []).filter(a => !/user.?deleted/i.test(a.name))
}));
} catch (err) {
setError(err.message);
@@ -187,7 +187,9 @@ const AuthorProfile = () => {
{data.name}
- 数据同步自 Wikit GraphQL 数据库
+ {data.fromAttribution
+ ? '数据同步自 crom 归属数据库 + Wikit'
+ : '数据同步自 Wikit GraphQL 数据库'}
@@ -204,7 +206,7 @@ const AuthorProfile = () => {
{data.attribution && data.attribution.pages > 0 && (