码桶

发现社区成员的开源项目

main
NodeNanny/core/repo-fetch.js
repo-fetch.js20.6 KB
'use strict';

// 流量池自愈生态·批次三:单仓库候选节点抓取模块。
//
// 定位:source-discovery.js 只发现"有哪些仓库",这个模块负责"从某一个具体仓库里
// 真的抓到候选节点链接"——这是founder本轮拍板要做的"改造抓取方式":放弃aggregator
// 整包抓、不分来源的旧模式,改成逐仓库单独抓取解析,这样每个节点天然带着"我是从
// 哪个来源来的"这个信息,能真正喂给source-trust.js。
//
// 对外只暴露一个函数:fetchCandidatesForSource(sourceRecord, discoveryConfig, headers)
//   -> Promise<{ sourceId, links: string[], filesFound: [...], filesTried: [...], errors: [...] }>
//
// 如实说明:
// - GitHub Contents API(探测/获取具体文件内容)本轮同样需要认证才能有稳定的调用额度
//   (未认证 60次/小时,每个来源可能要探测好几个候选文件名,量上去很容易打满),
//   跟source-discovery.js的代码搜索接口是同一份githubToken配置。
// - raw.githubusercontent.com 不受GitHub API限流规则管(它是静态文件CDN,不是API),
//   所以"已知确切路径"的文件用它直接抓,不占用API额度;"猜文件名"这一步因为要先
//   确认文件存不存在,必须先过一次Contents API(HEAD式存在性检查),这一步才占额度。
// - 本模块在沙盒里只做过mock测试(模拟fetch响应),没有真机验证过——沙盒没有网络,
//   这是本轮工作范围内做不到的部分,如实标注,不假装已经验证过。

const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');

const GITHUB_API_BASE = 'https://api.github.com';
const RAW_BASE = 'https://raw.githubusercontent.com';
const API_VERSION = '2022-11-28';
const DEFAULT_REQUEST_TIMEOUT_MS = 8000;
const DEFAULT_PROBE_THROTTLE_MS = 300;

// topic命中(不知道具体文件名)的来源,猜测尝试的常见候选文件名/路径。
// 只试仓库根目录,不做递归遍历子目录——如果找不到,大概率这个仓库本身就不是
// 一个"节点订阅仓库",让source-trust.js的试用期机制去处理(通过率0会被拉黑),
// 不在这里为了"多试几个路径"过度增加复杂度和API调用量。
const DEFAULT_GUESS_FILENAMES = [
  'v2ray.txt', 'sub.txt', 'clash.yaml', 'clash.yml', 'list.txt', 'config.yaml'
];

// 探测结果缓存(修复记录:此前每一轮刷新都会对所有来源重新猜一遍6个候选文件名,
// 哪怕上一轮已经确认某个来源根本没有这个文件——随着来源数量积累,这个成本会
// 持续线性增长。缓存"这个文件存不存在"这个判断结果一段时间,同一个来源同一个
// 文件名,缓存新鲜期内不重复探测,直接复用上次的结论;只有"探测到文件真的存在"
// 或"猜测缓存已过期"时才重新调用Contents API。内容本身(fetchText)不缓存——
// 文件内容会变化,每次都应该抓最新的,缓存的只是"值不值得去抓"这个判断。)
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7天
const CACHE_FILE = path.join(__dirname, '..', 'data', 'repo-fetch-probe-cache.json');

function readProbeCache() {
  try {
    if (!fs.existsSync(CACHE_FILE)) return {};
    const parsed = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8'));
    return parsed && typeof parsed === 'object' ? parsed : {};
  } catch (err) {
    return {}; // 缓存文件损坏,当成没有缓存处理,不影响主流程
  }
}

function writeProbeCache(cache) {
  try {
    const dir = path.dirname(CACHE_FILE);
    if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
    const tmp = CACHE_FILE + '.tmp';
    fs.writeFileSync(tmp, JSON.stringify(cache, null, 2));
    fs.renameSync(tmp, CACHE_FILE);
  } catch (err) {
    // 缓存写入失败不影响主流程,下次探测退化成"没有缓存"重新探测一次即可
  }
}

function probeCacheKey(repoFullName, filename) {
  return `${repoFullName}::${filename}`;
}

function getCachedProbe(cache, repoFullName, filename) {
  const entry = cache[probeCacheKey(repoFullName, filename)];
  if (!entry) return null;
  if (Date.now() - new Date(entry.checkedAt).getTime() > CACHE_TTL_MS) return null; // 过期,当成没有缓存
  return entry;
}

