#!/usr/bin/env node import { readFileSync, readdirSync, existsSync } from 'node:fs' import { join, resolve, relative, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import { createHash } from 'node:crypto' const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') export const OFFICIAL_HOSTS = { openai: ['developers.openai.com', 'platform.openai.com', 'learn.chatgpt.com', 'github.com'], anthropic: ['platform.claude.com', 'docs.claude.com', 'anthropic.com', 'www.anthropic.com'], 'github-copilot': ['docs.github.com', 'github.com'], deepseek: ['api-docs.deepseek.com'], alibaba: ['help.aliyun.com', 'www.alibabacloud.com'], volcengine: ['docs.volcengine.com', 'www.volcengine.com'], moonshot: ['platform.kimi.com', 'platform.kimi.ai', 'platform.moonshot.cn', 'platform.moonshot.ai'], zhipu: ['docs.bigmodel.cn', 'open.bigmodel.cn', 'docs.z.ai'], baichuan: ['www.baichuan-ai.com', 'platform.baichuan-ai.com'], minimax: ['platform.minimax.cn', 'platform.minimaxi.com', 'platform.minimax.io'], xiaomi: ['mimo.mi.com', 'mimo.xiaomi.com', 'platform.xiaomimimo.com'], baidu: ['cloud.baidu.com', 'intl.cloud.baidu.com', 'ernie.baidu.com'], tencent: ['www.tencent.com', 'cloud.tencent.com', 'cloud.tencent.com.cn', 'intl.cloud.tencent.com'], xunfei: ['www.xfyun.cn', 'xinghuo.xfyun.cn'], siliconflow: ['www.siliconflow.cn', 'docs.siliconflow.cn', 'www.siliconflow.com', 'docs.siliconflow.com'], ollama: ['ollama.com', 'docs.ollama.com'], google: ['ai.google.dev', 'cloud.google.com'], mistral: ['docs.mistral.ai', 'mistral.ai'], xai: ['docs.x.ai', 'x.ai'], cohere: ['docs.cohere.com', 'cohere.com'], kling: ['kling.ai', 'app.klingai.com', 'ir.kuaishou.com'], openrouter: ['openrouter.ai'], perplexity: ['docs.perplexity.ai', 'www.perplexity.ai', 'research.perplexity.ai'], stability: ['stability.ai', 'platform.stability.ai'], stealth: ['openrouter.ai', 'docs.bigmodel.cn'], infini: ['docs.infini-ai.com', 'cloud.infini-ai.com'], moorethread: ['code.mthreads.com', 'www.mthreads.com', 'developer.mthreads.com'], kwai: ['www.streamlake.ai', 'streamlake.com', 'www.streamlake.com'], } export function sourceUrlError(supplier, value) { let u try { u = new URL(value) } catch { return '不是合法 URL' } if (u.protocol !== 'https:' || u.username || u.password) return '必须是无凭据的 HTTPS URL' if (!OFFICIAL_HOSTS[supplier]?.includes(u.hostname)) return `未登记为 ${supplier} 的官方域名` if (u.hostname === 'github.com' && supplier === 'openai' && !u.pathname.startsWith('/openai/')) return 'GitHub 来源必须属于 openai 官方组织' if (/\/(?:article|articles|ask|community)\//i.test(u.pathname)) return '社区/用户文章不能作为官网主证据' if ([...u.searchParams.keys()].some((k) => /^(?:api[_-]?key|token|password|secret|access_token)$/i.test(k))) return '来源 URL 不得包含凭据参数' return null } const stable = (value) => { if (Array.isArray(value)) return value.map(stable) if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])])) return value } export const modelFingerprint = (row) => createHash('sha256').update(JSON.stringify(stable(row))).digest('hex') export const configFingerprint = (data) => modelFingerprint(Object.fromEntries(['id', 'provider', 'brandGroup', 'label', 'baseUrl', 'mediaBaseUrl', 'apiFormat', 'priceCurrency', 'services', 'accessMode', 'credentialSource', 'requiredClientVersion', 'tombstones', 'description', 'codingPlan'].filter((key) => key in data).map((key) => [key, data[key]]))) const get = (row, path) => path.split('.').reduce((value, key) => value?.[key], row) const dateValid = (value) => /^\d{4}-\d{2}-\d{2}$/.test(value ?? '') && !Number.isNaN(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value const configSupplier = (config) => { const name = config.split('/').at(-1).replace(/\.json$/, '') const aliases = { 'openai-codex': 'openai', 'local-whisper': 'openai', 'anthropic-claude': 'anthropic', dashscope: 'alibaba', qwen: 'alibaba', wan: 'alibaba', happyhorse: 'alibaba', 'dashscope-coding': 'alibaba', 'dashscope-token-plan': 'alibaba', 'zhipu-embedding': 'zhipu', 'tencent-token': 'tencent', 'moorethread-coding': 'moorethread' } return aliases[name] ?? name.replace(/-coding$/, '') } export function parseSourcePage(text) { const match = text.match(/\s*```json\s*([\s\S]*?)\s*```\s*/) if (!match) throw new Error('缺少 source-metadata JSON 块') const metadata = JSON.parse(match[1]) const records = [] const configFingerprints = new Map() let config = null for (const line of text.split('\n')) { const marker = line.match(/^$/) if (marker) { config = marker[1]; continue } const topFingerprint = line.match(/^$/) if (config && topFingerprint) configFingerprints.set(config, topFingerprint[1]) if (!config || !/^\| `/.test(line)) continue const cells = line.split('|').slice(1, -1).map((s) => s.trim()) if (cells.length !== 7) throw new Error('来源表必须有 7 列') const id = cells[0].match(/^`([^`]+)`$/)?.[1] const fingerprint = cells[6].match(/^`([a-f0-9]{64})`$/)?.[1] const fieldSources = [...cells[3].matchAll(/`([^`]+)`→\[([\w-]+)\]\(([^)]*?)#([\w-]+)\)/g)].map((m) => ({ field: m[1], source: m[2], target: m[3], anchor: m[4] })) const sources = [...cells[4].matchAll(/\[([\w-]+)\]\(([^)]*?)#([\w-]+)\)/g)].map((m) => ({ source: m[1], target: m[2], anchor: m[3] })) records.push({ config, id, fingerprint, fieldSources, sources, status: cells[5], limits: cells[1], pricing: cells[2] }) } const details = [...text.matchAll(/\s*```json\s*([\s\S]*?)\s*```\s*/g)].map((m) => ({ ...JSON.parse(m[1]), fields: JSON.parse(m[2]) })) return { metadata, records, configFingerprints, details } } export function validateSources(root = ROOT) { const errors = [], inventory = new Map(), documented = new Set(), configurations = new Map(), checkedConfigs = new Set() for (const [folder, key, idKey] of [['providers', 'models', 'modelName'], ['coding-plans', 'models', 'modelName'], ['model-specs', 'specs', 'id']]) { const index = JSON.parse(readFileSync(join(root, 'compute', folder, '_index.json'), 'utf8')) for (const name of index.order) { const config = `compute/${folder}/${name}.json` const data = JSON.parse(readFileSync(join(root, config), 'utf8')) configurations.set(config, data) for (const row of data[key]) { const address = `${config}#${row[idKey]}` if (inventory.has(address)) errors.push(`${address}: 配置中存在重复 ID`) inventory.set(address, { row, data }) } } } const dir = join(root, 'docs/model-sources/providers') let pages = 0, partial = 0, pending = 0, historical = 0 if (!existsSync(dir)) errors.push('缺少 docs/model-sources/providers') const walk = (folder) => readdirSync(folder, { withFileTypes: true }).flatMap((item) => { const path = join(folder, item.name) return item.isDirectory() ? walk(path) : item.name.endsWith('.md') ? [path] : [] }) for (const path of existsSync(dir) ? walk(dir) : []) { const file = relative(dir, path) let page const text = readFileSync(path, 'utf8') if (!text.includes('') && !text.includes('