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 && (

- 归属资料登记名下 + crom 归属资料登记名下 {data.attribution.pages} 个归属页面,归属总评分为 {data.attribution.score > 0 ? `+${data.attribution.score}` : data.attribution.score} @@ -242,7 +244,7 @@ const AuthorProfile = () => { {data.attribution && data.attribution.sites && data.attribution.sites.length > 0 && (

-

归属站点分数分布(来自站点归属资料页):

+

crom 站点分数分布(来自站点归属资料页):

{data.attribution.sites.map((site, index) => (
@@ -300,6 +302,13 @@ const AuthorProfile = () => { ); })}
+ ) : data.fromAttribution ? ( +
+
+
crom 归属数据暂未包含投票收藏信息
+
仅 Wikit 活跃作者可查询
+
+
) : (
数据源暂时不可用(Wikit 接口超时或异常) @@ -334,6 +343,13 @@ const AuthorProfile = () => { ); })}
+ ) : data.fromAttribution ? ( +
+
+
crom 归属数据暂未包含投票收藏信息
+
仅 Wikit 活跃作者可查询
+
+
) : (
数据源暂时不可用(Wikit 接口超时或异常) diff --git a/pages/site/[param].js b/pages/site/[param].js index 5d1cbc3..48bbd19 100644 --- a/pages/site/[param].js +++ b/pages/site/[param].js @@ -158,7 +158,7 @@ export async function getServerSideProps(context) { totalPages = searchRes.totalCount || 0; } if (rankingRes) { - topAuthors = rankingRes.ranking || []; + topAuthors = (rankingRes.ranking || []).filter(a => !/user.?deleted/i.test(a.name)); } } catch (e) { // 静默处理,页面会显示空状态 diff --git a/utils/crom.js b/utils/crom.js new file mode 100644 index 0000000..5b0380c --- /dev/null +++ b/utils/crom.js @@ -0,0 +1,315 @@ +/** + * crom 排行榜接口客户端 + * crom 是 SCP 系 wikidot 站点通用的归属元数据 + 评分聚合服务(如 backrooms-wiki-cn), + * 相比 wikit listpages(不收录 component/theme/art 等非文章页),crom 统计包含 + * 全部归属于作者的页面,分数与作者页/官方排行一致。 + * + * 接口:https://api.crom.avn.sh/graphql + * usersByRank(rank: Int!, filter: {anyBaseUrl: [String!]!}): [User] + * user.statistics(baseUrl: String): { rank, totalRating, meanRating, pageCount, ... } + */ + +const DEFAULT_CROM_API = 'https://api.crom.avn.sh/graphql'; + +const sleep = (ms) => new Promise(r => setTimeout(r, ms)); + +/** + * crom 内部存储部分站点 URL 使用 http://(如 backrooms-wiki-cn)而非 https。 + * pages filter/anyBaseUrl 精确匹配字符串,不会自动做 http/https 归一。 + * 此函数接受站点配置 URL(通常 https)并返回「crom 实际使用的 http base」(去掉末尾 /,替换为 http://)。 + * @param {string} configUrl 配置中的 URL,如 "https://backrooms-wiki-cn.wikidot.com/" + * @returns {string} http base,如 "http://backrooms-wiki-cn.wikidot.com" + */ +function toCromHttpBase(configUrl) { + const u = String(configUrl || '').replace(/\/+$/, ''); + return u.replace(/^https:/, 'http:'); +} + +function slugFromCromUrl(url) { + const s = String(url || ''); + const idx = s.lastIndexOf('/'); + return (idx >= 0 ? s.slice(idx + 1) : s); +} + +/** + * 拉取某站点 crom 全量作者排行(含 score/pages/average/rank)。 + * 输出与 utils/attribution.js#aggregateAuthorScores 同构: + * { [username小写]: { name, score, pages, average, rank } } + * + * @param {string} baseUrl 站点 base URL,如 "https://backrooms-wiki-cn.wikidot.com" + * @param {object} opts + * @param {import('axios').AxiosInstance} [opts.request] 自定义 axios 实例 + * @param {string} [opts.endpoint] crom GraphQL 端点 + * @param {number} [opts.maxRank] 最大遍历排名(默认 2000,保护性上限) + * @param {number} [opts.concurrency] 并发批次大小(每批同时请求 N 个 rank) + * @param {number} [opts.batchSleepMs] 每批之间等待毫秒 + * @param {(progress:{fetched:number, rank:number}) => void} [opts.onPage] 进度回调 + * @returns {Promise} { [username小写]: { name, score, pages, average, rank } } + */ +async function fetchCromSiteRanking(baseUrl, opts = {}) { + const request = opts.request || require('axios').create({ timeout: 30000 }); + const endpoint = opts.endpoint || DEFAULT_CROM_API; + const maxRank = opts.maxRank || 2000; + const concurrency = Math.max(1, Math.min(opts.concurrency || 8, 16)); + const batchSleepMs = opts.batchSleepMs || 100; + + const statFragment = (url) => `statistics(baseUrl: "${url}") { rank totalRating meanRating pageCount }`; + const userFields = (url) => `name wikidotInfo { unixName displayName wikidotId } ${statFragment(url)}`; + + const result = {}; + let rank = 1; + let consecutiveEmpty = 0; + + const throttleRe = /too often|rate.?limit|Please wait for/i; + while (rank <= maxRank) { + // 取一批 rank 并发 + const batch = []; + for (let i = 0; i < concurrency && rank + i <= maxRank; i++) { + const r = rank + i; + const query = `{ usersByRank(rank: ${r}, filter: {anyBaseUrl: ["${baseUrl}"]}) { ${userFields(baseUrl)} } }`; + batch.push( + (async () => { + // 单请求最多重试 3 次,遇限流按建议时间等待 + for (let att = 0; att < 3; att++) { + try { + const res = await request.post(endpoint, { query }, { validateStatus: () => true }); + if (!res.data || !res.data.data || res.data.errors) { + const errTxt = res && res.data && res.data.errors ? JSON.stringify(res.data.errors) : ''; + if (throttleRe.test(errTxt)) { + const m = errTxt.match(/wait for (\d+) seconds?/i); + const waitSec = m ? parseInt(m[1], 10) : 30; + await sleep((waitSec + 5) * 1000); + continue; + } + return { r, users: [] }; + } + return { r, users: res.data.data.usersByRank || [] }; + } catch (e) { + await sleep(1500); + } + } + return { r, users: [] }; + })() + ); + } + const responses = await Promise.all(batch); + let newThisBatch = 0; + let maxRankReturned = 0; + for (const { r, users } of responses) { + for (const u of users) { + const s = u.statistics || {}; + const key = String(u.wikidotInfo && u.wikidotInfo.unixName || u.name || '').toLowerCase(); + if (!key) continue; + // 跳过已删除用户 + if (/user.?deleted/i.test(u.name)) continue; + const pages = s.pageCount || 0; + // 跳过在本站无页面的用户(crom 可能返回跨站排名但本站 0 页的条目) + if (pages === 0) continue; + const score = s.totalRating || 0; + const average = pages ? Math.round((score / pages) * 100) / 100 : 0; + if (!result[key]) newThisBatch++; + result[key] = { + name: u.name, + score: Math.round(score * 100) / 100, + pages, + average, + rank: s.rank || r, + wikidotId: u.wikidotInfo && u.wikidotInfo.wikidotId != null ? String(u.wikidotInfo.wikidotId) : null, + unixName: u.wikidotInfo && u.wikidotInfo.unixName ? u.wikidotInfo.unixName : key + }; + if ((s.rank || r) > maxRankReturned) maxRankReturned = (s.rank || r); + } + } + if (typeof opts.onPage === 'function') { + opts.onPage({ fetched: Object.keys(result).length, rank }); + } + if (newThisBatch === 0) { + consecutiveEmpty++; + // 连续 3 批无新作者,说明已超过末位,结束 + if (consecutiveEmpty >= 3) break; + } else { + consecutiveEmpty = 0; + } + rank += concurrency; + if (batchSleepMs) await sleep(batchSleepMs); + } + + return result; +} + +/** + * 查询单个作者在某站点的统计(用于详情页实时校验或补丁)。 + * @param {string} username + * @param {string} baseUrl + * @param {object} [opts] + * @returns {Promise<{name:string, score:number, pages:number, average:number, rank:number, wikidotId:string|null, unixName:string}|null>} + */ +async function fetchCromUserStats(username, baseUrl, opts = {}) { + const request = opts.request || require('axios').create({ timeout: 20000 }); + const endpoint = opts.endpoint || DEFAULT_CROM_API; + const query = `{ user(name: ${JSON.stringify(username)}) { name wikidotInfo { unixName wikidotId } statistics(baseUrl: ${JSON.stringify(baseUrl)}) { rank totalRating meanRating pageCount } } }`; + try { + const res = await request.post(endpoint, { query }, { validateStatus: () => true }); + if (!res.data || res.data.errors || !res.data.data || !res.data.data.user) return null; + const u = res.data.data.user; + const s = u.statistics || {}; + const pages = s.pageCount || 0; + const score = s.totalRating || 0; + const average = pages ? Math.round((score / pages) * 100) / 100 : 0; + const info = u.wikidotInfo || {}; + return { + name: u.name, + score: Math.round(score * 100) / 100, + pages, + average, + rank: s.rank || 0, + wikidotId: info.wikidotId != null ? String(info.wikidotId) : null, + unixName: info.unixName || null + }; + } catch (e) { + return null; + } +} + +/** + * 拉取某站点 crom 全量页面(含标题、评分、分类、标签、创建时间)。 + * 适用于页面评分缓存、站内搜索索引构建。 + * + * 输出分为两种格式: + * scores: [{ page, title, rating, upvotes, downvotes }] ← 兼容 page_scores:{site} + * index: [{ page, title, rating, category, tags, createdAt }] ← 用于搜索 + * + * @param {string} httpBase crom 内部用的 http base,可通过 toCromHttpBase(configUrl) 得到 + * @param {object} opts + * @returns {Promise<{scores: Array, index: Array}>} + */ +async function fetchCromSitePages(httpBase, opts = {}) { + const request = opts.request || require('axios').create({ timeout: 60000 }); + const endpoint = opts.endpoint || DEFAULT_CROM_API; + // crom complexity 上限 600,字段越多 per-page 成本越高 + // 实测 first=50 OK(complexity<600),first=60 以上开始超 + const pageSize = opts.pageSize || 50; + const maxPages = opts.maxPages || 2000; // 保护性上限 10 万页 + const batchSleepMs = opts.batchSleepMs || 100; + + const scores = []; + const index = []; + let cursor = null; + let pageNum = 0; + let throttles = 0; + const THROTTLE_RETRY_MAX = 8; + while (pageNum < maxPages) { + const after = cursor ? `after: ${JSON.stringify(cursor)},` : ''; + const query = `{ pages(first: ${pageSize}, ${after} filter: {url: {startsWith: ${JSON.stringify(httpBase + '/')}}}) { edges { node { url wikidotInfo { title category rating voteCount tags createdAt } } cursor } pageInfo { endCursor hasNextPage } } }`; + let res; + let attempt = 0; + const MAX_ATTEMPTS = 3; + while (attempt < MAX_ATTEMPTS) { + attempt++; + try { + res = await request.post(endpoint, { query }, { validateStatus: () => true }); + } catch (e) { + res = null; + } + // 识别限流:GraphQL error 中包含 "requests too often" / "Please wait for N seconds" + const errs = res && res.data && res.data.errors; + const errTxt = errs ? JSON.stringify(errs) : ''; + if (/too often|rate.?limit|Please wait for/i.test(errTxt)) { + const waitMatch = errTxt.match(/wait for (\d+) seconds?/i); + const waitSec = waitMatch ? parseInt(waitMatch[1], 10) : 30; + throttles++; + if (throttles >= THROTTLE_RETRY_MAX) { + throw new Error('crom pages 连续触发限流,已放弃'); + } + await sleep((waitSec + 5) * 1000); + continue; // 重试(不消耗 attempt) + } + if (errs && attempt < MAX_ATTEMPTS) { + await sleep(2000 * attempt); + continue; + } + break; + } + if (!res || !res.data || res.data.errors || !res.data.data || !res.data.data.pages) { + const errs = res && res.data && res.data.errors; + throw new Error('crom pages 请求失败: ' + (errs ? JSON.stringify(errs).slice(0, 300) : '无数据')); + } + const conn = res.data.data.pages; + const edges = conn.edges || []; + for (const e of edges) { + const node = e.node || {}; + const info = node.wikidotInfo || {}; + const page = slugFromCromUrl(node.url); + if (!page) continue; + const rating = typeof info.rating === 'number' ? info.rating : 0; + const voteCount = typeof info.voteCount === 'number' ? info.voteCount : 0; + // rating = up - down; voteCount = up + down; 反推上下票数 + const up = Math.round((voteCount + rating) / 2); + const down = Math.round((voteCount - rating) / 2); + scores.push({ + page, + title: info.title || page, + rating, + upvotes: isNaN(up) ? 0 : Math.max(0, up), + downvotes: isNaN(down) ? 0 : Math.max(0, down) + }); + index.push({ + page, + title: info.title || page, + rating, + category: info.category || '_default', + tags: info.tags || [], + createdAt: info.createdAt || '' + }); + } + if (typeof opts.onProgress === 'function') { + opts.onProgress({ pages: index.length, pageNum: pageNum + 1, batchSize: edges.length }); + } + const hasNext = conn.pageInfo && conn.pageInfo.hasNextPage; + cursor = conn.pageInfo && conn.pageInfo.endCursor; + pageNum++; + if (!hasNext || edges.length === 0 || !cursor) break; + if (batchSleepMs) await sleep(batchSleepMs); + } + return { scores, index }; +} + +/** + * 用 crom searchPages 按关键字搜索某站点页面(用于在线搜索接口的快速第一页)。 + * searchPages 结果数量有限(无分页),适合直接展示;若不够用可结合 pages_index 做本地二次匹配。 + */ +async function searchCromPages(keyword, httpBase, opts = {}) { + const request = opts.request || require('axios').create({ timeout: 20000 }); + const endpoint = opts.endpoint || DEFAULT_CROM_API; + const kw = String(keyword || '').trim(); + if (!kw) return []; + const query = `{ searchPages(query: ${JSON.stringify(kw)}, filter: {anyBaseUrl: [${JSON.stringify(httpBase)}]}) { url wikidotInfo { title category rating createdAt tags } } }`; + try { + const res = await request.post(endpoint, { query }, { validateStatus: () => true }); + if (!res.data || res.data.errors || !res.data.data) return []; + const arr = res.data.data.searchPages || []; + return arr.map(n => { + const info = n.wikidotInfo || {}; + return { + page: slugFromCromUrl(n.url), + title: info.title || slugFromCromUrl(n.url), + rating: info.rating || 0, + category: info.category || '_default', + tags: info.tags || [], + createdAt: info.createdAt || '' + }; + }); + } catch (e) { + return []; + } +} + +module.exports = { + fetchCromSiteRanking, + fetchCromUserStats, + fetchCromSitePages, + searchCromPages, + toCromHttpBase, + slugFromCromUrl, + DEFAULT_CROM_API +}; diff --git a/wikitdb.config.js b/wikitdb.config.js index 30a3e82..59c6b99 100644 --- a/wikitdb.config.js +++ b/wikitdb.config.js @@ -1,6 +1,6 @@ module.exports = { SITE_NAME: 'WikitDB', - SITE_URL: '', + SITE_URL: 'https://www.wikitdb.cn', SITE_SINCE: '2026', SITE_AUTHOR: 'WikitDB Team', SUPPORT_WIKI: [ @@ -28,7 +28,10 @@ module.exports = { ImgURL: "https://laimu.backroomswiki.top/img/Logo.png", PARAM: "if", WIKIT_ID: "if-backrooms", - AUTHOR_TAG: "作者" + AUTHOR_TAG: "作者", + // if 排行榜走纯 Wikit CIL(不使用 crom 评分),因此无 CROM_API; + // 但保留归属资料页 ATTRIBUTION_PAGE,用于作者详情页展示作者名下归属页面列表 + ATTRIBUTION_PAGE: "attribution-metadata" }, { NAME: "地下黑市", @@ -52,7 +55,10 @@ module.exports = { ImgURL: "https://rule-wiki.wdfiles.com/local--files/component%3Atheme/rule-wiki-new.svg", PARAM: "rule", WIKIT_ID: "rule-wiki", - AUTHOR_TAG: "作者" + AUTHOR_TAG: "作者", + // rule 排行榜走纯 Wikit CIL(不使用 crom 评分),因此无 CROM_API; + // 但保留归属资料页 ATTRIBUTION_PAGE,用于作者详情页展示作者名下归属页面列表 + ATTRIBUTION_PAGE: "attribution-metadata" }, { NAME: "纸上书", @@ -83,14 +89,25 @@ module.exports = { WIKIT_ID: "scp-wiki-mc" }, { - NAME: "The Bsckrooms中文维基", + NAME: "The Backrooms中文维基", URL: "https://backrooms-wiki-cn.wikidot.com/", ImgURL: "https://7bye.com/hoah/i/2023/12/31/5hsx3.svg", PARAM: "brcn", WIKIT_ID: "backrooms-wiki-cn", GQL_API: "https://wikit.unitreaty.org/backrooms/apiv1/graphql", + CROM_API: "https://api.crom.avn.sh/graphql", ATTRIBUTION_PAGE: "attribution-metadata", FORUM_SYNC: true }, + { + NAME: "The Nationarea", + URL: "https://nationarea.wikidot.com/", + ImgURL: "https://nationarea.wdfiles.com/local--files/start/logo.png", + PARAM: "na", + WIKIT_ID: "nationarea", + AUTHOR_TAG: "作者", + CROM_API: "https://api.crom.avn.sh/graphql", + ATTRIBUTION_PAGE: "attribution-metadata" + }, ] };