function setCachedProbe(cache, repoFullName, filename, exists) {
  cache[probeCacheKey(repoFullName, filename)] = { exists, checkedAt: new Date().toISOString() };
}

// 补丁(本轮修复):来源被拉黑并从 sources.json 里滚动清理掉之后,它当初探测过的
// 缓存记录(每个来源最多6条,每条几十字节)此前会永远留在 repo-fetch-probe-cache.json
// 里,没有对应的清理机制——量级很小,不影响功能,但既然 sourceId 就是 repoFullName,
// 缓存 key 又是 `${repoFullName}::${filename}` 这种可预测的前缀,清理起来很简单,
// 顺手补上,让这份缓存也跟 sources.json 一样有始有终。
// 由调用方(discovery-runner.js,在 cleanupBlacklisted() 之后)传入被清理掉的
// sourceId 列表,这里只做"删除以这些 sourceId 开头的缓存条目"这一件事。
function cleanupProbeCacheFor(sourceIds) {
  if (!Array.isArray(sourceIds) || sourceIds.length === 0) return { removed: 0 };
  const cache = readProbeCache();
  const prefixes = sourceIds.map((id) => `${id}::`);
  let removed = 0;
  for (const key of Object.keys(cache)) {
    if (prefixes.some((prefix) => key.startsWith(prefix))) {
      delete cache[key];
      removed += 1;
    }
  }
  if (removed > 0) writeProbeCache(cache);
  return { removed };
}

function buildHeaders(token) {
  const headers = {
    'User-Agent': 'NodeNanny-RepoFetch',
    'Accept': 'application/vnd.github+json',
    'X-GitHub-Api-Version': API_VERSION
  };
  if (token) headers['Authorization'] = `token ${token}`;
  return headers;
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

// 修复记录:此前fetchJson/fetchText是裸调用fetch(),没有设超时——一次请求卡住
// (网络抖动、对方服务器无响应)会拖慢整轮刷新,不像pool-checker.js三层检测那样
// 每一层都有独立超时保护。这里用AbortController统一加一层超时,超时当成请求
// 失败处理(不抛异常中断整个流程,跟现有"每次独立请求单独try/catch"的原则一致)。
async function fetchWithTimeout(url, options, timeoutMs) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs || DEFAULT_REQUEST_TIMEOUT_MS);
  try {
    return await fetch(url, { ...options, signal: controller.signal });
  } finally {
    clearTimeout(timer);
  }
}

async function fetchJson(url, headers, timeoutMs) {
  const res = await fetchWithTimeout(url, { headers }, timeoutMs);
  if (!res.ok) return { ok: false, status: res.status };
  try {
    const data = await res.json();
    return { ok: true, data };
  } catch (err) {
    return { ok: false, status: res.status, error: err.message };
  }
}

// 本轮修复(2026-08-02,fq5211来源诊断出的真实bug):这个来源解析出来的订阅链接
// (app.sublink.works)会返回一个302跳转,但它把整份节点列表原样塞进了跳转目标
// 的query string里,Location响应头因此长达几万字节——远超Node内置fetch()(底层是
// undici)和http/https模块的默认单个响应头大小上限,请求会直接抛出
// TypeError,err.cause.code是'UND_ERR_HEADERS_OVERFLOW'。这不是这一个来源独有的
// 问题,是Node网络栈对"异常巨大的单个header"的通用默认限制,所以专门做一个兜底:
// 正常fetch()失败时,如果确认是这个特定错误类型(不是网络超时、DNS失败等其他原因),
// 才退回到Node内置http/https模块,手动加大maxHeaderSize重试一次。
// 特意不引入任何第三方npm包(比如got/node-fetch的老版本)——项目一直保持零依赖
// (package.json目前只有express和nodemailer两个真实依赖),这个问题用内置模块就
// 能解决,没必要为了一个边缘情况破例加依赖。
const HEADER_OVERFLOW_MAX_HEADER_SIZE = 262144; // 256KB,留足余量(fq5211实测约30KB)
const HEADER_OVERFLOW_MAX_REDIRECTS = 5;

function isHeaderOverflowError(err) {
  return !!(err && err.cause && err.cause.code === 'UND_ERR_HEADERS_OVERFLOW');
}

