@sns-parse/core 0.3.0-alpha.1 → 0.4.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/lib/config.d.ts +5 -0
  2. package/lib/config.js +28 -0
  3. package/lib/engine/config-io.d.ts +44 -0
  4. package/lib/engine/config-io.js +155 -0
  5. package/lib/engine/fetcher.d.ts +21 -0
  6. package/lib/engine/fetcher.js +228 -0
  7. package/lib/engine/gateway.d.ts +21 -0
  8. package/lib/engine/gateway.js +19 -0
  9. package/lib/engine/parser.d.ts +2 -0
  10. package/lib/engine/parser.js +162 -0
  11. package/lib/engine/platform-config.d.ts +24 -0
  12. package/lib/engine/platform-config.js +76 -0
  13. package/lib/engine/runtime.d.ts +49 -0
  14. package/lib/engine/runtime.js +90 -0
  15. package/lib/engine/translate.d.ts +15 -0
  16. package/lib/engine/translate.js +107 -0
  17. package/lib/engine/twitter.d.ts +26 -0
  18. package/lib/engine/twitter.js +398 -0
  19. package/lib/index.d.ts +19 -0
  20. package/lib/index.js +19 -0
  21. package/lib/registry.d.ts +20 -0
  22. package/lib/registry.js +77 -0
  23. package/lib/utils/cache.d.ts +9 -0
  24. package/lib/utils/cache.js +34 -0
  25. package/lib/utils/common.d.ts +7 -0
  26. package/lib/utils/common.js +58 -0
  27. package/lib/utils/concurrency.d.ts +8 -0
  28. package/lib/utils/concurrency.js +29 -0
  29. package/lib/utils/field-mapping.d.ts +2 -0
  30. package/lib/utils/field-mapping.js +29 -0
  31. package/lib/utils/format.d.ts +4 -0
  32. package/lib/utils/format.js +79 -0
  33. package/lib/utils/gif.d.ts +10 -0
  34. package/lib/utils/gif.js +104 -0
  35. package/lib/utils/image-size.d.ts +10 -0
  36. package/lib/utils/image-size.js +69 -0
  37. package/lib/utils/merge.d.ts +61 -0
  38. package/lib/utils/merge.js +388 -0
  39. package/lib/utils/tls-client.d.ts +30 -0
  40. package/lib/utils/tls-client.js +120 -0
  41. package/lib/utils/url.d.ts +10 -0
  42. package/lib/utils/url.js +89 -0
  43. package/package.json +16 -4
package/lib/config.d.ts CHANGED
@@ -48,3 +48,8 @@ export declare function defineConfig(contribution: ConfigContribution): ConfigCo
48
48
  * 用于 core 汇总所有扩展与平台声明的配置。
49
49
  */
50
50
  export declare function mergeConfigContributions(lists: ConfigContribution[][]): ConfigContribution[];
51
+ /**
52
+ * 由配置声明提取默认值表(CLI / 无 Schema 宿主用):
53
+ * 平台开关等 object 字段展开为逐键默认值(如 { douyin: true, ... })。
54
+ */
55
+ export declare function defaultsFromContributions(contribs: ConfigContribution[]): Record<string, any>;
package/lib/config.js CHANGED
@@ -11,6 +11,7 @@
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.defineConfig = defineConfig;
13
13
  exports.mergeConfigContributions = mergeConfigContributions;
14
+ exports.defaultsFromContributions = defaultsFromContributions;
14
15
  /** 类型辅助:声明即校验 */
15
16
  function defineConfig(contribution) {
16
17
  return contribution;
@@ -36,3 +37,30 @@ function mergeConfigContributions(lists) {
36
37
  }
37
38
  return out;
38
39
  }
