码桶
发现社区成员的开源项目
source-trust.js21.2 KB
'use strict';
// 候选来源信任度状态机(流量池自愈生态·批次二第二步)。
//
// 定位:source-discovery.js 只负责"发现"(存在、活跃),这个模块负责"测量可信度"。
// 核心洞察(见交接文档7.3.4节):可信度不该被"判断",应该被"测量"——不管一个来源
// 背后是谁、动机是什么,都可靠地测量"这个来源产出的节点,拿去做真实测试,通过率是多少"。
//
// 对外只暴露三个函数(严格按18.6节契约):
// recordCheckResult(sourceId, { totalChecked, passed, anomalyDetected }) -> 记录一轮结果并更新状态机
// getSourceState(sourceId) -> { status, weight, consecutivePeriods, passRate, addedAt }
// getAllSourceWeights() -> { sourceId: weight, ... } 留给批次三的加权抽取用
//
// 数据存储:data/sources.json,跟 pool.json 分开存(职责分开,18.6节已定)。
const fs = require('fs');
const path = require('path');
const DATA_DIR = path.join(__dirname, '..', 'data');
const SOURCES_FILE = path.join(DATA_DIR, 'sources.json');
// ============ 以下四个参数,创始人本轮已当面确认(见交接文档18.7+18.8.6节) ============
const TRIAL_PASS_RATE_THRESHOLD = 0.70; // trial期"持续达标"的通过率阈值(创始人确认:70%)
const TRIAL_PERIODS_TO_PROMOTE = 7; // trial期需要连续达标多少轮才转正(此前已确认:7轮≈42小时)
const TRUSTED_DOWNGRADE_PERIODS = 7; // trusted状态连续多少轮跌破阈值就降级(创始人确认:跟trial期一样,7轮)
const TRIAL_WEIGHT_CAP = 0.05; // trial期权重上限5%
// 权重浮动方式(创始人确认:0%~5%区间,按实测通过率线性浮动,不是固定死5%)
const HISTORY_KEEP_PERIODS = 10; // sources.json 滚动清理:每个来源history只保留最近10轮(创始人确认要做)
const BLACKLIST_CLEANUP_DAYS = 15; // 拉黑超过多少天自动从文件里清理掉这条记录(创始人反馈30天太久,改成15天,只改这一个常量,STALE_TRIAL_CLEANUP_DAYS的30天不动)
// 本轮新增(v30续篇二.2节,创始人确认要做):trial状态的"孤儿记录"清理——一个来源如果
// 曾经被扫描到过、但后来连续多周都没有再出现在GitHub扫描结果里(比如仓库改名/下线/
// 不再匹配查询词),它既不会被拉黑也不会转正,会永远以一条trial记录留在sources.json里,
// 没有任何清理路径。这里跟拉黑清理用同一套"多少天没更新就清"的思路,天数最初是照抄
// BLACKLIST_CLEANUP_DAYS定的(当时两者都是30)。本轮创始人把BLACKLIST_CLEANUP_DAYS
// 单独改成了15天,这个常量没有一起改,两者现在是独立的、不再相等——如果觉得trial
// 孤儿记录清理也该缩短,需要单独调整这个常量,不会跟着BLACKLIST_CLEANUP_DAYS联动。
const STALE_TRIAL_CLEANUP_DAYS = 30;
// 本轮新增(v30续篇·清理方案讨论,创始人已拍板选方向A):trusted来源目前完全没有任何
// 自动清理机制(代码里此前是刻意设计成"trusted永不自动清理",怕误删还在正常工作、
// 只是暂时没被扫到的来源)。创始人确认要加一层"多少天没被recordCheckResult更新过
// 就清理"的兜底,天数比trial期宽松很多(trusted代表已经证明过可靠,应该更宽容,
// 不能跟trial期的30天用同一个标准),创始人当面确认用180天。
const STALE_TRUSTED_CLEANUP_DAYS = 180;
// ============ 以上四个是本轮拍板的参数,以下是没有单独问、由AI按18.5节"初始参数可自主判断"精神做的实现细节 ============
// (如实说明:下面这两个不在18.7/18.8.6明确列出的必须确认清单里,是AI在实现层面做的选择,
// 不是创始人拍板的产品决策。如果跟创始人预期不符,后续可以调整,不影响其他部分。)
// v21修复(对应创始人反馈"旺财固定节点池被拉黑后,即使换了检测目标也永远测不出"):
// 1) CONSECUTIVE_ZERO_TO_BLACKLIST 从2放宽到4——2轮对一个全新来源(没有任何历史战绩
// 可以触发"战绩保护")来说太敏感,前面alive探测目标写死Google的bug就是活生生的例子:
// 旺财池子第一次被检测,直接就撞上0/16,2轮内就被判永久拉黑,根本没机会积累到
// MIN_CHECKS_FOR_TRACK_RECORD要求的8次检测量去触发保护。放宽到4轮,给新来源更多
// 喘息空间,同时也不是没有下限——真的完全不可用的来源,4轮0通过率一样会被拉黑。
// 2) MIN_TRACK_RECORD_PASS_RATE从30%降到20%——创始人本轮明确要求"降低检测标准,
// 能用就行",这个数字本身就是"战绩及格线",降低门槛让更多有一定历史战绩的来源
// 获得保护,不被短期波动误杀。
const CONSECUTIVE_ZERO_TO_BLACKLIST = 4; // trial期连续几轮通过率为0(且totalChecked>0)直接拉黑,不等满7轮
const TRUSTED_DOWNGRADE_RATE_THRESHOLD = TRIAL_PASS_RATE_THRESHOLD; // trusted降级用的"跌破阈值"沿用跟trial一样的70%,没有单独问过创始人这一项
// 2026-07-15新增(创始人反馈"手动验证过能用的固定节点池被拉黑"问题,真机复查后确认):
// 上面CONSECUTIVE_ZERO_TO_BLACKLIST=2这条规则完全不看这个来源在此之前的战绩——
// 哪怕之前16次检测过了8次,只要最近连续2轮撞上坏运气(比如三层检测里刁钻的
// 真实性验证目标,详见pool-checker.js的修复说明),照样直接判永久拉黑,没有申诉机制。
// 这里加一道保护:判断"连续2轮0通过率"要不要直接拉黑之前,先看这个来源在这2轮
// 之前积累的战绩够不够硬——总检测量达到MIN_CHECKS_FOR_TRACK_RECORD、且通过率
// 不低于MIN_TRACK_RECORD_PASS_RATE,就认为"证明过自己",不该被最近的坏运气一票
// 判死刑,先按"这轮没达标"处理(不拉黑,但也不算达标,达标连续计数照样清零),
// 留着继续观察。全新来源/从没证明过自己的来源不受这条保护,行为跟以前完全一样。
const MIN_CHECKS_FOR_TRACK_RECORD = 8; // 至少要有这么多次真实检测量,才谈得上"有战绩"
const MIN_TRACK_RECORD_PASS_RATE = 0.2; // 战绩及格线:20%(v21从30%降低),给了保护但没打算给"几乎没用"的来源开绿灯
// v21新增(真实bug修复,不是新功能——对应"拉黑之后代码里根本没有复活路径"这个死结):
// 已拉黑的来源此前是永久状态,除非手动删 sources.json 里的记录。但代码里其实已经有
// 一套"拉黑超过30天自动清理"的机制(cleanupBlacklisted),问题是它只挂在
// discovery-runner.js 每周一次的GitHub扫描定时任务上调用——如果用户没开GitHub发现
// 功能(discovery.enabled=false)、只用手动订阅源,这个清理函数从来不会被调用,
// 手动来源被拉黑之后连"30天后自动清理重来"这条路都没有,彻底死路一条。
// 这里加两层修复:
// a) REVIVAL_PERIODS_TO_UNBLACKLIST:已拉黑的来源如果连续若干轮真实通过率又达标了
// (说明当初判死可能是环境/检测目标一次性的问题,不是节点本身真的不行——这次
// alive探测目标从Google换成Cloudflare之后就是这种情况),直接复活成trial状态
// 重新计权重,不用等30天、也不用手动改JSON。所有来源类型都适用这条(discovered
// 来源理论上因为已拉黑后不再被重新抓取而不会触发到这条,但manual来源会持续
// 被抓取,这条就是专门为它设计的)。
// b) refreshPool()里会在每轮刷新时顺手调用cleanupBlacklisted,不再只依赖每周
// 的discovery定时任务,即使关掉discovery功能,manual来源的拉黑记录也能到期
// 自动清理(见pool.js对应修复)。
const REVIVAL_PERIODS_TO_UNBLACKLIST = 2; // 已拉黑来源连续几轮达标通过率(70%)就复活成trial
function ensureDataDir() {
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
}
function readAll() {
ensureDataDir();
if (!fs.existsSync(SOURCES_FILE)) {
return { sources: {} };
}
try {
const parsed = JSON.parse(fs.readFileSync(SOURCES_FILE, 'utf-8'));
if (!parsed || typeof parsed !== 'object' || !parsed.sources) return { sources: {} };
return parsed;
} catch (err) {
console.error('[source-trust] sources.json 解析失败,已重置为空:', err.message);
return { sources: {} };
}
}
function writeAll(data) {
ensureDataDir();
const tmp = SOURCES_FILE + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
fs.renameSync(tmp, SOURCES_FILE);
}
function nowIso() {
return new Date().toISOString();
}
function clamp01(n) {
if (Number.isNaN(n) || n < 0) return 0;
if (n > 1) return 1;
return n;
}
// 新来源第一次出现时的初始记录。
function createInitialRecord(sourceId) {
return {
sourceId,
status: 'trial', // 'trial' | 'trusted' | 'blacklisted'
consecutivePeriods: 0, // trial: 连续达标轮数;trusted: 连续跌破阈值轮数(复用同一字段,含义随status切换)
consecutiveZeroPeriods: 0, // trial期连续0通过率轮数,用于提前拉黑判断
recoveryStreak: 0, // v21新增:已拉黑状态下连续达标轮数,用于判断能否复活(见REVIVAL_PERIODS_TO_UNBLACKLIST)
history: [], // [{ periodAt, totalChecked, passed, passRate, anomalyDetected }]
weight: 0,
addedAt: nowIso(),
lastUpdated: nowIso(),
blacklistedAt: null
};
}
// 按最近的history算一个"当前测量通过率"(取最近几轮的均值,比单轮更稳,避免一次抖动就大幅调权重)。
function computeRecentPassRate(record) {
if (!record.history.length) return 0;
const recent = record.history.slice(0, Math.min(record.history.length, TRIAL_PERIODS_TO_PROMOTE));
const totalChecked = recent.reduce((sum, h) => sum + h.totalChecked, 0);
const totalPassed = recent.reduce((sum, h) => sum + h.passed, 0);
if (totalChecked === 0) return 0;
return totalPassed / totalChecked;
}
function trimHistory(record) {
if (record.history.length > HISTORY_KEEP_PERIODS) {
record.history.length = HISTORY_KEEP_PERIODS;
}
}
// 记录一轮检测结果,并推进状态机。
// totalChecked: 这一轮该来源产出、送去做三层检测的候选节点数
// passed: 这些节点里三层全部通过的数量
// anomalyDetected: 可选,调用方(比如未来做UUID重复检测的地方)发现"一眼假"信号时传true,
// 不管当前处于trial还是trusted,直接拉黑(18.6节:trial期这条规则明确写了,
// trusted期文档没写死"异常信号"是否也适用,这里按更安全的方向统一处理,
// 如实标注这是AI补的一条,不是原文逐字要求)
function recordCheckResult(sourceId, { totalChecked, passed, anomalyDetected = false } = {}) {
if (!sourceId) throw new Error('recordCheckResult 需要 sourceId');
const safeTotalChecked = Number.isFinite(totalChecked) ? Math.max(0, totalChecked) : 0;
const safePassed = Number.isFinite(passed) ? Math.max(0, Math.min(passed, safeTotalChecked)) : 0;
const passRate = safeTotalChecked > 0 ? safePassed / safeTotalChecked : 0;
const all = readAll();
let record = all.sources[sourceId];
if (!record) {
record = createInitialRecord(sourceId);
all.sources[sourceId] = record;
}
// 新的一轮结果,先入史册(不管后面状态机怎么走,原始记录都要留痕)。
record.history.unshift({
periodAt: nowIso(),
totalChecked: safeTotalChecked,
passed: safePassed,
passRate,
anomalyDetected: Boolean(anomalyDetected)
});
trimHistory(record);
record.lastUpdated = nowIso();
// 异常信号:不管当前什么状态,直接拉黑,不需要等满周期数。
if (anomalyDetected) {
record.status = 'blacklisted';
record.blacklistedAt = record.blacklistedAt || nowIso();
record.weight = 0;
record.consecutivePeriods = 0;
record.consecutiveZeroPeriods = 0;
writeAll(all);
return getSourceState(sourceId);
}
if (record.status === 'trial') {
if (safeTotalChecked > 0 && safePassed === 0) {
record.consecutiveZeroPeriods += 1;
} else {
record.consecutiveZeroPeriods = 0;
}
// 战绩保护:history[0]是刚才unshift进去的这一轮,排除掉它,只看这一轮之前
// 积累下来的战绩够不够硬。
const priorHistory = record.history.slice(1);
const priorTotalChecked = priorHistory.reduce((sum, h) => sum + h.totalChecked, 0);
const priorPassed = priorHistory.reduce((sum, h) => sum + h.passed, 0);
const priorPassRate = priorTotalChecked > 0 ? priorPassed / priorTotalChecked : 0;
const hasProvenTrackRecord =
priorTotalChecked >= MIN_CHECKS_FOR_TRACK_RECORD && priorPassRate >= MIN_TRACK_RECORD_PASS_RATE;
if (record.consecutiveZeroPeriods >= CONSECUTIVE_ZERO_TO_BLACKLIST && !hasProvenTrackRecord) {
record.status = 'blacklisted';
record.blacklistedAt = nowIso();
record.weight = 0;
record.consecutivePeriods = 0;
} else if (passRate >= TRIAL_PASS_RATE_THRESHOLD) {
record.consecutivePeriods += 1;
// trial权重:0~5%区间,按最近测量通过率线性浮动(创始人确认的方式),不是等转正才给权重。
const recentRate = computeRecentPassRate(record);
record.weight = clamp01(recentRate) * TRIAL_WEIGHT_CAP;
if (record.consecutivePeriods >= TRIAL_PERIODS_TO_PROMOTE) {
// 转正:trial期的"连续达标轮数"计数器切换含义,用于trusted期的降级判断,这里清零重新起算。
record.status = 'trusted';
record.consecutivePeriods = 0;
const recentRateOnPromote = computeRecentPassRate(record);
record.weight = clamp01(recentRateOnPromote); // 转正后不再受5%上限限制,权重直接按实测通过率给
}
} else {
// 这一轮没达标但也不是连续0,不算失败也不算成功,达标连续计数中断重来。
record.consecutivePeriods = 0;
const recentRate = computeRecentPassRate(record);
record.weight = clamp01(recentRate) * TRIAL_WEIGHT_CAP;
}
} else if (record.status === 'trusted') {
const recentRate = computeRecentPassRate(record);
record.weight = clamp01(recentRate);
if (passRate < TRUSTED_DOWNGRADE_RATE_THRESHOLD) {
record.consecutivePeriods += 1;
if (record.consecutivePeriods >= TRUSTED_DOWNGRADE_PERIODS) {
// 降级回trial,不是直接拉黑(拉黑只留给"异常信号"或者trial期连续0的情况)。
record.status = 'trial';
record.consecutivePeriods = 0;
record.consecutiveZeroPeriods = 0;
record.weight = clamp01(recentRate) * TRIAL_WEIGHT_CAP;
}
} else {
// 达标一轮,降级计数器清零重新起算(要连续跌破才会累计降级)。
record.consecutivePeriods = 0;
}
} else if (record.status === 'blacklisted') {
// v21新增:复活机制(修复"拉黑=永久死路,连30天自动清理都没接上"的问题,见上面
// REVIVAL_PERIODS_TO_UNBLACKLIST处的说明)。已拉黑的来源如果继续被抓取/检测
// (目前只有manual来源会这样,discovered来源拉黑后直接不再抓取,不会走到这里),
// 连续REVIVAL_PERIODS_TO_UNBLACKLIST轮真实通过率达到TRIAL_PASS_RATE_THRESHOLD,
// 说明当初判死很可能是检测环境本身的问题(比如探测目标不合适),不是节点/来源真的
// 不行——直接复活成trial状态重新计权重,不用等30天清理、也不用手动删JSON。
if (passRate >= TRIAL_PASS_RATE_THRESHOLD) {
record.recoveryStreak = (record.recoveryStreak || 0) + 1;
if (record.recoveryStreak >= REVIVAL_PERIODS_TO_UNBLACKLIST) {
record.status = 'trial';
record.blacklistedAt = null;
record.consecutivePeriods = 0;
record.consecutiveZeroPeriods = 0;
record.recoveryStreak = 0;
const recentRate = computeRecentPassRate(record);
record.weight = clamp01(recentRate) * TRIAL_WEIGHT_CAP;
}
} else {
record.recoveryStreak = 0;
}
}
writeAll(all);
return getSourceState(sourceId);
}
function getSourceState(sourceId) {
const all = readAll();
const record = all.sources[sourceId];
if (!record) return null;
return {
sourceId: record.sourceId,
status: record.status,
weight: record.weight,
consecutivePeriods: record.consecutivePeriods,
passRate: computeRecentPassRate(record),
addedAt: record.addedAt,
// 复查发现问题4新增:pool.js做"每轮最多处理N个来源、按最久没抓过的优先"这个
// 轮转排序时需要知道"这个来源上次真的被记录过一轮是什么时候",之前契约里没有
// 暴露这个字段——这是新增字段,不影响任何已有调用方(多读一个字段不会破坏什么)。
lastUpdated: record.lastUpdated
};
}
function getAllSourceWeights() {
const all = readAll();
const result = {};
for (const [sourceId, record] of Object.entries(all.sources)) {
result[sourceId] = record.status === 'blacklisted' ? 0 : record.weight;
}
return result;
}
// 清理:拉黑超过 BLACKLIST_CLEANUP_DAYS 天的来源记录直接从文件里删掉(创始人确认要做的滚动清理)。
// 不在每次recordCheckResult时都跑一遍(没必要那么频繁),暴露成独立函数,由调用方(比如每周跑一次
// 来源发现的同一个定时任务)顺手调一次即可。
function cleanupBlacklisted() {
const all = readAll();
const cutoff = Date.now() - BLACKLIST_CLEANUP_DAYS * 24 * 60 * 60 * 1000;
let removed = 0;
// 本轮修复:此前这里只返回一个数量(removed),调用方没办法知道具体是哪些
// sourceId 被清理掉了——导致 repo-fetch.js 那边"这个来源当初探测过的缓存记录"
// 没有对应的联动清理入口。现在把被删除的 sourceId 列表也带出去,调用方可以拿这份
// 列表去清理其他跟这个 sourceId 相关、但生命周期本该跟着 sources.json 走的数据。
const removedSourceIds = [];
for (const [sourceId, record] of Object.entries(all.sources)) {
if (record.status === 'blacklisted' && record.blacklistedAt) {
const blacklistedAtMs = new Date(record.blacklistedAt).getTime();
if (blacklistedAtMs < cutoff) {
delete all.sources[sourceId];
removed += 1;
removedSourceIds.push(sourceId);
}
}
}
if (removed > 0) writeAll(all);
return { removed, removedSourceIds };
}
// 清理:trial状态且连续STALE_TRIAL_CLEANUP_DAYS天没有被recordCheckResult更新过的"孤儿记录"
// 直接删除(本轮新增,创始人确认要做)。只清理trial状态——trusted来源理论上会持续出现在
// 每周扫描结果里、持续被记录,如果trusted来源也很久没更新,大概率是别的问题(比如整个
// discovery功能挂了),不应该被这个函数悄悄清掉,所以特意只限定trial状态,范围收窄一些更安全。
// blacklisted已经有独立的cleanupBlacklisted处理,不重复。
// 跟cleanupBlacklisted一样,不在每次recordCheckResult时都跑,暴露成独立函数,由调用方
// (discovery-runner.js里每周跑一次来源发现的同一个定时任务)顺手调一次即可。
function cleanupStaleTrials() {
const all = readAll();
const cutoff = Date.now() - STALE_TRIAL_CLEANUP_DAYS * 24 * 60 * 60 * 1000;
let removed = 0;
const removedSourceIds = [];
for (const [sourceId, record] of Object.entries(all.sources)) {
if (record.status === 'trial' && record.lastUpdated) {
const lastUpdatedMs = new Date(record.lastUpdated).getTime();
if (lastUpdatedMs < cutoff) {
delete all.sources[sourceId];
removed += 1;
removedSourceIds.push(sourceId);
}
}
}
if (removed > 0) writeAll(all);
return { removed, removedSourceIds };
}
// 清理:trusted状态且连续STALE_TRUSTED_CLEANUP_DAYS天没有被recordCheckResult更新过的
// 记录直接删除(本轮新增,创始人已拍板方向A)。只清理trusted状态——trial的孤儿记录归
// cleanupStaleTrials管,blacklisted归cleanupBlacklisted管,三个函数各管各的状态,互不重叠。
// 180天是特意比trial期的30天宽松很多的阈值:trusted来源已经证明过可靠(转正需要连续7轮
// 达标),不应该因为短暂几周没被扫到就被当成失效清掉;但如果连续半年都没有再出现在
// 每周GitHub扫描结果里(仓库改名/下线/不再匹配查询词),继续占着这条记录也没有意义,
// 这正是v30续篇里提到的"trusted名单会一年年攒下去,没有任何机制让它变小"这个隐患的兜底。
// 同样不在每次recordCheckResult时都跑,暴露成独立函数,由discovery-runner.js每周一次的
// 定时任务顺手调用。
function cleanupStaleTrusted() {
const all = readAll();
const cutoff = Date.now() - STALE_TRUSTED_CLEANUP_DAYS * 24 * 60 * 60 * 1000;
let removed = 0;
const removedSourceIds = [];
for (const [sourceId, record] of Object.entries(all.sources)) {
if (record.status === 'trusted' && record.lastUpdated) {
const lastUpdatedMs = new Date(record.lastUpdated).getTime();
if (lastUpdatedMs < cutoff) {
delete all.sources[sourceId];
removed += 1;
removedSourceIds.push(sourceId);
}
}
}
if (removed > 0) writeAll(all);
return { removed, removedSourceIds };
}
module.exports = {
recordCheckResult,
getSourceState,
getAllSourceWeights,
cleanupBlacklisted,
cleanupStaleTrials,
cleanupStaleTrusted
};