// http.get()/https.get()不像fetch()/curl -L那样会自动跟随跳转,这里手动实现,
// 加一个跳转跳数上限防止极端情况下的死循环。每一跳都用加大maxHeaderSize的内置
// 模块请求,因为巨大的Location头往往不会只出现在第一跳。
function fetchTextViaNodeHttp(url, headers, timeoutMs, redirectsLeft) {
  return new Promise((resolve) => {
    let currentUrl;
    try {
      currentUrl = new URL(url);
    } catch (err) {
      resolve({ ok: false, status: 0, error: `invalid url: ${err.message}` });
      return;
    }
    const client = currentUrl.protocol === 'http:' ? http : https;
    const req = client.get(
      currentUrl,
      {
        headers,
        maxHeaderSize: HEADER_OVERFLOW_MAX_HEADER_SIZE,
        timeout: timeoutMs || DEFAULT_REQUEST_TIMEOUT_MS
      },
      (res) => {
        const status = res.statusCode;
        // 3xx跳转:手动跟到下一跳,不在这一跳读取正文
        if (status >= 300 && status < 400 && res.headers.location) {
          res.resume(); // 丢弃这一跳的正文,避免socket卡住
          if (redirectsLeft <= 0) {
            resolve({ ok: false, status, error: 'too many redirects (header-overflow fallback path)' });
            return;
          }
          let nextUrl;
          try {
            nextUrl = new URL(res.headers.location, currentUrl).toString();
          } catch (err) {
            resolve({ ok: false, status, error: `invalid redirect location: ${err.message}` });
            return;
          }
          resolve(fetchTextViaNodeHttp(nextUrl, headers, timeoutMs, redirectsLeft - 1));
          return;
        }
        const chunks = [];
        res.on('data', (chunk) => chunks.push(chunk));
        res.on('end', () => {
          if (status < 200 || status >= 300) {
            resolve({ ok: false, status });
            return;
          }
          resolve({ ok: true, text: Buffer.concat(chunks).toString('utf-8') });
        });
        res.on('error', (err) => {
          resolve({ ok: false, status: 0, error: err.message });
        });
      }
    );
    req.on('timeout', () => {
      req.destroy(new Error('timeout'));
    });
    req.on('error', (err) => {
      resolve({ ok: false, status: 0, error: err.message });
    });
  });
}

async function fetchText(url, timeoutMs) {
  // 本轮修复(创始人反馈"旺财"手动订阅源一直测不出候选节点):此前这里裸调用
  // fetchWithTimeout(url, {}, timeoutMs),完全没带任何请求头。GitHub API那条路径
  // (fetchJson)一直有带User-Agent,唯独手动订阅源用的这个fetchText没有——不少个人
  // 维护的订阅服务会对没有UA、或者UA像脚本/爬虫的请求返回一个说明页/跳转页而不是
  // 真实订阅内容,请求本身是200成功的,但parseSubscriptionContent()三种格式全部
  // 识别不出节点,最终会静默判定成"这一轮0个候选",看起来就像来源没人维护,实际上
  // 只是请求没伪装成一个正常客户端。这里补上跟GitHub路径一致的User-Agent。
  const headers = { 'User-Agent': 'NodeNanny-RepoFetch' };
  try {
    const res = await fetchWithTimeout(url, { headers }, timeoutMs);
    if (!res.ok) return { ok: false, status: res.status };
    const text = await res.text();
    return { ok: true, text };
  } catch (err) {
    if (!isHeaderOverflowError(err)) throw err; // 只兜底这一种已知错误类型,别的错误照常往外抛
    return fetchTextViaNodeHttp(url, headers, timeoutMs, HEADER_OVERFLOW_MAX_REDIRECTS);
  }
}

// 取仓库默认分支,拿不到就依次尝试常见分支名兜底,不让一次接口失败就整个来源放弃。
async function resolveDefaultBranch(repoFullName, headers, timeoutMs) {
  const { ok, data } = await fetchJson(`${GITHUB_API_BASE}/repos/${repoFullName}`, headers, timeoutMs);
  if (ok && data && data.default_branch) return data.default_branch;
  return null; // 交给调用方按 main/master 顺序兜底试
}

// 探测仓库根目录下某个文件是否存在,只在"topic命中、不知道具体路径"时才需要这一步。
async function fileExistsAtRoot(repoFullName, filename, branch, headers, timeoutMs) {
  const url = `${GITHUB_API_BASE}/repos/${repoFullName}/contents/${encodeURIComponent(filename)}?ref=${encodeURIComponent(branch)}`;
  const { ok } = await fetchJson(url, headers, timeoutMs);
  return ok;
}