40
+ function applyFieldDefault(target, f) {
41
+ if (f.type === 'object' && f.fields) {
42
+ const obj = {};
43
+ for (const sub of f.fields)
44
+ applyFieldDefault(obj, sub);
45
+ // object 自身 default 与子字段 default 合并(子字段优先)
46
+ target[f.key] = isPlainObject(f.default) ? { ...f.default, ...obj } : obj;
47
+ return;
48
+ }
49
+ if (f.default !== undefined)
50
+ target[f.key] = f.default;
51
+ }
52
+ function isPlainObject(v) {
53
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
54
+ }
55
+ /**
56
+ * 由配置声明提取默认值表(CLI / 无 Schema 宿主用):
57
+ * 平台开关等 object 字段展开为逐键默认值(如 { douyin: true, ... })。
58
+ */
59
+ function defaultsFromContributions(contribs) {
60
+ const out = {};
61
+ for (const c of contribs) {
62
+ for (const f of c.fields)
63
+ applyFieldDefault(out, f);
64
+ }
65
+ return out;
66
+ }
@@ -0,0 +1,44 @@
1
+ export declare const CONFIG_ENVELOPE_KIND = "sns-parse-config";
2
+ export declare const CONFIG_ENVELOPE_VERSION = 1;
3
+ /** 脱敏占位;导入时遇此值跳过(保留现有密钥) */
4
+ export declare const MASK = "***";
5
+ export interface ConfigEnvelope {
6
+ kind: typeof CONFIG_ENVELOPE_KIND;
7
+ version: number;
8
+ exportedAt: string;
9
+ pluginName: string;
10
+ redacted: boolean;
11
+ config: Record<string, any>;
12
+ }
13
+ /** 判断键名是否为敏感字段(另含 customHeaders[].value 等,由调用方逐层递归统一处理) */
14
+ export declare function isSecretKey(key: string): boolean;
15
+ /** 深度克隆并按敏感键名打码;数组原样保留(元素递归) */
16
+ export declare function redactConfig(config: any): any;
17
+ /** 构造导出信封 */
18
+ export declare function createConfigEnvelope(config: any, opts?: {
19
+ pluginName?: string;
20
+ includeSecrets?: boolean;
21
+ }): ConfigEnvelope;
22
+ /** 序列化信封(pretty JSON) */
23
+ export declare function serializeConfigEnvelope(env: ConfigEnvelope): string;
24
+ export declare function isConfigEnvelope(x: any): x is ConfigEnvelope;
25
+ /**
26
+ * 解析导入输入:接受
27
+ * - 信封 JSON
28
+ * - 裸配置对象 JSON
29
+ * - 旧版导出(无 kind,直接是配置)
30
+ */
31
+ export declare function parseConfigInput(input: string): {
32
+ config: Record<string, any>;
33
+ envelope: boolean;
34
+ pluginName?: string;
35
+ };
36
+ /** 深度合并:对象递归合并,数组/标量直接替换;MASK 值跳过(保留现有) */
37
+ export declare function mergeConfig(base: any, patch: any): any;
38
+ /** 统计将被覆盖的顶层键,供 import/migrate 输出摘要 */
39
+ export declare function diffKeys(base: any, patch: any): string[];
40
+ export declare function overrideFilePath(baseDir: string | undefined, name: string): string;
41
+ export declare function readOverride(baseDir: string | undefined, name: string): Record<string, any> | null;
42
+ export declare function writeOverride(baseDir: string | undefined, name: string, config: Record<string, any>, pluginName?: string): string;
43
+ /** 读覆盖文件并合并到 schema 配置之上(启动时调用) */
44
+ export declare function applyOverrideToConfig(config: any, baseDir: string | undefined, name: string): any;
@@ -0,0 +1,155 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MASK = exports.CONFIG_ENVELOPE_VERSION = exports.CONFIG_ENVELOPE_KIND = void 0;
4
+ exports.isSecretKey = isSecretKey;
5
+ exports.redactConfig = redactConfig;
6
+ exports.createConfigEnvelope = createConfigEnvelope;
7
+ exports.serializeConfigEnvelope = serializeConfigEnvelope;
8
+ exports.isConfigEnvelope = isConfigEnvelope;
9
+ exports.parseConfigInput = parseConfigInput;
10
+ exports.mergeConfig = mergeConfig;
11
+ exports.diffKeys = diffKeys;
12
+ exports.overrideFilePath = overrideFilePath;
13
+ exports.readOverride = readOverride;
14
+ exports.writeOverride = writeOverride;
15
+ exports.applyOverrideToConfig = applyOverrideToConfig;
16
+ /**
17
+ * 配置导出 / 导入 / 迁移。
18
+ *
19
+ * 目的:新旧包使用不同命名空间(旧 video-parser-all / 新 sns-parse),
20
+ * koishi 配置键随之不同,因此提供可跨命名空间移植的配置信封。
21
+ *
22
+ * - 导出:默认脱敏(密钥打码 MASK),--include-secrets 才输出明文
23
+ * - 导入:深度合并进当前配置;MASK 值跳过(保留现有密钥)
24
+ * - 持久化:自管覆盖文件 <baseDir>/data/<name>/config.override.json
25
+ */
26
+ const fs_1 = require("fs");
27
+ const path_1 = require("path");
28
+ exports.CONFIG_ENVELOPE_KIND = 'sns-parse-config';
29
+ exports.CONFIG_ENVELOPE_VERSION = 1;
30
+ /** 脱敏占位;导入时遇此值跳过(保留现有密钥) */
31
+ exports.MASK = '***';
32
+ /** 需要脱敏的键名(大小写不敏感) */
33
+ const SECRET_KEYS = new Set([
34
+ 'apikey', 'secretkey', 'secretid', 'accesskeyid', 'accesskeysecret',
35
+ 'authtoken', 'ct0', 'twitterauthtoken', 'twitterct0', 'password',
36
+ ]);
37
+ function isPlainObject(v) {
38
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
39
+ }
40
+ /** 判断键名是否为敏感字段(另含 customHeaders[].value 等,由调用方逐层递归统一处理) */
41
+ function isSecretKey(key) {
42
+ return SECRET_KEYS.has(key.toLowerCase());
43
+ }
44
+ /** 深度克隆并按敏感键名打码;数组原样保留(元素递归) */
45
+ function redactConfig(config) {
46
+ if (Array.isArray(config))
47
+ return config.map(redactConfig);
48
+ if (!isPlainObject(config))
49
+ return config;
50
+ const out = {};
51
+ for (const [k, v] of Object.entries(config)) {
52
+ if (isSecretKey(k) && typeof v === 'string' && v)
53
+ out[k] = exports.MASK;
54
+ else
55
+ out[k] = redactConfig(v);
56
+ }
57
+ return out;
58
+ }
59
+ /** 构造导出信封 */
60
+ function createConfigEnvelope(config, opts = {}) {
61
+ const cfg = opts.includeSecrets ? JSON.parse(JSON.stringify(config ?? {})) : redactConfig(config);
62
+ return {
63
+ kind: exports.CONFIG_ENVELOPE_KIND,
64
+ version: exports.CONFIG_ENVELOPE_VERSION,
65
+ exportedAt: new Date().toISOString(),
66
+ pluginName: opts.pluginName || '',
67
+ redacted: !opts.includeSecrets,
68
+ config: cfg,
69
+ };
70
+ }
71
+ /** 序列化信封(pretty JSON) */
72
+ function serializeConfigEnvelope(env) {
73
+ return JSON.stringify(env, null, 2);
74
+ }
75
+ function isConfigEnvelope(x) {
76
+ return !!x && typeof x === 'object' && x.kind === exports.CONFIG_ENVELOPE_KIND;
77
+ }
78
+ /**
79
+ * 解析导入输入:接受
80
+ * - 信封 JSON
81
+ * - 裸配置对象 JSON
82
+ * - 旧版导出(无 kind,直接是配置)
83
+ */
84
+ function parseConfigInput(input) {
85
+ let data;
86
+ const text = (input || '').trim();
87
+ if (!text)
88
+ throw new Error('导入内容为空');
89
+ try {
90
+ data = JSON.parse(text);
91
+ }
92
+ catch (e) {
93
+ throw new Error(`导入内容不是合法 JSON:${e?.message || e}`);
94
+ }
95
+ if (isConfigEnvelope(data)) {
96
+ return { config: data.config || {}, envelope: true, pluginName: data.pluginName };
97
+ }
98
+ if (isPlainObject(data))
99
+ return { config: data, envelope: false };
100
+ throw new Error('导入内容既不是配置信封也不是配置对象');
101
+ }
102
+ /** 深度合并:对象递归合并,数组/标量直接替换;MASK 值跳过(保留现有) */
103
+ function mergeConfig(base, patch) {
104
+ if (!isPlainObject(patch))
105
+ return patch;
106
+ const out = isPlainObject(base) ? { ...base } : {};
107
+ for (const [k, v] of Object.entries(patch)) {
108
+ if (v === exports.MASK)
109
+ continue;
110
+ out[k] = isPlainObject(v) && isPlainObject(out[k]) ? mergeConfig(out[k], v) : v;
111
+ }
112
+ return out;
113
+ }
114
+ /** 统计将被覆盖的顶层键,供 import/migrate 输出摘要 */
115
+ function diffKeys(base, patch) {
116
+ if (!isPlainObject(patch))
117
+ return [];
118
+ const out = [];
119
+ for (const [k, v] of Object.entries(patch)) {
120
+ if (v === exports.MASK)
121
+ continue;
122
+ if (isPlainObject(v) && isPlainObject(base?.[k]))
123
+ out.push(...diffKeys(base[k], v).map(sub => `${k}.${sub}`));
124
+ else
125
+ out.push(k);
126
+ }
127
+ return out;
128
+ }
129
+ /* ---------- 覆盖文件持久化 ---------- */
130
+ function overrideFilePath(baseDir, name) {
131
+ const root = baseDir || process.cwd();
132
+ return (0, path_1.join)(root, 'data', name, 'config.override.json');
133
+ }
134
+ function readOverride(baseDir, name) {
135
+ try {
136
+ const raw = (0, fs_1.readFileSync)(overrideFilePath(baseDir, name), 'utf8');
137
+ const data = JSON.parse(raw);
138
+ return isConfigEnvelope(data) ? data.config : (isPlainObject(data) ? data : null);
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ }
144
+ function writeOverride(baseDir, name, config, pluginName) {
145
+ const file = overrideFilePath(baseDir, name);
146
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(file), { recursive: true });
147
+ const env = createConfigEnvelope(config, { pluginName: pluginName || name, includeSecrets: true });
148
+ (0, fs_1.writeFileSync)(file, serializeConfigEnvelope(env), 'utf8');
149
+ return file;
150
+ }
151
+ /** 读覆盖文件并合并到 schema 配置之上(启动时调用) */
152
+ function applyOverrideToConfig(config, baseDir, name) {
153
+ const override = readOverride(baseDir, name);
154
+ return override ? mergeConfig(config, override) : config;
155
+ }
@@ -0,0 +1,21 @@
1
+ import type { ParserRuntime } from './runtime';
2
+ import type { ParsedData } from '../types';
3
+ export declare function fetchApi(rt: ParserRuntime, url: string, type: string, fieldMapping?: Record<string, string>, platformConf?: any): Promise<ParsedData>;
4
+ export declare function parseUrl(rt: ParserRuntime, url: string, type: string, fieldMapping?: Record<string, string>, platformConf?: any): Promise<{
5
+ success: true;
6
+ data: ParsedData;
7
+ } | {
8
+ success: false;
9
+ msg: string;
10
+ }>;
11
+ export declare function processSingleUrl(rt: ParserRuntime, url: string, type: string, fieldMapping?: Record<string, string>, platformConf?: any): Promise<{
12
+ success: true;
13
+ data: {
14
+ text: string;
15
+ parsed: ParsedData;
16
+ };
17
+ } | {
18
+ success: false;
19
+ msg: string;
20
+ url: string;
21
+ }>;
@@ -0,0 +1,228 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.fetchApi = fetchApi;
7
+ exports.parseUrl = parseUrl;
8
+ exports.processSingleUrl = processSingleUrl;
9
+ const axios_1 = __importDefault(require("axios"));
10
+ const https_proxy_agent_1 = require("https-proxy-agent");
11
+ const logger_1 = require("../logger");
12
+ const common_1 = require("../utils/common");
13
+ const format_1 = require("../utils/format");
14
+ const parser_1 = require("./parser");
15
+ const platform_config_1 = require("./platform-config");
16
+ const twitter_1 = require("./twitter");
17
+ const translate_1 = require("./translate");
18
+ const tls_client_1 = require("../utils/tls-client");
19
+ const gateway_1 = require("./gateway");
20
+ async function fetchApi(rt, url, type, fieldMapping, platformConf) {
21
+ const { config, http, urlCacheLocal, proxyConfig, cacheTTL } = rt;
22
+ const cacheKey = url;
23
+ const cached = urlCacheLocal.get(cacheKey);
24
+ if (cached && cached.expire > Date.now()) {
25
+ (0, logger_1.debugLog)('缓存命中,直接返回:', url);
26
+ return cached.data;
27
+ }
28
+ const { apiUrl: dedicatedUrl, dedicatedFirst, apiKey, authHeaderType, customHeaderName, customProxy } = platformConf || (0, platform_config_1.getPlatformConfig)(rt, type);
29
+ // X / Twitter:统一网关均走原生 syndication 解析(除非用户自定义了 API)
30
+ if (type === 'twitter' && !dedicatedUrl) {
31
+ (0, logger_1.debugLog)('twitter 走原生 syndication 解析:', url);
32
+ const twCreds = (config.twitterAuthToken && config.twitterCt0)
33
+ ? { authToken: String(config.twitterAuthToken), ct0: String(config.twitterCt0) }
34
+ : undefined;
35
+ // 代理串通:axios 走 runtime 实例;tlsget-rs(GraphQL/Grok 翻译)显式透传代理地址
36
+ const tlsProxy = (proxyConfig.enabled && proxyConfig.host)
37
+ ? `${proxyConfig.protocol || 'http'}://${proxyConfig.auth?.username ? `${encodeURIComponent(proxyConfig.auth.username)}:${encodeURIComponent(proxyConfig.auth.password || '')}@` : ''}${proxyConfig.host}:${proxyConfig.port || 7890}`
38
+ : undefined;
39
+ const tlsGetWithProxy = (u, o) => (0, tls_client_1.tlsGet)(u, { ...o, proxy: tlsProxy });
40
+ const parsed = await (0, twitter_1.parseTwitter)(url, http, twCreds, tlsGetWithProxy);
41
+ // 推文翻译:目标语种与推文语种相同时跳过;Grok(需登录态)优先,通用翻译兜底
42
+ if (config.tweetTranslateEnabled && parsed.desc) {
43
+ const target = config.tweetTranslateLang || 'zh';
44
+ if (!(0, translate_1.shouldSkipTranslate)(parsed.lang, target)) {
45
+ let translated = null;
46
+ let provider = '';
47
+ if (twCreds) {
48
+ const grok = await (0, twitter_1.fetchGrokTranslation)(url, target, twCreds, tlsGetWithProxy).catch(() => null);
49
+ if (grok) {
50
+ translated = grok.text;
51
+ provider = 'Grok';
52
+ if (!parsed.lang && grok.sourceLang)
53
+ parsed.lang = grok.sourceLang;
54
+ }
55
+ }
56
+ if (!translated) {
57
+ const generic = await (0, translate_1.translateText)(rt, parsed.desc, target, parsed.lang);
58
+ if (generic) {
59
+ translated = generic.text;
60
+ provider = generic.provider;
61
+ }
62
+ }
63
+ if (translated) {
64
+ parsed.translation = translated;
65
+ parsed.translationProvider = provider;
66
+ }
67
+ }
68
+ }
69
+ urlCacheLocal.set(cacheKey, { data: parsed, expire: Date.now() + cacheTTL });
70
+ return parsed;
71
+ }
72
+ // 网关选择(上游 issue #12):配置 apiKey → 新网关(无备用 API);否则 → 旧网关(主+备用)。
73
+ // 注意:primaryApiUrl 是旧网关覆盖字段,新网关不受其影响(避免旧配置劫持切换)
74
+ const useNewGateway = !!config.apiKey;
75
+ const primaryApi = useNewGateway
76
+ ? gateway_1.NEW_GATEWAY_PRIMARY
77
+ : (config.primaryApiUrl || gateway_1.LEGACY_GATEWAY_PRIMARY);
78
+ const backupApi = config.backupApiUrl || gateway_1.LEGACY_GATEWAY_BACKUP;
79
+ const backupAllowed = !useNewGateway && new Set(['douyin', 'xiaohongshu', 'instagram', 'jimeng']).has(type);
80
+ const gwConf = useNewGateway
81
+ ? { apiKey: config.apiKey, authHeaderType: 'X-API-Key', customHeaderName: 'X-API-Key' }
82
+ : { apiKey: '', authHeaderType: undefined, customHeaderName: undefined };
83
+ const apiList = [];
84
+ if (dedicatedFirst && dedicatedUrl) {
85
+ apiList.push({ url: dedicatedUrl, label: `专属API(${type})`, apiKey, authHeaderType, customHeaderName, fieldMapping });
86
+ apiList.push({ url: primaryApi, label: '默认主API', ...gwConf, fieldMapping });
87
+ if (backupAllowed)
88
+ apiList.push({ url: backupApi, label: '备用主API', ...gwConf, fieldMapping });
89
+ }
90
+ else {
91
+ apiList.push({ url: primaryApi, label: '默认主API', ...gwConf, fieldMapping });
92
+ if (backupAllowed)
93
+ apiList.push({ url: backupApi, label: '备用主API', ...gwConf, fieldMapping });
94
+ if (dedicatedUrl)
95
+ apiList.push({ url: dedicatedUrl, label: `专属API(${type})`, apiKey, authHeaderType, customHeaderName, fieldMapping });
96
+ }
97
+ if (type.startsWith('custom_') && apiList.length === 0 && dedicatedUrl) {
98
+ apiList.push({ url: dedicatedUrl, label: `自定义API(${type})`, apiKey, authHeaderType, customHeaderName, fieldMapping });
99
+ }
100
+ // 新网关模式下追加旧网关末位兜底:新网关无专属端点的平台(微博/小红书等)或主 API 故障
101
+ // (5xx/网关侧异常)时仍有机会解析;旧网关对语义问题会返回更明确的错误信息
102
+ if (useNewGateway && !type.startsWith('custom_')) {
103
+ const legacyDedicated = rt.dedicatedApis?.legacy?.[type];
104
+ const legacyMain = config.primaryApiUrl && config.primaryApiUrl !== gateway_1.LEGACY_GATEWAY_PRIMARY ? config.primaryApiUrl : gateway_1.LEGACY_GATEWAY_PRIMARY;
105
+ if (legacyDedicated && !apiList.some((a) => a.url === legacyDedicated)) {
106
+ apiList.push({ url: legacyDedicated, label: `旧网关专属API(${type})`, apiKey: '', authHeaderType: undefined, customHeaderName: undefined, fieldMapping });
107
+ }
108
+ if (!apiList.some((a) => a.url === legacyMain)) {
109
+ apiList.push({ url: legacyMain, label: '旧网关主API', apiKey: '', authHeaderType: undefined, customHeaderName: undefined, fieldMapping });
110
+ }
111
+ if (!apiList.some((a) => a.url === backupApi)) {
112
+ apiList.push({ url: backupApi, label: '旧网关备用API', apiKey: '', authHeaderType: undefined, customHeaderName: undefined, fieldMapping });
113
+ }
114
+ }
115
+ const customHeaders = config.customHeaders || [];
116
+ const errors = [];
117
+ for (const [apiIndex, api] of apiList.entries()) {
118
+ for (let attempt = 0; attempt <= config.retryTimes; attempt++) {
119
+ try {
120
+ const headers = {
121
+ 'User-Agent': config.userAgent,
122
+ 'Referer': 'https://www.baidu.com/',
123
+ 'Content-Type': 'application/x-www-form-urlencoded'
124
+ };
125
+ for (const h of customHeaders) {
126
+ if (h.name && h.value)
127
+ headers[h.name] = h.value;
128
+ }
129
+ if (api.apiKey) {
130
+ const authHeaders = (0, platform_config_1.buildAuthHeaders)(api.apiKey, api.authHeaderType || 'Bearer', api.customHeaderName || 'X-API-Key');
131
+ Object.assign(headers, authHeaders);
132
+ }
133
+ const proxyToUse = customProxy && customProxy.enabled ? customProxy : (proxyConfig.enabled ? proxyConfig : undefined);
134
+ const axiosConfigLocal = {
135
+ params: { url },
136
+ timeout: config.timeout,
137
+ headers,
138
+ };
139
+ // 代理:runtime 实例已按全局代理配置好 https-proxy-agent;
140
+ // 仅当平台级 customProxy 覆盖时才按请求另建 agent(不用 axios 内置 proxy 选项——对 https 目标不做 CONNECT 隧道)
141
+ if (customProxy && customProxy.enabled && customProxy.host) {
142
+ const pUrl = `${customProxy.protocol || 'http'}://${customProxy.auth?.username ? `${encodeURIComponent(customProxy.auth.username)}:${encodeURIComponent(customProxy.auth.password || '')}@` : ''}${customProxy.host}:${customProxy.port || 7890}`;
143
+ const agent = new https_proxy_agent_1.HttpsProxyAgent(pUrl);
144
+ axiosConfigLocal.httpAgent = agent;
145
+ axiosConfigLocal.httpsAgent = agent;
146
+ axiosConfigLocal.proxy = false;
147
+ }
148
+ const res = await http.get(api.url, axiosConfigLocal);
149
+ // 兼容第三方 API:响应不含 code/status 状态字段时视为成功,直接交由解析器处理
150
+ const code = res.data?.code;
151
+ const ok = res.data && (code === 200 || code === 0 || (code === undefined && res.data.status === undefined));
152
+ if (ok) {
153
+ // 回退链路重要事件:非首个 API 成功说明上游发生了故障切换
154
+ if (apiIndex > 0)
155
+ logger_1.logger.info(`${api.label} 回退解析成功(此前 API 失败): ${url}`);
156
+ else
157
+ (0, logger_1.debugLog)(`${api.label} 解析成功: ${url}`);
158
+ const parsed = (0, parser_1.parseApiResponse)(res.data, config.maxDescLength, api.fieldMapping);
159
+ urlCacheLocal.set(cacheKey, { data: parsed, expire: Date.now() + cacheTTL });
160
+ return parsed;
161
+ }
162
+ throw new Error(res.data?.msg || res.data?.message || `API返回错误码: ${code ?? res.data?.status}`);
163
+ }
164
+ catch (error) {
165
+ const lastErr = error instanceof Error ? error : new Error(String(error));
166
+ errors.push(lastErr);
167
+ (0, logger_1.debugLog)(`${api.label} attempt ${attempt + 1} failed: ${lastErr.message}`);
168
+ if (axios_1.default.isAxiosError(error)) {
169
+ if (!error.response) {
170
+ if (attempt < config.retryTimes) {
171
+ await (0, common_1.delay)(config.retryInterval);
172
+ continue;
173
+ }
174
+ }
175
+ const status = error.response?.status;
176
+ if (status && (status >= 500 || status === 429)) {
177
+ if (attempt < config.retryTimes) {
178
+ await (0, common_1.delay)(config.retryInterval);
179
+ continue;
180
+ }
181
+ }
182
+ }
183
+ break;
184
+ }
185
+ }
186
+ logger_1.logger.warn(`${api.label} 全部重试失败`);
187
+ }
188
+ // 优先返回语义级错误(服务端明确说明原因),避免被后续兜底 API 的 5xx/网络/风控泛错覆盖;
189
+ // 「Request failed with status code NNN」这类 axios 泛错无信息量,同样降级
190
+ const semantic = errors.find((e) => !/status code \d{3}|socket|ECONNRESET|ETIMEDOUT|timeout|Bad Gateway|Service Unavailable/i.test(e.message));
191
+ throw semantic || errors[errors.length - 1] || new Error('所有API请求全部失败');
192
+ }
193
+ async function parseUrl(rt, url, type, fieldMapping, platformConf) {
194
+ try {
195
+ const info = await fetchApi(rt, url, type, fieldMapping, platformConf);
196
+ const hasMedia = !!(info.video || info.images.length > 0 || info.live_photo.length > 0);
197
+ if (hasMedia)
198
+ return { success: true, data: info };
199
+ // 纯文字内容(如 X 纯文字推文):有正文即视为成功,下游发送文字卡片
200
+ if (info.type === 'text' && (info.title || info.desc)) {
201
+ (0, logger_1.debugLog)(`纯文字内容: ${url}`);
202
+ return { success: true, data: info };
203
+ }
204
+ (0, logger_1.debugLog)(`解析成功但无内容: ${url}`);
205
+ return { success: false, msg: '解析接口返回空内容' };
206
+ }
207
+ catch (error) {
208
+ logger_1.logger.error(`解析失败: ${url}`, (0, common_1.getErrorMessage)(error));
209
+ let msg = (0, common_1.getErrorMessage)(error);
210
+ // 小红书风控:裸 /discovery/item 链接缺 xsec_token,服务端获取 gate 票据失败;提示用户改发完整分享链接
211
+ if (type === 'xiaohongshu' && /xsec_token|gate|票据/i.test(msg)) {
212
+ msg += '(小红书升级了风控:请发送完整分享链接,如 xhslink.com 短链、App 分享文案里的原链接,或带 xsec_token 参数的网页链接)';
213
+ }
214
+ // 微博:API 仅支持视频微博,图文微博/新版 base62 短链会报「无法从URL中提取视频ID」
215
+ if (type === 'weibo' && /无法从URL中提取视频ID|提取.*视频ID/.test(msg)) {
216
+ msg += '(微博解析仅支持视频微博链接,如 weibo.com/tv/show/… 或 video.weibo.com/show?fid=…;图文微博与新版短链 /用户ID/短ID 形式暂不支持)';
217
+ }
218
+ return { success: false, msg };
219
+ }
220
+ }
221
+ async function processSingleUrl(rt, url, type, fieldMapping, platformConf) {
222
+ const { config } = rt;
223
+ const result = await parseUrl(rt, url, type, fieldMapping, platformConf);
224
+ if (!result.success)
225
+ return { success: false, msg: result.msg, url };
226
+ const text = (0, format_1.generateFormattedText)(result.data, config.unifiedMessageFormat);
227
+ return { success: true, data: { text, parsed: result.data } };
228
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * 双网关配置(上游 issue #12):
3
+ * - 旧网关 api.bugpk.com:无需 API Key,v1.5.8 及之前的行为
4
+ * - 新网关 api-new.ifphp.com:需 API Key(X-API-Key 头),上游唯一维护入口
5
+ * 选择规则:配置了 apiKey → 新网关;否则 → 旧网关
6
+ *
7
+ * 平台专属端点内联在各 PlatformDefinition(dedicated 字段),此处提供
8
+ * 由定义列表聚合出的映射函数(runtime 构建时计算一次挂在 rt 上)。
9
+ */
10
+ import type { PlatformDefinition } from '../platform';
11
+ export declare const NEW_GATEWAY_PRIMARY = "https://api-new.ifphp.com/api/svparse";
12
+ export declare const LEGACY_GATEWAY_PRIMARY = "https://api.bugpk.com/api/short_videos";
13
+ export declare const LEGACY_GATEWAY_BACKUP = "https://api.bugpk.com/api/svparse";
14
+ export interface DedicatedApiMaps {
15
+ /** 旧网关平台专属端点(无需 Key) */
16
+ legacy: Record<string, string>;
17
+ /** 新网关平台专属端点(需 Key;未覆盖的平台走主 API 兜底) */
18
+ next: Record<string, string>;
19
+ }
20
+ /** 由平台定义列表聚合专属端点映射 */
21
+ export declare function dedicatedApisFrom(defs: PlatformDefinition[]): DedicatedApiMaps;
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LEGACY_GATEWAY_BACKUP = exports.LEGACY_GATEWAY_PRIMARY = exports.NEW_GATEWAY_PRIMARY = void 0;
4
+ exports.dedicatedApisFrom = dedicatedApisFrom;
5
+ exports.NEW_GATEWAY_PRIMARY = 'https://api-new.ifphp.com/api/svparse';
6
+ exports.LEGACY_GATEWAY_PRIMARY = 'https://api.bugpk.com/api/short_videos';
7
+ exports.LEGACY_GATEWAY_BACKUP = 'https://api.bugpk.com/api/svparse';
8
+ /** 由平台定义列表聚合专属端点映射 */
9
+ function dedicatedApisFrom(defs) {
10
+ const legacy = {};
11
+ const next = {};
12
+ for (const p of defs) {
13
+ if (p.dedicated?.legacy)
14
+ legacy[p.type] = p.dedicated.legacy;
15
+ if (p.dedicated?.next)
16
+ next[p.type] = p.dedicated.next;
17
+ }
18
+ return { legacy, next };
19
+ }
@@ -0,0 +1,2 @@
1
+ import type { ParsedData } from '../types';
2
+ export declare function parseApiResponse(rawInput: any, maxDescLen: number, fieldMapping?: Record<string, string>): ParsedData;