码桶

发现社区成员的开源项目

main
NodeNanny/core/discovery-runner.js
discovery-runner.js8.1 KB
'use strict';

// 流量池自愈生态·批次三:候选来源发现的定时缓存管理。
//
// 背景:source-discovery.js的discoverSources()本身不做任何持久化,每次调用都是
// 一次真实的GitHub API扫描。但18.1节已经拍板"候选来源扫描频率是每周一次",而
// refreshPool()现在(改造后)是按pool.refreshIntervalHours(默认6小时)的节奏跑的,
// 不能每次刷新流量池都触发一次GitHub扫描——那会在几小时内就把请求量堆起来,
// 跟"每周扫一次"的设计意图不符,也没必要(候选仓库列表不会小时级别变化)。
//
// 这个模块负责:维护一份本地缓存(data/discovered-sources.json),记录"上次扫描的
// 时间"和"上次扫描发现的来源列表"。refreshPool()每次要用来源列表时调用
// getActiveSources(config),由这个模块决定"要不要真的发起一次新的GitHub扫描,
// 还是直接把缓存吐回去"。
//
// 对外只暴露一个函数:getActiveSources(discoveryConfig) -> Promise<{ sources, scannedAt, fromCache }>

const fs = require('fs');
const path = require('path');
const { discoverSources, filterByProtocolTopic } = require('./source-discovery');
const sourceTrust = require('./source-trust');
const repoFetch = require('./repo-fetch');

const DATA_DIR = path.join(__dirname, '..', 'data');
const CACHE_FILE = path.join(DATA_DIR, 'discovered-sources.json');

function ensureDataDir() {
  if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
}

function readCache() {
  ensureDataDir();
  if (!fs.existsSync(CACHE_FILE)) return null;
  try {
    return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8'));
  } catch (err) {
    console.error('[discovery-runner] discovered-sources.json 解析失败,已忽略缓存:', err.message);
    return null;
  }
}

function writeCache(data) {
  ensureDataDir();
  const tmp = CACHE_FILE + '.tmp';
  fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
  fs.renameSync(tmp, CACHE_FILE);
}

