v2er-insight 1.0.0 → 1.2.0

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.
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  V2EX 用户画像深度分析工具。通过自动化抓取数据、统计解析及 AI 语言模型建模,构建多维度的用户行为与心理画像。
4
4
 
5
+ 目前画像结果一般。受限于模型能力、Analyze 结果、提示词,后两个需要更多的迭代。
6
+
5
7
  ## 核心流程 (Pipe Flow)
6
8
 
7
9
  本项目采用管道化设计,目前通过以下步骤逐步生成深度报告:
@@ -50,7 +52,7 @@ v2er <username>
50
52
  | 选项 | 说明 |
51
53
  | -------------------------- | ------------------------------------------------------------------ |
52
54
  | `--force` | 强制重新抓取(忽略本地缓存) |
53
- | `--model [name]` | 指定 AI 模型(默认: `gemini-3-pro-preview`) |
55
+ | `--model [name]` | 指定 AI 模型(默认: `gemini-3.1-pro-preview`) |
54
56
  | `--thinking-level [level]` | 指定思考等级(默认: `high`,可选 `minimal`/`low`/`medium`/`high`) |
55
57
  | `-v, --verbose` | 显示调试输出 |
56
58
 
@@ -98,7 +100,7 @@ v2er ai <username> [选项]
98
100
 
99
101
  | 选项 | 说明 |
100
102
  | -------------------------- | ------------------------------------------------------------------ |
101
- | `--model [name]` | 指定 Gemini 模型(默认: `gemini-3-pro-preview`) |
103
+ | `--model [name]` | 指定 Gemini 模型(默认: `gemini-3.1-pro-preview`) |
102
104
  | `--thinking-level [level]` | 指定思考等级(默认: `high`,可选 `minimal`/`low`/`medium`/`high`) |
103
105
 
104
106
  ### 4. 报告展示 (Show)
@@ -131,6 +133,12 @@ v2er config set ai.thinkingLevel medium # 设置思考等级
131
133
  v2er config set log.level debug # 开启调试日志
132
134
  v2er config set data.keepRaw true # 保留原始数据
133
135
  v2er config set ai.timeout 120000 # AI 请求超时 120s
136
+ v2er config set ai.maxRetries 5 # AI 最大重试次数
137
+ v2er config set ai.baseDelay 2000 # AI 重试基础延迟 2s
138
+ v2er config set ai.maxDelay 20000 # AI 重试最大延迟 20s
139
+ v2er config set fetch.maxRetries 5 # 抓取最大重试次数
140
+ v2er config set fetch.baseDelay 2000 # 抓取重试基础延迟 2s
141
+ v2er config set fetch.maxDelay 30000 # 抓取重试最大延迟 30s
134
142
 
135
143
  # 重置
136
144
  v2er config reset # 重置全部为默认值