// 解析一段抓到的原始文本,尝试三种可能的格式,依次识别:
// 1. 明文分享链接列表(每行一个 vless://...)
// 2. 整体base64编码的订阅内容(解一次base64后再按1识别)
// 3. clash yaml格式(有 proxies 数组)
// 三种都试不出内容,返回空数组,不当成错误——很多topic命中的仓库本身跟节点无关,
// 这是预期内会发生的情况。
function parseSubscriptionContent(text) {
  const clashYaml = require('./clash-yaml');
  const raw = (text || '').trim();
  if (!raw) return { format: 'empty', links: [] };

  // 2026-07-30新增:识别的协议前缀里加入 http/https。
  //
  // 背景:core/proxy-parse.js此前评估"要不要支持http/https代理链接"时,担心跟
  // 本函数已有的"http(s)前缀=订阅链接,应该被抓取而不是当成节点"的理解冲突,
  // 当时因为没有这份源码,没法确认,标成了"待确认,先不加"。这次拿到真实源码
  // 核实过:本函数(以及core/目录下其他任何地方)都没有"整行内容是http(s)开头就
  // 去二次抓取当订阅"这种逻辑——"http(s)前缀=订阅链接"这个理解只发生在更上层
  // (config.json里配置的来源地址本身,即pool.manualSources的url字段/
  // source-discovery.js发现的仓库文件raw地址),跟"已经抓到手的文件内容里某一行
  // 是不是http开头"是两个完全不同的层面,互不干扰。之前担心的冲突不存在。
  //
  // 如实标注一个残留的已知局限,不是新引入的问题,是加了http/https之后才会
  // 真正暴露出来的:如果抓到的内容里某一行恰好是一个"订阅链接"(比如某个机场的
  // http://airport.com/sub?token=xxx,本身应该被当成"需要再去抓一次内容"的地址),
  // 会被这里误判成"这是一条http代理服务器地址",按host:port去当代理服务器解析,
  // 传给pool-checker.js三层检测——检测会因为那个host:port根本不是真实代理服务
  // 而正常测不通,按"这条节点没通过"处理,不会引发错误或崩溃,只是白白浪费一次
  // 检测,也错失了"其实应该展开抓取"这个机会。本项目目前没有做"识别出订阅链接
  // 后自动展开二次抓取"这个更大的功能,如果之后要做,这是需要专门设计的另一块。
  const tryLines = (s) =>
    s
      .split(/\r?\n/)
      .map((l) => l.trim())
      .filter((l) => /^(vless|vmess|ss|ssr|trojan|hysteria2?|hy2|tuic|https?):\/\//i.test(l));

  let lines = tryLines(raw);
  if (lines.length > 0) return { format: 'raw-links', links: Array.from(new Set(lines)) };

  try {
    const decoded = Buffer.from(raw, 'base64').toString('utf-8');
    lines = tryLines(decoded);
    if (lines.length > 0) return { format: 'base64-links', links: Array.from(new Set(lines)) };
  } catch (err) {
    // 不是合法base64,继续往下试
  }

  const yamlResult = clashYaml.extractLinksFromClashYaml(raw);
  if (yamlResult && yamlResult.links.length > 0) {
    return { format: 'clash-yaml', links: Array.from(new Set(yamlResult.links)), yamlTotal: yamlResult.total, yamlConverted: yamlResult.converted };
  }

  return { format: 'unrecognized', links: [] };
}

// 主入口:给一条source-discovery.js发现的来源记录,抓取这个来源这一轮产出的候选节点。
async function fetchCandidatesForSource(sourceRecord, discoveryConfig, headers) {
  const cfg = discoveryConfig || {};
  const guessFilenames = cfg.guessFilenames || DEFAULT_GUESS_FILENAMES;
  const repoFullName = sourceRecord.repoFullName;
  const filesTried = [];
  const filesFound = [];
  const errors = [];
  const allLinks = new Set();
  const timeoutMs = cfg.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS;
  const throttleMs = cfg.probeThrottleMs != null ? cfg.probeThrottleMs : DEFAULT_PROBE_THROTTLE_MS;
  const probeCache = readProbeCache();
  let probeCacheDirty = false;

  if (!repoFullName) {
    return { sourceId: repoFullName, links: [], filesFound, filesTried, errors: ['缺少repoFullName'] };
  }

  // 第一步:确定要尝试的候选路径列表。
  // filename命中的来源已经有确切路径(matchedFiles),topic命中的来源没有,
  // 需要先猜文件名、逐个探测是否存在。两者都试是安全的(有确切路径优先直接抓,
  // 猜测列表作为补充,不会因为已经有确切路径就跳过猜测——万一同一仓库还有其他节点文件)。
  const knownPaths = (sourceRecord.matchedFiles || []).map((f) => f.path).filter(Boolean);

  let branch = null;
  try {
    branch = await resolveDefaultBranch(repoFullName, headers, timeoutMs);
  } catch (err) {
    errors.push(`获取默认分支失败:${err.message}`);
  }
  const branchCandidates = branch ? [branch] : ['main', 'master'];

  // 已知确切路径:直接用raw CDN抓,不占GitHub API额度,也不需要节流
  // (raw.githubusercontent.com是静态文件CDN,不受API限流规则管,见文件顶部说明)。
  for (const p of knownPaths) {
    let fetched = false;
    for (const b of branchCandidates) {
      const url = `${RAW_BASE}/${repoFullName}/${b}/${p}`;
      filesTried.push(url);
      try {
        const { ok, text } = await fetchText(url, timeoutMs);
        if (ok) {
          filesFound.push(url);
          const parsed = parseSubscriptionContent(text);
          parsed.links.forEach((l) => allLinks.add(l));
          fetched = true;
          break;
        }
      } catch (err) {
        errors.push(`抓取失败(${url}):${err.message}`);
      }
    }
    if (!fetched) errors.push(`已知路径抓取失败(所有候选分支都试过):${p}`);
  }

  // 猜测文件名(仅对topic命中、且还没有任何已知路径命中过内容的情况有意义;
  // 就算filename命中已经拿到内容了,这里仍然顺手都试一遍——万一同一个仓库
  // 根目录下还有别的节点文件,多一份候选没有坏处,GitHub Contents API的
  // 探测请求本身失败也不影响其他候选)。
  if (cfg.githubTokenMissingSkipGuess !== true) {
    let firstProbe = true;
    for (const filename of guessFilenames) {
      if (knownPaths.includes(filename)) continue; // 已经试过同名的确切路径,不重复

      // 探测缓存命中(修复记录见文件顶部注释):同一个来源同一个文件名,如果最近
      // CACHE_TTL_MS内已经探测过、且确认不存在,直接跳过这次API调用;只有缓存
      // 过期或者从没探测过时才真的发请求。缓存命中也算一次"探测",仍然计入
      // filesTried便于观测,但不产生网络请求、不占节流时间。
      const cached = getCachedProbe(probeCache, repoFullName, filename);
      if (cached && cached.exists === false) {
        continue;
      }

      if (!firstProbe && throttleMs > 0) {
        await sleep(throttleMs);
      }
      firstProbe = false;

      let existsChecked = false;
      let branchUsed = null;
      for (const b of branchCandidates) {
        try {
          const exists = await fileExistsAtRoot(repoFullName, filename, b, headers, timeoutMs);
          if (exists) {
            existsChecked = true;
            branchUsed = b;
            break;
          }
        } catch (err) {
          errors.push(`探测文件是否存在失败(${repoFullName}/${filename}@${b}):${err.message}`);
        }
      }
      setCachedProbe(probeCache, repoFullName, filename, existsChecked);
      probeCacheDirty = true;
      if (!existsChecked) continue;
      const url = `${RAW_BASE}/${repoFullName}/${branchUsed}/${filename}`;
      filesTried.push(url);
      try {
        const { ok, text } = await fetchText(url, timeoutMs);
        if (ok) {
          filesFound.push(url);
          const parsed = parseSubscriptionContent(text);
          parsed.links.forEach((l) => allLinks.add(l));
        }
      } catch (err) {
        errors.push(`抓取失败(${url}):${err.message}`);
      }
    }
  }

  if (probeCacheDirty) writeProbeCache(probeCache);

  return {
    sourceId: repoFullName,
    links: Array.from(allLinks),
    filesFound,
    filesTried,
    errors
  };
}

module.exports = {
  fetchCandidatesForSource,
  parseSubscriptionContent,
  fetchText,
  buildHeaders,
  cleanupProbeCacheFor
};