// 主入口:按需(缓存不存在,或者已经超过scanIntervalHours)触发一次真实扫描,
// 否则直接返回上次缓存的结果。scanIntervalHours默认168小时(约一周),
// 跟18.1节拍板的"每周扫描一次"一致。
async function getActiveSources(discoveryConfig) {
  const cfg = discoveryConfig || {};
  if (!cfg.enabled) return { sources: [], scannedAt: null, fromCache: false, skipped: true };

  const intervalHours = cfg.scanIntervalHours || 168;
  const cache = readCache();
  const now = Date.now();
  const cacheAgeMs = cache && cache.scannedAt ? now - new Date(cache.scannedAt).getTime() : Infinity;

  if (cache && cacheAgeMs < intervalHours * 3600 * 1000) {
    return { sources: cache.sources || [], scannedAt: cache.scannedAt, fromCache: true, errors: cache.errors || [] };
  }

  // 缓存过期或不存在,发起一次真实扫描。扫描本身失败(比如网络问题、token失效)
  // 不应该让refreshPool()整体崩溃——如果有旧缓存,退回旧缓存并如实记录这次扫描失败;
  // 完全没有缓存(第一次跑)才返回空列表。
  try {
    const result = await discoverSources(cfg);
    // v21新增:过滤掉"只命中通用topic关键词、标签里却没有任何VPN协议特征"的来源
    // (比如网页HTTP代理IP列表项目),见 source-discovery.js 里 filterByProtocolTopic
    // 的说明。只在真的发起新扫描时过滤一次,不影响直接吐缓存的分支。
    const { kept: filteredSources, filteredOut } = filterByProtocolTopic(result.found);
    if (filteredOut > 0) {
      console.log(`[discovery-runner] 按协议标签共现规则过滤掉了 ${filteredOut} 个疑似无关来源(仅命中通用关键词、标签里没有任何VPN协议特征)`);
    }
    writeCache({ sources: filteredSources, scannedAt: result.scannedAt, errors: result.errors });
    // 复查发现问题2修复:cleanupBlacklisted()此前写好了、测试也通过了,但没有任何地方
    // 真正调用它——创始人在20.1节明确确认过要做"拉黑超过一段时间自动清理",代码交付了
    // 却没接线,等于这条需求一直没生效。这个函数本身注释里也写好了"由每周跑一次来源
    // 发现的同一个定时任务顺手调一次即可",这里就是那个"顺手调一次"的地方——只在真的
    // 发起了一次新扫描时才清理(不是每次getActiveSources都清理),跟扫描本身同一个节奏。
    try {
      const { removed, removedSourceIds } = sourceTrust.cleanupBlacklisted();
      if (removed > 0) console.log(`[discovery-runner] 清理了 ${removed} 条超过滚动清理周期的拉黑来源记录`);
      // 本轮修复:被清理掉的来源,它当初探测过的文件名缓存(repo-fetch-probe-cache.json)
      // 此前会永远留着,没有对应的清理机制——量级很小,但既然 cleanupBlacklisted 现在
      // 会把 sourceId 列表带出来,顺手接上这个联动清理,让这份缓存也有始有终。
      if (removedSourceIds && removedSourceIds.length > 0) {
        try {
          const { removed: cacheRemoved } = repoFetch.cleanupProbeCacheFor(removedSourceIds);
          if (cacheRemoved > 0) console.log(`[discovery-runner] 联动清理了 ${cacheRemoved} 条已拉黑来源的探测缓存记录`);
        } catch (cacheCleanErr) {
          console.error('[discovery-runner] 探测缓存联动清理失败(不影响本次扫描结果):', cacheCleanErr.message);
        }
      }
    } catch (cleanupErr) {
      console.error('[discovery-runner] cleanupBlacklisted 执行失败(不影响本次扫描结果):', cleanupErr.message);
    }
    // 本轮新增:清理trial状态的孤儿记录(长期没被扫描到、既不会转正也不会被拉黑的来源)。
    // 同一个每周定时任务里顺手调用,跟cleanupBlacklisted是独立的两件事,互不影响彼此的结果。
    try {
      const { removed, removedSourceIds } = sourceTrust.cleanupStaleTrials();
      if (removed > 0) console.log(`[discovery-runner] 清理了 ${removed} 条长期未被扫描到的孤儿trial来源记录`);
      if (removedSourceIds && removedSourceIds.length > 0) {
        try {
          const { removed: cacheRemoved } = repoFetch.cleanupProbeCacheFor(removedSourceIds);
          if (cacheRemoved > 0) console.log(`[discovery-runner] 联动清理了 ${cacheRemoved} 条孤儿trial来源的探测缓存记录`);
        } catch (cacheCleanErr) {
          console.error('[discovery-runner] 探测缓存联动清理失败(不影响本次扫描结果):', cacheCleanErr.message);
        }
      }
    } catch (staleErr) {
      console.error('[discovery-runner] cleanupStaleTrials 执行失败(不影响本次扫描结果):', staleErr.message);
    }
    // 本轮新增:清理长期(180天)没被扫描到的trusted孤儿记录(创始人已拍板方向A)。
    // 同一个每周定时任务里顺手调用,跟前两个清理函数各自独立、互不影响彼此的结果。
    try {
      const { removed, removedSourceIds } = sourceTrust.cleanupStaleTrusted();
      if (removed > 0) console.log(`[discovery-runner] 清理了 ${removed} 条长期未被扫描到的孤儿trusted来源记录`);
      if (removedSourceIds && removedSourceIds.length > 0) {
        try {
          const { removed: cacheRemoved } = repoFetch.cleanupProbeCacheFor(removedSourceIds);
          if (cacheRemoved > 0) console.log(`[discovery-runner] 联动清理了 ${cacheRemoved} 条孤儿trusted来源的探测缓存记录`);
        } catch (cacheCleanErr) {
          console.error('[discovery-runner] 探测缓存联动清理失败(不影响本次扫描结果):', cacheCleanErr.message);
        }
      }
    } catch (staleTrustedErr) {
      console.error('[discovery-runner] cleanupStaleTrusted 执行失败(不影响本次扫描结果):', staleTrustedErr.message);
    }
    return { sources: filteredSources, scannedAt: result.scannedAt, fromCache: false, errors: result.errors };
  } catch (err) {
    if (cache) {
      return { sources: cache.sources || [], scannedAt: cache.scannedAt, fromCache: true, scanError: err.message };
    }
    return { sources: [], scannedAt: null, fromCache: false, scanError: err.message };
  }
}

module.exports = { getActiveSources };