@@ -52,7 +52,7 @@ async function runAi(username, options) {
52
52
  // 字符串时直接使用,否则回退到配置/默认值(后续支持交互选择时替换此逻辑)
53
53
  const model = typeof options.model === 'string'
54
54
  ? options.model
55
- : (config.ai?.model ?? 'gemini-3-pro-preview');
55
+ : (config.ai?.model ?? config_1.DEFAULT_CONFIG.ai.model);
56
56
  // 同上:--thinking-level [level] 无值时为 true,字符串时直接使用
57
57
  const rawThinkingLevel = typeof options.thinkingLevel === 'string' ? options.thinkingLevel : config.ai?.thinkingLevel;
58
58
  // 校验 thinkingLevel 合法性
@@ -81,9 +81,14 @@ async function runAi(username, options) {
81
81
  // 创建 Provider
82
82
  const provider = new ai_1.GeminiProvider(apiKey, model);
83
83
  const retryOptions = {
84
- maxRetries: config.ai?.maxRetries,
85
- baseDelay: config.ai?.baseDelay,
86
- maxDelay: config.ai?.maxDelay,
84
+ maxRetries: config.ai?.maxRetries ?? config_1.DEFAULT_CONFIG.ai.maxRetries,
85
+ baseDelay: config.ai?.baseDelay ?? config_1.DEFAULT_CONFIG.ai.baseDelay,
86
+ maxDelay: config.ai?.maxDelay ?? config_1.DEFAULT_CONFIG.ai.maxDelay,
87
+ onRetry: (attempt, maxRetries, error, delay) => {
88
+ const delaySec = (delay / 1000).toFixed(1);
89
+ logger_1.logger.warn(` AI 重试 (${attempt}/${maxRetries}) [${delaySec}s 后]`);
90
+ logger_1.logger.debug(` 原因: ${error.message}`);
91
+ },
87
92
  };
88
93
  try {
89
94
  // 初始化会话
@@ -36,6 +36,9 @@ const CONFIG_PATHS = {
36
36
  'ai.maxDelay': { type: 'number' },
37
37
  // Fetch
38
38
  'fetch.timeout': { type: 'number' },
39
+ 'fetch.maxRetries': { type: 'number' },
40
+ 'fetch.baseDelay': { type: 'number' },
41
+ 'fetch.maxDelay': { type: 'number' },
39
42
  // Analyzer
40
43
  'analyzer.inactivityThreshold': { type: 'number' },
41
44
  'analyzer.chunkMaxTopics': { type: 'number' },
@@ -132,8 +132,6 @@ async function runFetch(username, options) {
132
132
  meta: {
133
133
  failedTopics,
134
134
  failedPages,
135
- // TODO(ia319): 在实现 --retry 后补充真实失败页索引 [2026-02-14]
136
- failedPageIndices: [],
137
135
  },
138
136
  };
139
137
  }
package/dist/cli/index.js CHANGED
@@ -48,8 +48,12 @@ commander_1.program
48
48
  .option('--topics', 'Fetch topics only')
49
49
  .option('--replies', 'Fetch replies only')
50
50
  .option('--force', 'Force refetch even if cache exists')
51
- .action(async (username, options) => {
52
- const result = await (0, commands_1.runFetch)(username, options);
51
+ .option('-v, --verbose', 'Show debug output')
52
+ .action(async (username, _, command) => {
53
+ const opts = command.optsWithGlobals();
54
+ if (opts.verbose)
55
+ logger_1.logger.setLevel('debug');
56
+ const result = await (0, commands_1.runFetch)(username, opts);
53
57
  if (result.status === 'failed')
54
58
  process.exitCode = 1;
55
59
  });
@@ -58,7 +62,11 @@ commander_1.program
58
62
  .command('analyze')
59
63
  .description('Process raw data and generate statistics')
60
64
  .argument('<username>', 'V2EX username')
61
- .action(async (username) => {
65
+ .option('-v, --verbose', 'Show debug output')
66
+ .action(async (username, _, command) => {
67
+ const opts = command.optsWithGlobals();
68
+ if (opts.verbose)
69
+ logger_1.logger.setLevel('debug');
62
70
  const result = await (0, commands_1.runAnalyze)(username);
63
71
  if (result.status === 'failed')
64
72
  process.exitCode = 1;
@@ -70,8 +78,12 @@ commander_1.program
70
78
  .argument('<username>', 'V2EX username')
71
79
  .option('--model [name]', 'Specify Gemini model (or select interactively)')
72
80
  .option('--thinking-level [level]', 'Specify thinking level: minimal | low | medium | high')
73
- .action(async (username, options) => {
74
- const result = await (0, commands_1.runAi)(username, options);
81
+ .option('-v, --verbose', 'Show debug output')
82
+ .action(async (username, _, command) => {
83
+ const opts = command.optsWithGlobals();
84
+ if (opts.verbose)
85
+ logger_1.logger.setLevel('debug');
86
+ const result = await (0, commands_1.runAi)(username, opts);
75
87
  if (result.status === 'failed')
76
88
  process.exitCode = 1;
77
89
  });
@@ -82,8 +94,12 @@ commander_1.program
82
94
  .argument('<username>', 'V2EX username')
83
95
  .option('--json', 'Output raw JSON')
84
96
  .option('--brief', 'Show brief summary only')
85
- .action(async (username, options) => {
86
- const result = await (0, commands_1.runShow)(username, options);
97
+ .option('-v, --verbose', 'Show debug output')
98
+ .action(async (username, _options, command) => {
99
+ const opts = command.optsWithGlobals();
100
+ if (opts.verbose)
101
+ logger_1.logger.setLevel('debug');
102
+ const result = await (0, commands_1.runShow)(username, opts);
87
103
  if (result.status === 'failed')
88
104
  process.exitCode = 1;
89
105
  });
package/dist/cli/utils.js CHANGED
@@ -43,6 +43,11 @@ function createFetchEvents(label) {
43
43
  onError: (result) => {
44
44
  logFetchError(result);
45
45
  },
46
+ onRetry: (url, attempt, maxRetries, delay, reason) => {
47
+ const delaySec = (delay / 1000).toFixed(1);
48
+ logger_1.logger.warn(` 重试 (${attempt}/${maxRetries}) [${delaySec}s 后]: ${url}`);
49
+ logger_1.logger.debug(` 原因: ${reason}`);
50
+ },
46
51
  };
47
52
  }
48
53
  //# sourceMappingURL=utils.js.map
@@ -21,7 +21,7 @@ export type ResolvedConfig = Required<{
21
21
  export declare const DEFAULT_CONFIG: {
22
22
  readonly ai: {
23
23
  readonly provider: "gemini";
24
- readonly model: "gemini-3-pro-preview";
24
+ readonly model: "gemini-3.1-pro-preview";
25
25
  readonly thinkingLevel: "high";
26
26
  readonly timeout: 60000;
27
27
  readonly maxRetries: 3;
@@ -30,6 +30,9 @@ export declare const DEFAULT_CONFIG: {
30
30
  };
31
31
  readonly fetch: {
32
32
  readonly timeout: 30000;
33
+ readonly maxRetries: 3;
34
+ readonly baseDelay: 1000;
35
+ readonly maxDelay: 8000;
33
36
  };
34
37
  readonly analyzer: {
35
38
  readonly inactivityThreshold: 60;
@@ -15,7 +15,7 @@ exports.DEFAULT_CONFIG = void 0;
15
15
  exports.DEFAULT_CONFIG = {
16
16
  ai: {
17
17
  provider: 'gemini',
18
- model: 'gemini-3-pro-preview',
18
+ model: 'gemini-3.1-pro-preview',
19
19
  thinkingLevel: 'high',
20
20
  timeout: 60000,
21
21
  maxRetries: 3,
@@ -24,6 +24,9 @@ exports.DEFAULT_CONFIG = {
24
24
  },
25
25
  fetch: {
26
26
  timeout: 30000,
27
+ maxRetries: 3,
28
+ baseDelay: 1000,
29
+ maxDelay: 8000,
27
30
  },
28
31
  analyzer: {
29
32
  inactivityThreshold: 60,
@@ -5,5 +5,11 @@
5
5
  export interface FetchConfig {
6
6
  /** HTTP 请求超时(毫秒) */
7
7
  timeout?: number;
8
+ /** HTTP 请求最大重试次数(0 = 不重试) */
9
+ maxRetries?: number;
10
+ /** 重试基础延迟(毫秒) */
11
+ baseDelay?: number;
12
+ /** 重试最大延迟上限(毫秒) */
13
+ maxDelay?: number;
8
14
  }
9
15
  //# sourceMappingURL=fetch.d.ts.map
@@ -1,15 +1,8 @@
1
1
  /**
2
- * 重试工具 - 指数退避
3
- */
4
- export interface RetryOptions {
5
- maxRetries?: number;
6
- baseDelay?: number;
7
- maxDelay?: number;
8
- }
9
- /**
10
- * 带重试逻辑执行函数
2
+ * 重试工具 — 向后兼容 re-export
11
3
  *
12
- * 使用指数退避 + 随机抖动策略
4
+ * 实现已迁移至 infra/retry,此文件保留以维持 core/ai 的公开 API 不变。
13
5
  */
14
- export declare function withRetry<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T>;
6
+ export { withRetry } from '../../../infra/retry';
7
+ export type { RetryOptions } from '../../../infra/retry';
15
8
  //# sourceMappingURL=retry.d.ts.map
@@ -1,37 +1,11 @@
1
1
  "use strict";
2
2
  /**
3
- * 重试工具 - 指数退避
4
- */
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.withRetry = withRetry;
7
- const config_1 = require("../../../config");
8
- /**
9
- * 带重试逻辑执行函数
3
+ * 重试工具 — 向后兼容 re-export
10
4
  *
11
- * 使用指数退避 + 随机抖动策略
5
+ * 实现已迁移至 infra/retry,此文件保留以维持 core/ai 的公开 API 不变。
12
6
  */
13
- async function withRetry(fn, options = {}) {
14
- const aiConfig = (0, config_1.getConfig)().ai;
15
- const { maxRetries = aiConfig?.maxRetries ?? 3, baseDelay = aiConfig?.baseDelay ?? 1000, maxDelay = aiConfig?.maxDelay ?? 10000, } = options;
16
- const safeMaxRetries = Math.max(0, maxRetries);
17
- let lastError;
18
- for (let attempt = 0; attempt <= safeMaxRetries; attempt++) {
19
- try {
20
- return await fn();
21
- }
22
- catch (error) {
23
- lastError = error instanceof Error ? error : new Error(String(error));
24
- if (attempt === safeMaxRetries) {
25
- break;
26
- }
27
- const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
28
- const jitter = delay * 0.1 * Math.random();
29
- await sleep(delay + jitter);
30
- }
31
- }
32
- throw lastError;
33
- }
34
- function sleep(ms) {
35
- return new Promise((resolve) => setTimeout(resolve, ms));
36
- }
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.withRetry = void 0;
9
+ var retry_1 = require("../../../infra/retry");
10
+ Object.defineProperty(exports, "withRetry", { enumerable: true, get: function () { return retry_1.withRetry; } });
37
11
  //# sourceMappingURL=retry.js.map
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * 用户发帖详情获取服务
3
- * 获取用户所有发帖的完整内容
3
+ * 获取用户所有发帖的完整内容,支持失败帖二次重试
4
4
  */
5
5
  import type { TopicDetailParseResult } from '../../types';
6
6
  import type { ServiceOptions } from '../types';
@@ -22,6 +22,9 @@ export interface UserTopicsDetailResult {
22
22
  /**
23
23
  * 获取用户所有发帖的完整详情
24
24
  *
25
+ * 第一轮批量抓取所有帖子,收集失败项(HTTP 失败或解析失败);
26
+ * 第一轮结束后,若存在失败项,发起第二轮重试。
27
+ *
25
28
  * @param username - 用户名
26
29
  * @param options - 服务配置选项
27
30
  * @returns 包含所有帖子详情的结果
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  /**
3
3
  * 用户发帖详情获取服务
4
- * 获取用户所有发帖的完整内容
4
+ * 获取用户所有发帖的完整内容,支持失败帖二次重试
5
5
  */
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  exports.getAllUserTopicsDetail = getAllUserTopicsDetail;
@@ -11,6 +11,9 @@ const topic_urls_1 = require("./topic-urls");
11
11
  /**
12
12
  * 获取用户所有发帖的完整详情
13
13
  *
14
+ * 第一轮批量抓取所有帖子,收集失败项(HTTP 失败或解析失败);
15
+ * 第一轮结束后,若存在失败项,发起第二轮重试。
16
+ *
14
17
  * @param username - 用户名
15
18
  * @param options - 服务配置选项
16
19
  * @returns 包含所有帖子详情的结果
@@ -34,8 +37,8 @@ async function getAllUserTopicsDetail(username, options) {
34
37
  };
35
38
  const topics = [];
36
39
  let fetchedTopics = 0;
37
- let failedTopics = 0;
38
- // 批量抓取并解析帖子详情
40
+ const failedUrls = [];
41
+ // 第一轮:批量抓取并解析帖子详情
39
42
  for await (const result of fetcher.fetch(urlsResult.data, fetchOptions, options?.events)) {
40
43
  if (result.success && result.content) {
41
44
  try {
@@ -44,18 +47,45 @@ async function getAllUserTopicsDetail(username, options) {
44
47
  fetchedTopics++;
45
48
  }
46
49
  catch {
47
- failedTopics++;
50
+ // 解析失败,记录 URL 用于二次重试
51
+ failedUrls.push(result.url);
48
52
  }
49
53
  }
50
54
  else {
51
- failedTopics++;
55
+ failedUrls.push(result.url);
56
+ }
57
+ }
58
+ // 第二轮:对失败帖发起重试
59
+ if (failedUrls.length > 0) {
60
+ const recoveredUrls = new Set();
61
+ for await (const result of fetcher.fetch(failedUrls, fetchOptions, options?.events)) {
62
+ if (result.success && result.content) {
63
+ try {
64
+ const detail = (0, parsers_1.parseTopicDetail)(result.content);
65
+ topics.push(detail);
66
+ fetchedTopics++;
67
+ recoveredUrls.add(result.url);
68
+ }
69
+ catch {
70
+ // 二次重试解析仍失败
71
+ }
72
+ }
52
73
  }
74
+ // 最终失败数 = 原失败列表 - 已恢复
75
+ const stillFailed = failedUrls.filter((url) => !recoveredUrls.has(url));
76
+ return {
77
+ topics,
78
+ totalTopics: urlsResult.data.length,
79
+ fetchedTopics,
80
+ failedTopics: stillFailed.length,
81
+ isHidden: false,
82
+ };
53
83
  }
54
84
  return {
55
85
  topics,
56
86
  totalTopics: urlsResult.data.length,
57
87
  fetchedTopics,
58
- failedTopics,
88
+ failedTopics: failedUrls.length,
59
89
  isHidden: false,
60
90
  };
61
91
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * 分页数据编排器
3
- * 提供通用的多页数据获取逻辑
3
+ * 提供通用的多页数据获取逻辑,支持失败页二次重试
4
4
  */
5
5
  import type { PagedResult, ServiceOptions } from '../types';
6
6
  /**
@@ -14,6 +14,10 @@ export interface PaginatedParseResult {
14
14
  /**
15
15
  * 获取分页数据的通用函数
16
16
  *
17
+ * 第一轮遍历所有页面,收集失败项;
18
+ * 第一轮结束后,若存在失败页,发起第二轮重试。
19
+ * 最终返回的 failedPages 为二次重试后仍失败的数量。
20
+ *
17
21
  * @param urlGenerator - 根据页码生成 URL 的函数
18
22
  * @param parser - 解析 HTML 并返回包含分页信息的结果
19
23
  * @param extractor - 从解析结果中提取数据列表的函数
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  /**
3
3
  * 分页数据编排器
4
- * 提供通用的多页数据获取逻辑
4
+ * 提供通用的多页数据获取逻辑,支持失败页二次重试
5
5
  */
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  exports.fetchPagedData = fetchPagedData;
@@ -9,6 +9,10 @@ const fetcher_1 = require("../../../../infra/fetcher");
9
9
  /**
10
10
  * 获取分页数据的通用函数
11
11
  *
12
+ * 第一轮遍历所有页面,收集失败项;
13
+ * 第一轮结束后,若存在失败页,发起第二轮重试。
14
+ * 最终返回的 failedPages 为二次重试后仍失败的数量。
15
+ *
12
16
  * @param urlGenerator - 根据页码生成 URL 的函数
13
17
  * @param parser - 解析 HTML 并返回包含分页信息的结果
14
18
  * @param extractor - 从解析结果中提取数据列表的函数
@@ -24,7 +28,6 @@ async function fetchPagedData(urlGenerator, parser, extractor, options) {
24
28
  const allData = [];
25
29
  let totalPages = 1;
26
30
  let fetchedPages = 0;
27
- let failedPages = 0;
28
31
  // 抓取第一页,获取分页信息
29
32
  // total 参数使用 -1 表示尚未确定总页数
30
33
  const firstPageUrl = urlGenerator(1);
@@ -56,14 +59,15 @@ async function fetchPagedData(urlGenerator, parser, extractor, options) {
56
59
  }
57
60
  // 单页时直接返回
58
61
  if (totalPages <= 1) {
59
- return { data: allData, totalPages, fetchedPages, failedPages };
62
+ return { data: allData, totalPages, fetchedPages, failedPages: 0 };
60
63
  }
61
- // 生成剩余页 URL 并批量抓取
64
+ // 生成剩余页 URL 并批量抓取(第一轮)
62
65
  const remainingUrls = [];
63
66
  for (let page = 2; page <= totalPages; page++) {
64
67
  remainingUrls.push(urlGenerator(page));
65
68
  }
66
- let pageIndex = 1; // 从第2页开始,index=1
69
+ const failedItems = [];
70
+ let pageIndex = 1; // 从第 2 页开始,index = 1
67
71
  for await (const result of fetcher.fetch(remainingUrls, fetchOptions, options?.events)) {
68
72
  if (result.success && result.content) {
69
73
  try {
@@ -72,7 +76,7 @@ async function fetchPagedData(urlGenerator, parser, extractor, options) {
72
76
  fetchedPages++;
73
77
  }
74
78
  catch (error) {
75
- // 单页解析失败,通知错误并继续
79
+ // 单页解析失败,记录并继续
76
80
  options?.events?.onError?.({
77
81
  url: result.url,
78
82
  success: false,
@@ -80,14 +84,42 @@ async function fetchPagedData(urlGenerator, parser, extractor, options) {
80
84
  error: error instanceof Error ? error : new Error(String(error)),
81
85
  statusCode: 0,
82
86
  }, pageIndex, totalPages);
83
- failedPages++;
87
+ failedItems.push({ url: result.url, pageIndex });
84
88
  }
85
89
  }
86
90
  else {
87
- failedPages++;
91
+ failedItems.push({ url: result.url, pageIndex });
88
92
  }
89
93
  pageIndex++;
90
94
  }
91
- return { data: allData, totalPages, fetchedPages, failedPages };
95
+ // 第二轮:对失败页发起重试
96
+ if (failedItems.length > 0) {
97
+ const retryUrls = failedItems.map((item) => item.url);
98
+ const recoveredUrls = new Set();
99
+ for await (const result of fetcher.fetch(retryUrls, fetchOptions, options?.events)) {
100
+ if (result.success && result.content) {
101
+ try {
102
+ const parsed = parser(result.content);
103
+ allData.push(...extractor(parsed));
104
+ fetchedPages++;
105
+ recoveredUrls.add(result.url);
106
+ }
107
+ catch (error) {
108
+ // 二次重试解析仍失败,通知调用方
109
+ const item = failedItems.find((f) => f.url === result.url);
110
+ options?.events?.onError?.({
111
+ url: result.url,
112
+ success: false,
113
+ content: null,
114
+ error: error instanceof Error ? error : new Error(String(error)),
115
+ statusCode: 0,
116
+ }, item?.pageIndex ?? -1, totalPages);
117
+ }
118
+ }
119
+ }
120
+ const stillFailed = failedItems.filter((item) => !recoveredUrls.has(item.url));
121
+ return { data: allData, totalPages, fetchedPages, failedPages: stillFailed.length };
122
+ }
123
+ return { data: allData, totalPages, fetchedPages, failedPages: 0 };
92
124
  }
93
125
  //# sourceMappingURL=page-orchestrator.js.map
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Fetcher — HTTP 请求执行器
3
+ *
4
+ * 支持传输级自动重试:对网络错误、5xx、429 使用指数退避重试。
5
+ * 4xx 等客户端错误不重试,直接返回失败结果。
6
+ */
1
7
  import type { IFetchStrategy, FetchResult, FetchOptions, FetchEvents } from './types';
2
8
  export declare class SequentialStrategy implements IFetchStrategy {
3
9
  fetch(urls: string[], options?: FetchOptions, events?: FetchEvents): AsyncGenerator<FetchResult>;
@@ -1,4 +1,10 @@
1
1
  "use strict";
2
+ /**
3
+ * Fetcher — HTTP 请求执行器
4
+ *
5
+ * 支持传输级自动重试:对网络错误、5xx、429 使用指数退避重试。
6
+ * 4xx 等客户端错误不重试,直接返回失败结果。
7
+ */
2
8
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
9
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
10
  };
@@ -7,6 +13,8 @@ exports.Fetcher = exports.SequentialStrategy = void 0;
7
13
  const axios_1 = __importDefault(require("axios"));
8
14
  const agent_1 = require("./agent");
9
15
  const config_1 = require("../../config");
16
+ const defaults_1 = require("../../config/defaults");
17
+ const retryable_1 = require("./retryable");
10
18
  /**
11
19
  * 将响应数据转换为字符串
12
20
  */
@@ -21,49 +29,101 @@ function responseToString(data) {
21
29
  return String(data);
22
30
  }
23
31
  }
32
+ /**
33
+ * 计算指数退避延迟 + 随机抖动
34
+ */
35
+ function getRetryDelay(attempt, baseDelay, maxDelay) {
36
+ const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
37
+ const jitter = delay * 0.1 * Math.random();
38
+ return delay + jitter;
39
+ }
40
+ function sleep(ms) {
41
+ return new Promise((resolve) => setTimeout(resolve, ms));
42
+ }
43
+ /**
44
+ * 将 axios 响应转换为 FetchResult
45
+ */
46
+ function toFetchResult(url, response) {
47
+ const isSuccess = response.status >= 200 && response.status < 300;
48
+ const responseBody = responseToString(response.data);
49
+ return {
50
+ url,
51
+ content: isSuccess ? responseBody : null,
52
+ success: isSuccess,
53
+ statusCode: response.status,
54
+ ...(isSuccess ? {} : { errorBody: responseBody }),
55
+ };
56
+ }
24
57
  class SequentialStrategy {
25
58
  async *fetch(urls, options, events) {
26
59
  const total = urls.length;
27
60
  const httpsAgent = (0, agent_1.getHttpsAgent)();
61
+ const fetchConfig = (0, config_1.getConfig)().fetch;
62
+ const maxRetries = Math.max(0, options?.maxRetries ?? fetchConfig?.maxRetries ?? defaults_1.DEFAULT_CONFIG.fetch.maxRetries);
63
+ const baseDelay = options?.baseDelay ?? fetchConfig?.baseDelay ?? defaults_1.DEFAULT_CONFIG.fetch.baseDelay;
64
+ const maxDelay = options?.maxDelay ?? fetchConfig?.maxDelay ?? defaults_1.DEFAULT_CONFIG.fetch.maxDelay;
28
65
  for (let i = 0; i < total; i++) {
29
66
  const url = urls[i];
30
- // 触发开始事件
31
67
  events?.onStart?.(url, i, total);
32
- try {
33
- const response = await axios_1.default.get(url, {
34
- timeout: options?.timeout ?? (0, config_1.getConfig)().fetch?.timeout ?? 30000,
35
- ...(options?.headers && { headers: options.headers }),
36
- ...(httpsAgent && { httpsAgent }),
37
- proxy: false,
38
- validateStatus: () => true,
39
- });
40
- const isSuccess = response.status >= 200 && response.status < 300;
41
- const responseBody = responseToString(response.data);
42
- const result = {
43
- url,
44
- content: isSuccess ? responseBody : null,
45
- success: isSuccess,
46
- statusCode: response.status,
47
- ...(isSuccess ? {} : { errorBody: responseBody }),
48
- };
49
- // 触发成功/失败事件
50
- if (result.success) {
51
- events?.onSuccess?.(result, i, total);
68
+ let lastResult = null;
69
+ let yielded = false;
70
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
71
+ try {
72
+ const response = await axios_1.default.get(url, {
73
+ timeout: options?.timeout ?? fetchConfig?.timeout ?? defaults_1.DEFAULT_CONFIG.fetch.timeout,
74
+ ...(options?.headers && { headers: options.headers }),
75
+ ...(httpsAgent && { httpsAgent }),
76
+ proxy: false,
77
+ validateStatus: () => true,
78
+ });
79
+ const result = toFetchResult(url, response);
80
+ // 成功 不可重试的错误 直接返回
81
+ if (result.success || !(0, retryable_1.isRetryable)(result)) {
82
+ if (result.success) {
83
+ events?.onSuccess?.(result, i, total);
84
+ }
85
+ else {
86
+ events?.onError?.(result, i, total);
87
+ }
88
+ yield result;
89
+ yielded = true;
90
+ break;
91
+ }
92
+ // 可重试的失败,记录并准备重试
93
+ lastResult = result;
94
+ if (attempt < maxRetries) {
95
+ // 429 时优先使用 Retry-After 值
96
+ const retryAfterSeconds = result.statusCode === 429
97
+ ? (0, retryable_1.parseRetryAfter)(response.headers)
98
+ : null;
99
+ const delay = retryAfterSeconds !== null
100
+ ? Math.min(retryAfterSeconds * 1000, maxDelay)
101
+ : getRetryDelay(attempt, baseDelay, maxDelay);
102
+ const reason = result.statusCode ? `HTTP ${result.statusCode}` : 'unknown';
103
+ events?.onRetry?.(url, attempt + 1, maxRetries, delay, reason);
104
+ await sleep(delay);
105
+ }
52
106
  }
53
- else {
54
- events?.onError?.(result, i, total);
107
+ catch (error) {
108
+ // 网络错误(超时、连接重置等)→ 可重试
109
+ lastResult = {
110
+ url,
111
+ content: null,
112
+ success: false,
113
+ error: error,
114
+ };
115
+ if (attempt < maxRetries) {
116
+ const delay = getRetryDelay(attempt, baseDelay, maxDelay);
117
+ const reason = error.message ?? 'network error';
118
+ events?.onRetry?.(url, attempt + 1, maxRetries, delay, reason);
119
+ await sleep(delay);
120
+ }
55
121
  }
56
- yield result;
57
122
  }
58
- catch (error) {
59
- const result = {
60
- url,
61
- content: null,
62
- success: false,
63
- error: error,
64
- };
65
- events?.onError?.(result, i, total);
66
- yield result;
123
+ // 所有重试耗尽,yield 最后一次失败结果
124
+ if (!yielded && lastResult) {
125
+ events?.onError?.(lastResult, i, total);
126
+ yield lastResult;
67
127
  }
68
128
  }
69
129
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * HTTP 请求重试判定
3
+ *
4
+ * 根据响应状态码判断是否值得重试。
5
+ * 仅网络错误和服务端瞬态故障可重试,客户端错误(4xx)不重试。
6
+ */
7
+ import type { FetchResult } from './types';
8
+ /**
9
+ * 判断请求结果是否可重试
10
+ *
11
+ * - 无状态码(网络错误、超时等)→ 可重试
12
+ * - 429 Too Many Requests → 可重试
13
+ * - 5xx 服务端错误 → 可重试
14
+ * - 其余(含 4xx 客户端错误)→ 不可重试
15
+ */
16
+ export declare function isRetryable(result: FetchResult): boolean;
17
+ /**
18
+ * 从响应头解析 Retry-After 值(秒)
19
+ *
20
+ * @returns 延迟秒数,无法解析时返回 null
21
+ */
22
+ export declare function parseRetryAfter(headers?: Record<string, string>): number | null;
23
+ //# sourceMappingURL=retryable.d.ts.map
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ /**
3
+ * HTTP 请求重试判定
4
+ *
5
+ * 根据响应状态码判断是否值得重试。
6
+ * 仅网络错误和服务端瞬态故障可重试,客户端错误(4xx)不重试。
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.isRetryable = isRetryable;
10
+ exports.parseRetryAfter = parseRetryAfter;
11
+ /**
12
+ * 判断请求结果是否可重试
13
+ *
14
+ * - 无状态码(网络错误、超时等)→ 可重试
15
+ * - 429 Too Many Requests → 可重试
16
+ * - 5xx 服务端错误 → 可重试
17
+ * - 其余(含 4xx 客户端错误)→ 不可重试
18
+ */
19
+ function isRetryable(result) {
20
+ if (!result.statusCode)
21
+ return true;
22
+ if (result.statusCode === 429)
23
+ return true;
24
+ if (result.statusCode >= 500)
25
+ return true;
26
+ return false;
27
+ }
28
+ /**
29
+ * 从响应头解析 Retry-After 值(秒)
30
+ *
31
+ * @returns 延迟秒数,无法解析时返回 null
32
+ */
33
+ function parseRetryAfter(headers) {
34
+ if (!headers)
35
+ return null;
36
+ // header 名称不区分大小写
37
+ const value = headers['retry-after'] ?? headers['Retry-After'];
38
+ if (!value)
39
+ return null;
40
+ const seconds = Number(value);
41
+ if (!Number.isNaN(seconds) && seconds >= 0) {
42
+ return seconds;
43
+ }
44
+ return null;
45
+ }
46
+ //# sourceMappingURL=retryable.js.map
@@ -5,6 +5,12 @@
5
5
  export interface FetchOptions {
6
6
  headers?: Record<string, string>;
7
7
  timeout?: number;
8
+ /** 最大重试次数 */
9
+ maxRetries?: number;
10
+ /** 重试基础延迟(毫秒) */
11
+ baseDelay?: number;
12
+ /** 重试最大延迟上限(毫秒) */
13
+ maxDelay?: number;
8
14
  }
9
15
  /** 抓取结果 */
10
16
  export interface FetchResult {
@@ -21,6 +27,8 @@ export interface FetchEvents {
21
27
  onStart?: (url: string, index: number, total: number) => void;
22
28
  onSuccess?: (result: FetchResult, index: number, total: number) => void;
23
29
  onError?: (result: FetchResult, index: number, total: number) => void;
30
+ /** 重试时触发 */
31
+ onRetry?: (url: string, attempt: number, maxRetries: number, delay: number, reason: string) => void;
24
32
  }
25
33
  /** 抓取策略接口 */
26
34
  export interface IFetchStrategy {
@@ -0,0 +1,6 @@
1
+ /**
2
+ * 重试模块导出
3
+ */
4
+ export { withRetry } from './retry';
5
+ export type { RetryOptions } from './types';
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ /**
3
+ * 重试模块导出
4
+ */
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.withRetry = void 0;
7
+ var retry_1 = require("./retry");
8
+ Object.defineProperty(exports, "withRetry", { enumerable: true, get: function () { return retry_1.withRetry; } });
9
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,15 @@
1
+ /**
2
+ * 通用重试工具 — 指数退避 + 随机抖动
3
+ *
4
+ * 纯参数驱动,不依赖任何 config 模块。
5
+ * 调用方(AI / Fetcher)负责从 config 读取默认值并传入。
6
+ */
7
+ import type { RetryOptions } from './types';
8
+ /**
9
+ * 带重试逻辑执行异步函数
10
+ *
11
+ * 使用指数退避(2^attempt * baseDelay)+ 10% 随机抖动策略。
12
+ * 当所有重试耗尽后,抛出最后一次捕获的错误。
13
+ */
14
+ export declare function withRetry<T>(fn: () => Promise<T>, options: RetryOptions): Promise<T>;
15
+ //# sourceMappingURL=retry.d.ts.map
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ /**
3
+ * 通用重试工具 — 指数退避 + 随机抖动
4
+ *
5
+ * 纯参数驱动,不依赖任何 config 模块。
6
+ * 调用方(AI / Fetcher)负责从 config 读取默认值并传入。
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.withRetry = withRetry;
10
+ /**
11
+ * 带重试逻辑执行异步函数
12
+ *
13
+ * 使用指数退避(2^attempt * baseDelay)+ 10% 随机抖动策略。
14
+ * 当所有重试耗尽后,抛出最后一次捕获的错误。
15
+ */
16
+ async function withRetry(fn, options) {
17
+ const { maxRetries, baseDelay, maxDelay, onRetry } = options;
18
+ const safeMaxRetries = Math.max(0, maxRetries);
19
+ let lastError;
20
+ for (let attempt = 0; attempt <= safeMaxRetries; attempt++) {
21
+ try {
22
+ return await fn();
23
+ }
24
+ catch (error) {
25
+ lastError = error instanceof Error ? error : new Error(String(error));
26
+ if (attempt === safeMaxRetries) {
27
+ break;
28
+ }
29
+ const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
30
+ const jitter = delay * 0.1 * Math.random();
31
+ const actualDelay = delay + jitter;
32
+ onRetry?.(attempt + 1, safeMaxRetries, lastError, actualDelay);
33
+ await sleep(actualDelay);
34
+ }
35
+ }
36
+ throw lastError;
37
+ }
38
+ function sleep(ms) {
39
+ return new Promise((resolve) => setTimeout(resolve, ms));
40
+ }
41
+ //# sourceMappingURL=retry.js.map
@@ -0,0 +1,15 @@
1
+ /**
2
+ * 重试模块类型定义
3
+ */
4
+ /** 重试配置选项(所有字段必填,由调用方从 config 传入) */
5
+ export interface RetryOptions {
6
+ /** 最大重试次数(0 = 不重试) */
7
+ maxRetries: number;
8
+ /** 首次重试基础延迟(毫秒) */
9
+ baseDelay: number;
10
+ /** 重试最大延迟上限(毫秒) */
11
+ maxDelay: number;
12
+ /** 重试时回调,用于日志输出(可选) */
13
+ onRetry?: (attempt: number, maxRetries: number, error: Error, delay: number) => void;
14
+ }
15
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ /**
3
+ * 重试模块类型定义
4
+ */
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ //# sourceMappingURL=types.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "v2er-insight",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "",
5
5
  "main": "./dist/cli/index.js",
6
6
  "bin": {