recker 1.0.79 → 1.0.80-next.70ea84d

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 (196) hide show
  1. package/dist/browser/browser/mini.d.ts +2 -2
  2. package/dist/browser/browser/recker-mini.d.ts +8 -8
  3. package/dist/browser/browser/recker.d.ts +11 -8
  4. package/dist/browser/browser/recker.js +54 -6
  5. package/dist/browser/core/client.d.ts +15 -10
  6. package/dist/browser/core/client.js +54 -38
  7. package/dist/browser/index.iife.min.js +129 -130
  8. package/dist/browser/index.min.js +129 -130
  9. package/dist/browser/index.mini.iife.js +5697 -636
  10. package/dist/browser/index.mini.iife.min.js +47 -48
  11. package/dist/browser/index.mini.min.js +47 -48
  12. package/dist/browser/index.mini.umd.js +5697 -636
  13. package/dist/browser/index.mini.umd.min.js +47 -48
  14. package/dist/browser/index.umd.min.js +129 -130
  15. package/dist/browser/mini.d.ts +2 -2
  16. package/dist/browser/plugins/proxy-rotator.d.ts +2 -2
  17. package/dist/browser/plugins/proxy-rotator.js +6 -28
  18. package/dist/browser/recker-mini.d.ts +8 -8
  19. package/dist/browser/recker.d.ts +11 -8
  20. package/dist/browser/recker.js +54 -6
  21. package/dist/browser/scrape/document.js +2 -2
  22. package/dist/browser/scrape/element.js +7 -1
  23. package/dist/browser/scrape/parser/nodes/html.js +1 -1
  24. package/dist/browser/scrape/spider.d.ts +52 -0
  25. package/dist/browser/scrape/spider.js +620 -38
  26. package/dist/browser/scrape/types.d.ts +2 -0
  27. package/dist/browser/search/google.d.ts +26 -1
  28. package/dist/browser/search/google.js +427 -45
  29. package/dist/browser/seo/analyzer.d.ts +1 -0
  30. package/dist/browser/seo/analyzer.js +144 -1
  31. package/dist/browser/seo/index.d.ts +1 -1
  32. package/dist/browser/seo/keyword-campaign-analyzer.d.ts +2 -0
  33. package/dist/browser/seo/keyword-campaign-analyzer.js +538 -0
  34. package/dist/browser/seo/keyword-campaign-seed-advanced.d.ts +17 -0
  35. package/dist/browser/seo/keyword-campaign-seed-advanced.js +269 -0
  36. package/dist/browser/seo/keyword-campaign-seed-core.d.ts +29 -0
  37. package/dist/browser/seo/keyword-campaign-seed-core.js +525 -0
  38. package/dist/browser/seo/keyword-campaign-shared.d.ts +165 -0
  39. package/dist/browser/seo/keyword-campaign-shared.js +59 -0
  40. package/dist/browser/seo/keyword-campaign.d.ts +4 -107
  41. package/dist/browser/seo/keyword-campaign.js +2 -380
  42. package/dist/browser/seo/keywords.js +5 -22
  43. package/dist/browser/seo/types.d.ts +19 -0
  44. package/dist/browser/transport/curl.d.ts +5 -1
  45. package/dist/browser/transport/curl.js +207 -50
  46. package/dist/browser/transport/undici.d.ts +4 -3
  47. package/dist/browser/transport/undici.js +123 -49
  48. package/dist/browser/types/index.d.ts +9 -3
  49. package/dist/browser/utils/binary-manager.js +26 -3
  50. package/dist/browser/utils/block-detector.d.ts +8 -0
  51. package/dist/browser/utils/block-detector.js +542 -7
  52. package/dist/cli/commands/hls-runner.js +5 -4
  53. package/dist/cli/commands/live-runner.js +5 -4
  54. package/dist/cli/commands/loadtest-runner.js +3 -2
  55. package/dist/cli/commands/search.d.ts +2 -0
  56. package/dist/cli/commands/search.js +105 -0
  57. package/dist/cli/commands/seo-runner.js +9 -7
  58. package/dist/cli/commands/seo.js +140 -1
  59. package/dist/cli/commands/serve.js +75 -131
  60. package/dist/cli/commands/server-runner.js +59 -82
  61. package/dist/cli/commands/spider-runner.d.ts +37 -1
  62. package/dist/cli/commands/spider-runner.js +134 -10
  63. package/dist/cli/commands/spider.d.ts +18 -1
  64. package/dist/cli/commands/spider.js +457 -27
  65. package/dist/cli/events/handlers/cli.js +30 -1
  66. package/dist/cli/events/handlers/tui.js +26 -0
  67. package/dist/cli/events/types.d.ts +27 -0
  68. package/dist/cli/handler.d.ts +2 -12
  69. package/dist/cli/handler.js +20 -15
  70. package/dist/cli/handlers/protocols.js +39 -12
  71. package/dist/cli/handlers/search.d.ts +2 -0
  72. package/dist/cli/handlers/search.js +171 -0
  73. package/dist/cli/handlers/seo-analyze.d.ts +1 -0
  74. package/dist/cli/handlers/seo-analyze.js +666 -0
  75. package/dist/cli/handlers/seo-robots.d.ts +1 -0
  76. package/dist/cli/handlers/seo-robots.js +76 -0
  77. package/dist/cli/handlers/seo-serp.d.ts +54 -0
  78. package/dist/cli/handlers/seo-serp.js +243 -0
  79. package/dist/cli/handlers/seo-sitemap.d.ts +1 -0
  80. package/dist/cli/handlers/seo-sitemap.js +55 -0
  81. package/dist/cli/handlers/seo-spider.d.ts +1 -0
  82. package/dist/cli/handlers/seo-spider.js +334 -0
  83. package/dist/cli/handlers/seo.d.ts +8 -4
  84. package/dist/cli/handlers/seo.js +294 -442
  85. package/dist/cli/handlers/spider.js +94 -17
  86. package/dist/cli/handlers/streaming.js +5 -1
  87. package/dist/cli/index.js +11 -2
  88. package/dist/cli/presets.d.ts +1 -1
  89. package/dist/cli/presets.js +15 -4
  90. package/dist/cli/tui/app.js +6 -1
  91. package/dist/cli/tui/components/rich-response.d.ts +72 -0
  92. package/dist/cli/tui/components/rich-response.js +117 -0
  93. package/dist/cli/tui/executor-commands/background.js +30 -24
  94. package/dist/cli/tui/executor-commands/testing.js +3 -2
  95. package/dist/cli/tui/hooks/useDomains.d.ts +17 -0
  96. package/dist/cli/tui/hooks/useHelp.js +15 -2
  97. package/dist/cli/tui/job-manager.d.ts +4 -4
  98. package/dist/cli/tui/job-manager.js +5 -1
  99. package/dist/cli/tui/spider-tui.d.ts +63 -0
  100. package/dist/cli/tui/spider-tui.js +120 -2
  101. package/dist/cli/types.d.ts +12 -0
  102. package/dist/cli/types.js +1 -0
  103. package/dist/cli/utils/option-helpers.d.ts +10 -0
  104. package/dist/cli/utils/option-helpers.js +63 -0
  105. package/dist/cli/utils/score-color.d.ts +11 -0
  106. package/dist/cli/utils/score-color.js +11 -0
  107. package/dist/cli/utils/serp-campaign.d.ts +53 -0
  108. package/dist/cli/utils/serp-campaign.js +53 -0
  109. package/dist/cli/utils/serp-config.d.ts +26 -0
  110. package/dist/cli/utils/serp-config.js +125 -0
  111. package/dist/core/client.d.ts +15 -10
  112. package/dist/core/client.js +54 -38
  113. package/dist/index.d.ts +1 -0
  114. package/dist/index.js +1 -0
  115. package/dist/mcp/cli.js +35 -34
  116. package/dist/mcp/client.d.ts +2 -2
  117. package/dist/mcp/client.js +20 -2
  118. package/dist/mcp/contract.d.ts +1 -1
  119. package/dist/mcp/profiles.js +5 -1
  120. package/dist/mcp/prompts/index.js +8 -4
  121. package/dist/mcp/resources/index.js +46 -23
  122. package/dist/mcp/server.js +9 -6
  123. package/dist/mcp/tools/protocols.js +9 -2
  124. package/dist/mcp/tools/registry.js +13 -3
  125. package/dist/mcp/tools/seo.js +427 -2
  126. package/dist/mcp/types.d.ts +5 -1
  127. package/dist/plugins/proxy-rotator.d.ts +2 -2
  128. package/dist/plugins/proxy-rotator.js +6 -28
  129. package/dist/raffel/client.d.ts +38 -0
  130. package/dist/raffel/client.js +282 -0
  131. package/dist/raffel/index.d.ts +2 -0
  132. package/dist/raffel/index.js +2 -0
  133. package/dist/raffel/types.d.ts +40 -0
  134. package/dist/raffel/types.js +14 -0
  135. package/dist/recker.d.ts +13 -7
  136. package/dist/recker.js +58 -6
  137. package/dist/scrape/document.js +2 -2
  138. package/dist/scrape/element.js +7 -1
  139. package/dist/scrape/parser/nodes/html.js +1 -1
  140. package/dist/scrape/spider.d.ts +52 -0
  141. package/dist/scrape/spider.js +620 -38
  142. package/dist/scrape/types.d.ts +2 -0
  143. package/dist/search/google.d.ts +26 -1
  144. package/dist/search/google.js +427 -45
  145. package/dist/search/index.d.ts +1 -1
  146. package/dist/seo/analyzer.d.ts +1 -0
  147. package/dist/seo/analyzer.js +144 -1
  148. package/dist/seo/index.d.ts +1 -1
  149. package/dist/seo/keyword-campaign-analyzer.d.ts +2 -0
  150. package/dist/seo/keyword-campaign-analyzer.js +538 -0
  151. package/dist/seo/keyword-campaign-seed-advanced.d.ts +17 -0
  152. package/dist/seo/keyword-campaign-seed-advanced.js +269 -0
  153. package/dist/seo/keyword-campaign-seed-core.d.ts +29 -0
  154. package/dist/seo/keyword-campaign-seed-core.js +525 -0
  155. package/dist/seo/keyword-campaign-shared.d.ts +165 -0
  156. package/dist/seo/keyword-campaign-shared.js +59 -0
  157. package/dist/seo/keyword-campaign.d.ts +4 -107
  158. package/dist/seo/keyword-campaign.js +2 -380
  159. package/dist/seo/keywords.js +5 -22
  160. package/dist/seo/types.d.ts +19 -0
  161. package/dist/template/index.d.ts +1 -1
  162. package/dist/template/types.d.ts +0 -2
  163. package/dist/testing/index.d.ts +1 -22
  164. package/dist/testing/index.js +1 -11
  165. package/dist/transport/curl.d.ts +5 -1
  166. package/dist/transport/curl.js +207 -50
  167. package/dist/transport/undici.d.ts +4 -3
  168. package/dist/transport/undici.js +123 -49
  169. package/dist/types/index.d.ts +9 -3
  170. package/dist/utils/binary-manager.js +26 -3
  171. package/dist/utils/block-detector.d.ts +8 -0
  172. package/dist/utils/block-detector.js +542 -7
  173. package/dist/version.js +1 -1
  174. package/package.json +12 -1
  175. package/dist/testing/mock-dns-server.d.ts +0 -69
  176. package/dist/testing/mock-dns-server.js +0 -269
  177. package/dist/testing/mock-ftp-server.d.ts +0 -89
  178. package/dist/testing/mock-ftp-server.js +0 -562
  179. package/dist/testing/mock-hls-server.d.ts +0 -80
  180. package/dist/testing/mock-hls-server.js +0 -381
  181. package/dist/testing/mock-http-server.d.ts +0 -124
  182. package/dist/testing/mock-http-server.js +0 -343
  183. package/dist/testing/mock-proxy-server.d.ts +0 -108
  184. package/dist/testing/mock-proxy-server.js +0 -615
  185. package/dist/testing/mock-sse-server.d.ts +0 -76
  186. package/dist/testing/mock-sse-server.js +0 -291
  187. package/dist/testing/mock-telnet-server.d.ts +0 -59
  188. package/dist/testing/mock-telnet-server.js +0 -274
  189. package/dist/testing/mock-udp-server.d.ts +0 -43
  190. package/dist/testing/mock-udp-server.js +0 -188
  191. package/dist/testing/mock-websocket-server.d.ts +0 -76
  192. package/dist/testing/mock-websocket-server.js +0 -334
  193. package/dist/testing/mock-whois-server.d.ts +0 -56
  194. package/dist/testing/mock-whois-server.js +0 -234
  195. package/dist/testing/proxy-certs.d.ts +0 -19
  196. package/dist/testing/proxy-certs.js +0 -208
@@ -1,25 +1,43 @@
1
1
  import { createOutput } from '../handler-types.js';
2
2
  import colors from '../../utils/colors.js';
3
+ import { parseSpiderSerpConfig } from '../utils/serp-config.js';
4
+ import { runSpiderKeywordCampaign, toSerpPayload } from '../utils/serp-campaign.js';
5
+ import { getOptionValue, toBoolean, toNonNegativeInt, toOptionString, toSpiderTransport, toSpiderFocusMode, } from '../utils/option-helpers.js';
3
6
  function isExtendedContext(tui) {
4
- return tui && typeof tui.setLoading === 'function';
7
+ return !!tui && typeof tui.setLoading === 'function';
5
8
  }
6
9
  export const spiderHandler = async (ctx) => {
7
10
  const out = createOutput(ctx);
8
11
  const extCtx = isExtendedContext(ctx.tui) ? ctx.tui : null;
12
+ const options = ctx.result.options;
13
+ const serpConfig = parseSpiderSerpConfig(options);
14
+ const shouldRunSerp = !!ctx.result.options.seo && serpConfig.enabled;
9
15
  let url = ctx.result.positional.url;
10
- const depth = ctx.result.options.depth || 10;
11
- const limit = ctx.result.options.limit || 1000;
12
- const concurrency = ctx.result.options.concurrency || 5;
13
- const output = ctx.result.options.output;
14
- const focus = ctx.result.options.focus || 'all';
15
- const seo = ctx.result.options.seo;
16
- const robots = ctx.result.options.robots;
17
- const jsonOutput = ctx.result.options.json;
18
- const jsonl = ctx.result.options.jsonl;
19
- const extract = ctx.result.options.extract;
20
- const include = ctx.result.options.include;
21
- const exclude = ctx.result.options.exclude;
22
- const noSitemap = ctx.result.options.noSitemap;
16
+ const depth = toNonNegativeInt(ctx.result.options.depth, 10);
17
+ const limit = toNonNegativeInt(ctx.result.options.limit, 1000);
18
+ const concurrency = toNonNegativeInt(ctx.result.options.concurrency, 5);
19
+ const output = toOptionString(ctx.result.options.output);
20
+ const focus = toSpiderFocusMode(ctx.result.options.focus);
21
+ const seo = toBoolean(ctx.result.options.seo, false);
22
+ const robots = toBoolean(ctx.result.options.robots, false);
23
+ const jsonOutput = toBoolean(ctx.result.options.json, false);
24
+ const jsonl = toBoolean(ctx.result.options.jsonl, false);
25
+ const extract = toOptionString(ctx.result.options.extract);
26
+ const include = toOptionString(ctx.result.options.include);
27
+ const exclude = toOptionString(ctx.result.options.exclude);
28
+ const noSitemap = toBoolean(ctx.result.options.noSitemap, false);
29
+ const transport = toSpiderTransport(getOptionValue(options, 'transport'));
30
+ const preferCurlFirst = toBoolean(getOptionValue(options, 'preferCurlFirst', 'prefer-curl-first'), true);
31
+ const timeout = toNonNegativeInt(getOptionValue(options, 'timeout'), 10000);
32
+ const delay = toNonNegativeInt(getOptionValue(options, 'delay'), 100);
33
+ const maxRetryAttempts = toNonNegativeInt(getOptionValue(options, 'maxRetryAttempts', 'max-retry-attempts'), 3);
34
+ const baseRetryDelayMs = toNonNegativeInt(getOptionValue(options, 'baseRetryDelayMs', 'base-retry-delay-ms'), 1000);
35
+ const maxRetryDelayMs = toNonNegativeInt(getOptionValue(options, 'maxRetryDelayMs', 'max-retry-delay-ms'), 12000);
36
+ const retryBackoffMultiplier = toNonNegativeInt(getOptionValue(options, 'retryBackoffMultiplier', 'retry-backoff-multiplier'), 2);
37
+ const retryJitterMs = toNonNegativeInt(getOptionValue(options, 'retryJitterMs', 'retry-jitter-ms'), 250);
38
+ const maxDomainBlockStrikes = toNonNegativeInt(getOptionValue(options, 'maxDomainBlockStrikes', 'max-domain-block-strikes'), 2);
39
+ const rotateUserAgent = toBoolean(getOptionValue(options, 'rotateUserAgent', 'rotate-user-agent'), true);
40
+ const randomizeHeaders = toBoolean(getOptionValue(options, 'randomizeHeaders', 'randomize-headers'), true);
23
41
  if (!url) {
24
42
  out.error(colors.red('URL is required'));
25
43
  if (!ctx.isTui)
@@ -39,12 +57,26 @@ export const spiderHandler = async (ctx) => {
39
57
  robots,
40
58
  seo,
41
59
  useSitemap: seo && !noSitemap,
42
- focus: focus,
60
+ focus,
43
61
  extract: extract ? extract.split(',').map(s => s.trim()) : undefined,
44
62
  include: include ? include.split(',').map(s => s.trim()) : undefined,
45
63
  exclude: exclude ? exclude.split(',').map(s => s.trim()) : undefined,
64
+ transport,
65
+ preferCurlFirst,
66
+ timeout,
67
+ delay,
68
+ maxRetryAttempts,
69
+ baseRetryDelayMs,
70
+ maxRetryDelayMs,
71
+ retryBackoffMultiplier,
72
+ retryJitterMs,
73
+ maxDomainBlockStrikes,
74
+ rotateUserAgent,
75
+ randomizeHeaders,
46
76
  json: true,
47
77
  });
78
+ const serpCampaign = shouldRunSerp ? await runSpiderKeywordCampaign(url, result.pages, serpConfig) : undefined;
79
+ const serpPayload = serpCampaign ? toSerpPayload(serpCampaign) : undefined;
48
80
  extCtx.addResponse({
49
81
  url: result.url,
50
82
  pagesVisited: result.pagesVisited,
@@ -52,6 +84,8 @@ export const spiderHandler = async (ctx) => {
52
84
  errors: result.errors,
53
85
  internalLinks: result.internalLinks,
54
86
  externalLinks: result.externalLinks,
87
+ security: result.security,
88
+ serp: serpPayload,
55
89
  seo: result.seo ? {
56
90
  avgScore: result.seo.avgScore,
57
91
  pagesWithErrors: result.seo.pagesWithErrors,
@@ -68,7 +102,7 @@ export const spiderHandler = async (ctx) => {
68
102
  }
69
103
  catch (err) {
70
104
  extCtx.addResponse({
71
- error: err.message,
105
+ error: err instanceof Error ? err.message : String(err),
72
106
  }, { responseType: 'spider-error' });
73
107
  }
74
108
  finally {
@@ -92,12 +126,25 @@ export const spiderHandler = async (ctx) => {
92
126
  extract,
93
127
  include,
94
128
  exclude,
129
+ transport,
130
+ preferCurlFirst,
131
+ timeout,
132
+ delay,
133
+ maxRetryAttempts,
134
+ baseRetryDelayMs,
135
+ maxRetryDelayMs,
136
+ retryBackoffMultiplier,
137
+ retryJitterMs,
138
+ maxDomainBlockStrikes,
139
+ rotateUserAgent,
140
+ randomizeHeaders,
95
141
  noSitemap,
142
+ serpConfig,
96
143
  disableTui: false,
97
144
  });
98
145
  }
99
146
  catch (err) {
100
- out.error(colors.red(`Spider failed: ${err.message}`));
147
+ out.error(colors.red(`Spider failed: ${err instanceof Error ? err.message : String(err)}`));
101
148
  if (!ctx.isTui)
102
149
  process.exit(1);
103
150
  }
@@ -126,11 +173,41 @@ export const spiderCommands = {
126
173
  include: { short: 'i', type: 'string', description: 'URL patterns to include (comma-separated regex)' },
127
174
  exclude: { short: 'x', type: 'string', description: 'URL patterns to exclude (comma-separated regex)' },
128
175
  noSitemap: { short: 'N', type: 'boolean', description: 'Disable sitemap.xml crawling' },
176
+ transport: { type: 'string', default: 'auto', description: 'HTTP transport (auto | undici | curl)' },
177
+ 'prefer-curl-first': { type: 'boolean', default: true, description: 'Prefer curl-impersonate first in auto mode' },
178
+ timeout: { type: 'number', default: 10000, description: 'Request timeout in ms' },
179
+ delay: { type: 'number', default: 100, description: 'Delay between requests in ms' },
180
+ 'max-retry-attempts': { type: 'number', default: 3, description: 'Max retry attempts per request' },
181
+ 'base-retry-delay-ms': { type: 'number', default: 1000, description: 'Base retry delay in ms' },
182
+ 'max-retry-delay-ms': { type: 'number', default: 12000, description: 'Maximum retry delay in ms' },
183
+ 'retry-backoff-multiplier': { type: 'number', default: 2, description: 'Retry backoff multiplier' },
184
+ 'retry-jitter-ms': { type: 'number', default: 250, description: 'Retry jitter in ms' },
185
+ 'max-domain-block-strikes': { type: 'number', default: 2, description: 'Block strikes before forcing curl in auto mode' },
186
+ 'rotate-user-agent': { type: 'boolean', default: true, description: 'Rotate user-agent per request' },
187
+ 'randomize-headers': { type: 'boolean', default: true, description: 'Randomize request headers' },
188
+ serp: { type: 'boolean', description: 'Enable SERP campaign for top extracted keywords' },
189
+ 'serp-top-keywords': { type: 'number', default: 5, description: 'Top keywords per page used as seeds' },
190
+ 'serp-query-limit': { type: 'number', default: 10, description: 'Number of SERP queries to execute' },
191
+ 'serp-results-per-query': { type: 'number', default: 10, description: 'Results fetched per SERP query' },
192
+ 'serp-concurrency': { type: 'number', default: 1, description: 'Number of SERP queries to execute in parallel' },
193
+ 'serp-delay-ms': { type: 'number', default: 1200, description: 'Delay in ms between SERP queries' },
194
+ 'serp-delay-jitter-ms': { type: 'number', default: 450, description: 'Random delay jitter in ms between SERP queries' },
195
+ 'serp-max-consecutive-blocks': { type: 'number', default: 3, description: 'Stop campaign after this many blocked/captcha responses in a row' },
196
+ 'serp-captcha-cooldown-ms': { type: 'number', default: 2400, description: 'Cooldown in ms after captcha before continuing SERP' },
197
+ 'serp-retry-count': { type: 'number', default: 1, description: 'Retry count per SERP query' },
198
+ 'serp-retry-delay-ms': { type: 'number', default: 1200, description: 'Base retry delay in ms for SERP queries' },
199
+ 'serp-transport': { type: 'string', description: 'Search transport (auto | undici | curl)' },
200
+ 'serp-timeout': { type: 'number', description: 'Search timeout in ms' },
201
+ 'serp-country': { type: 'string', description: 'Search country/region' },
202
+ 'serp-gl': { type: 'string', description: 'Google GL parameter' },
203
+ 'serp-hl': { type: 'string', description: 'Google HL parameter' },
129
204
  },
130
205
  examples: [
131
206
  { cmd: 'rek spider example.com', desc: 'Basic crawl' },
132
207
  { cmd: 'rek spider example.com --seo', desc: 'Enable SEO analysis' },
133
208
  { cmd: 'rek spider example.com -d 3 -l 50', desc: 'Depth 3, max 50 pages' },
209
+ { cmd: 'rek spider example.com --seo --serp', desc: 'Enable SEO + SERP campaign' },
210
+ { cmd: 'rek spider example.com --seo --serp --serp-top-keywords 12 --serp-query-limit 10 --serp-results-per-query 10', desc: 'Tuned SERP campaign' },
134
211
  ],
135
212
  handler: spiderHandler
136
213
  }
@@ -1,4 +1,5 @@
1
1
  import { withHandler, getString, getNumber, getBoolean, colors, } from '../output.js';
2
+ import { ValidationError } from '../../core/errors.js';
2
3
  export const hlsInfoHandler = withHandler({ loading: true }, async (ctx, out, extCtx) => {
3
4
  let url = getString(ctx.result.positional.url);
4
5
  if (!url) {
@@ -83,7 +84,10 @@ export const sseHandler = withHandler({ loading: true }, async (ctx, out, extCtx
83
84
  });
84
85
  const reader = response.body?.getReader();
85
86
  if (!reader) {
86
- throw new Error('No response body - SSE requires streaming support');
87
+ throw new ValidationError('SSE response must include a readable body', {
88
+ field: 'response.body',
89
+ value: url,
90
+ });
87
91
  }
88
92
  const decoder = new TextDecoder();
89
93
  const events = [];
package/dist/cli/index.js CHANGED
@@ -8,6 +8,7 @@ import { registerDnsCommands } from './commands/dns.js';
8
8
  import { registerAiCommand } from './commands/ai.js';
9
9
  import { registerSpiderCommand } from './commands/spider.js';
10
10
  import { registerScrapeCommand } from './commands/scrape.js';
11
+ import { registerSearchCommand } from './commands/search.js';
11
12
  import { registerBenchCommand, registerLoadCommand } from './commands/bench.js';
12
13
  import { registerSecurityCommand } from './commands/security.js';
13
14
  import { registerServeCommand } from './commands/serve.js';
@@ -123,6 +124,7 @@ async function main() {
123
124
  .option('-o, --output <file>', 'Write response body to file')
124
125
  .option('-j, --json', 'Force JSON content-type')
125
126
  .option('-e, --env [path]', 'Load .env file')
127
+ .option('-x, --proxy <url>', 'Proxy URL (http://, https://, socks5://, socks5h://). Repeat for rotation.')
126
128
  .addHelpText('after', () => `
127
129
  ${theme.header('Examples:')}
128
130
  ${theme.example('$ rek httpbin.org/json')}
@@ -144,6 +146,12 @@ ${formatColumns(PRESET_NAMES, { prefix: '@', indent: 2, minWidth: 16, transform:
144
146
  const { clientOptions: enhancerOptions, remainingArgs } = await parseEnhancerPresets(args);
145
147
  let argsToParse = remainingArgs;
146
148
  let finalClientOptions = enhancerOptions;
149
+ if (options.proxy) {
150
+ const proxyArg = options.proxy;
151
+ finalClientOptions.proxy = Array.isArray(proxyArg)
152
+ ? proxyArg
153
+ : proxyArg;
154
+ }
147
155
  if (argsToParse.length > 0 && argsToParse[0].startsWith('@')) {
148
156
  let presetArg = argsToParse[0];
149
157
  let presetName = presetArg.slice(1);
@@ -155,7 +163,7 @@ ${formatColumns(PRESET_NAMES, { prefix: '@', indent: 2, minWidth: 16, transform:
155
163
  }
156
164
  const mainPresetOptions = await resolvePreset(presetName);
157
165
  if (mainPresetOptions) {
158
- finalClientOptions = { ...finalClientOptions, ...mainPresetOptions, headers: { ...finalClientOptions.headers, ...mainPresetOptions.headers } };
166
+ finalClientOptions = { ...finalClientOptions, ...mainPresetOptions, headers: { ...finalClientOptions.headers, ...(mainPresetOptions.headers || {}) } };
159
167
  if (pathFromPreset) {
160
168
  if (finalClientOptions.baseUrl) {
161
169
  finalClientOptions.baseUrl = (finalClientOptions.baseUrl || '').replace(/\/$/, '') + pathFromPreset;
@@ -259,7 +267,7 @@ ${formatColumns(PRESET_NAMES, { prefix: '@', indent: 2, minWidth: 16, transform:
259
267
  body = data;
260
268
  }
261
269
  await handleRequest({
262
- method,
270
+ method: method,
263
271
  url,
264
272
  body,
265
273
  verbose: options.verbose,
@@ -314,6 +322,7 @@ ${formatColumns(PRESET_NAMES, { prefix: '@', indent: 2, minWidth: 16, transform:
314
322
  registerSecurityCommand(program);
315
323
  registerSpiderCommand(program);
316
324
  registerScrapeCommand(program);
325
+ registerSearchCommand(program);
317
326
  registerSeoCommand(program);
318
327
  registerNetworkCommands(program);
319
328
  registerDnsCommands(program);
@@ -2,4 +2,4 @@ export interface ResolvePresetOptions {
2
2
  throwOnError?: boolean;
3
3
  silent?: boolean;
4
4
  }
5
- export declare function resolvePreset(name: string, options?: ResolvePresetOptions): Promise<any>;
5
+ export declare function resolvePreset(name: string, options?: ResolvePresetOptions): Promise<unknown>;
@@ -1,5 +1,6 @@
1
1
  import * as presets from '../presets/index.js';
2
2
  import colors from '../utils/colors.js';
3
+ import { ConfigurationError, ValidationError } from '../core/errors.js';
3
4
  const ENV_MAPPING = {
4
5
  openai: ['OPENAI_API_KEY'],
5
6
  anthropic: ['ANTHROPIC_API_KEY'],
@@ -20,13 +21,14 @@ const ENV_MAPPING = {
20
21
  together: ['TOGETHER_API_KEY'],
21
22
  perplexity: ['PERPLEXITY_API_KEY'],
22
23
  };
24
+ const presetFactories = presets;
23
25
  export async function resolvePreset(name, options = {}) {
24
26
  const { throwOnError = false, silent = false } = options;
25
- const presetFn = presets[name];
27
+ const presetFn = presetFactories[name];
26
28
  if (!presetFn) {
27
29
  const msg = `Unknown preset '@${name}'`;
28
30
  if (throwOnError) {
29
- throw new Error(msg);
31
+ throw new ValidationError(msg, { field: 'name', value: name });
30
32
  }
31
33
  if (!silent) {
32
34
  console.error(colors.red(`Error: ${msg}`));
@@ -58,9 +60,9 @@ export async function resolvePreset(name, options = {}) {
58
60
  return presetFn(presetOptions);
59
61
  }
60
62
  catch (error) {
61
- const msg = `Error initializing preset @${name}: ${error.message}`;
63
+ const msg = `Error initializing preset @${name}: ${getErrorMessage(error)}`;
62
64
  if (throwOnError) {
63
- throw new Error(msg);
65
+ throw new ConfigurationError(msg, { configKey: `preset.${name}` });
64
66
  }
65
67
  if (!silent) {
66
68
  console.error(colors.red(msg));
@@ -68,6 +70,15 @@ export async function resolvePreset(name, options = {}) {
68
70
  return null;
69
71
  }
70
72
  }
73
+ function getErrorMessage(error) {
74
+ if (error instanceof Error) {
75
+ return error.message;
76
+ }
77
+ if (typeof error === 'string') {
78
+ return error;
79
+ }
80
+ return String(error);
81
+ }
71
82
  function mapEnvToOption(preset, env) {
72
83
  if (env.endsWith('_KEY') || env.endsWith('_TOKEN'))
73
84
  return 'apiKey';
@@ -385,7 +385,12 @@ function DomainsTabContent({ width, height }) {
385
385
  }
386
386
  return Box({ flexDirection: 'column', paddingX: 1 }, Text({ color: primaryColor, bold: true }, `🌐 ${intel.domain}`), Text({ color: mutedFg }, '─'.repeat(Math.min(60, width - 4))), Text({ color: mutedFg, dim: true }, `First seen: ${intel.firstSeen.toLocaleString()} │ Last: ${intel.lastAccessed.toLocaleString()}`), Box({ height: 1 }), intel.dns ? Box({ flexDirection: 'column' }, Text({ color: accentColor, bold: true }, '📡 DNS Records'), ...intel.dns.records.slice(0, 5).map(r => Text({ color: mutedFg }, ` ${r.type.padEnd(6)} ${r.value}`)), intel.dns.records.length > 5
387
387
  ? Text({ color: mutedFg, dim: true }, ` ... and ${intel.dns.records.length - 5} more`)
388
- : null, Box({ height: 1 })) : null, intel.seo ? Box({ flexDirection: 'column' }, Text({ color: accentColor, bold: true }, `📊 SEO Score: ${intel.seo.score}/100`), Text({ color: mutedFg }, ` Issues: ${intel.seo.issues} │ Technical: ${intel.seo.categories.technical} │ Content: ${intel.seo.categories.content}`), Box({ height: 1 })) : null, intel.spider ? Box({ flexDirection: 'column' }, Text({ color: accentColor, bold: true }, `🕷 Spider Results`), Text({ color: mutedFg }, ` Pages: ${intel.spider.pagesFound} │ Links: ${intel.spider.internalLinks + intel.spider.externalLinks} │ Images: ${intel.spider.images}`), Box({ height: 1 })) : null, intel.downloads.length > 0 ? Box({ flexDirection: 'column' }, Text({ color: accentColor, bold: true }, `📥 Downloads (${intel.downloads.length})`), ...intel.downloads.slice(-3).map(d => Text({ color: mutedFg }, ` ${d.type.padEnd(5)} ${d.filename.slice(0, 30)} (${(d.size / 1024 / 1024).toFixed(1)} MB)`)), Box({ height: 1 })) : null, Text({ color: mutedFg, dim: true }, 'Esc: Back to list Ctrl+D: DNS Ctrl+S: SEO Ctrl+R: Crawl'));
388
+ : null, Box({ height: 1 })) : null, intel.seo ? Box({ flexDirection: 'column' }, Text({ color: accentColor, bold: true }, `📊 SEO Score: ${intel.seo.score}/100`), Text({ color: mutedFg }, ` Issues: ${intel.seo.issues} │ Technical: ${intel.seo.categories.technical} │ Content: ${intel.seo.categories.content}`), Box({ height: 1 })) : null, intel.spider ? Box({ flexDirection: 'column' }, Text({ color: accentColor, bold: true }, `🕷 Spider Results`), Text({ color: mutedFg }, ` Pages: ${intel.spider.pagesFound} │ Links: ${intel.spider.internalLinks + intel.spider.externalLinks} │ Images: ${intel.spider.images}`), intel.spider.security ? Box({ flexDirection: 'column' }, Text({ color: mutedFg }, ` Anti-bot: blocked ${intel.spider.security.blockedPages} · captcha ${intel.spider.security.captchaPages}`), Text({ color: mutedFg }, ` Attempts: ${intel.spider.security.attempts} │ retries: ${intel.spider.security.retries}`), Text({ color: mutedFg }, ` Avg attempts: ${intel.spider.security.avgAttempts !== undefined ? intel.spider.security.avgAttempts.toFixed(2) : 'n/a'}`), Text({ color: mutedFg }, ` Transport: curl ${intel.spider.security.transportUsage?.curl || 0} / undici ${intel.spider.security.transportUsage?.undici || 0}`), Text({ color: mutedFg }, ` Timing: TTFB ${intel.spider.security.avgTtfbMs ?? 'n/a'}ms | total ${intel.spider.security.avgTotalMs ?? 'n/a'}ms | download ${intel.spider.security.avgDownloadMs ?? 'n/a'}ms`), intel.spider.security.captchaProviders && Object.keys(intel.spider.security.captchaProviders).length > 0
389
+ ? Text({ color: mutedFg }, ` Captcha providers: ${Object.entries(intel.spider.security.captchaProviders)
390
+ .sort((a, b) => b[1] - a[1])
391
+ .map(([provider, count]) => `${provider}: ${count}`)
392
+ .join(' | ')}`)
393
+ : null, Box({ height: 1 })) : null, Box({ height: 1 })) : null, intel.downloads.length > 0 ? Box({ flexDirection: 'column' }, Text({ color: accentColor, bold: true }, `📥 Downloads (${intel.downloads.length})`), ...intel.downloads.slice(-3).map(d => Text({ color: mutedFg }, ` ${d.type.padEnd(5)} ${d.filename.slice(0, 30)} (${(d.size / 1024 / 1024).toFixed(1)} MB)`)), Box({ height: 1 })) : null, Text({ color: mutedFg, dim: true }, 'Esc: Back to list │ Ctrl+D: DNS │ Ctrl+S: SEO │ Ctrl+R: Crawl'));
389
394
  }
390
395
  return Box({ flexDirection: 'column', paddingX: 1 }, Text({ color: primaryColor, bold: true }, `🌐 Domain Intelligence (${stats.total})`), Text({ color: mutedFg }, '─'.repeat(Math.min(60, width - 4))), Box({ height: 1 }), Box({ flexDirection: 'row', gap: 2 }, Text({ color: mutedFg, dim: true }, `DNS: ${stats.withDns}`), Text({ color: mutedFg, dim: true }, `SEO: ${stats.withSeo}`), Text({ color: mutedFg, dim: true }, `Spider: ${stats.withSpider}`), Text({ color: mutedFg, dim: true }, `Downloads: ${stats.totalDownloads}`)), Box({ height: 1 }), Box({ flexDirection: 'row', gap: 1 }, Text({ color: mutedFg, dim: true, bold: true }, ' Domain'.padEnd(25)), Text({ color: mutedFg, dim: true, bold: true }, 'DNS'), Text({ color: mutedFg, dim: true, bold: true }, ' SEO'), Text({ color: mutedFg, dim: true, bold: true }, ' Spider'), Text({ color: mutedFg, dim: true, bold: true }, ' DL')), ...allDomains.slice(0, height - 10).map((entry, idx) => {
391
396
  const isSelected = entry.domain === selected;
@@ -206,6 +206,61 @@ interface SeoResponseData {
206
206
  };
207
207
  checks?: SeoCheck[];
208
208
  topIssues?: string[];
209
+ serp?: {
210
+ summary?: {
211
+ queriesRequested?: number;
212
+ queriesExecuted?: number;
213
+ queriesFound?: number;
214
+ queriesBlocked?: number;
215
+ queriesCaptcha?: number;
216
+ avgTopPosition?: number | null;
217
+ top3Count?: number;
218
+ top10Count?: number;
219
+ topOrganicCompetitors?: Array<{
220
+ domain: string;
221
+ matchedKeywords: number;
222
+ totalOutperformedQueries?: number;
223
+ organicQueries?: number;
224
+ paidQueries?: number;
225
+ avgOutperformedGap?: number | null;
226
+ }>;
227
+ topPaidCompetitors?: Array<{
228
+ domain: string;
229
+ matchedKeywords: number;
230
+ totalOutperformedQueries?: number;
231
+ organicQueries?: number;
232
+ paidQueries?: number;
233
+ avgOutperformedGap?: number | null;
234
+ }>;
235
+ competitorCoverage?: {
236
+ organicUniqueDomains?: number;
237
+ paidUniqueDomains?: number;
238
+ };
239
+ };
240
+ campaign?: {
241
+ active?: boolean;
242
+ confidence?: 'high' | 'medium' | 'low';
243
+ evidence?: string[];
244
+ };
245
+ results?: Array<{
246
+ keyword: string;
247
+ found: boolean;
248
+ blocked?: boolean;
249
+ blockReason?: string;
250
+ position: number | null;
251
+ targetUrl?: string;
252
+ searchUrl: string;
253
+ }>;
254
+ pageComparison?: Array<{
255
+ pageUrl: string;
256
+ tracked: number;
257
+ found: number;
258
+ appearanceRate: string;
259
+ avgPosition: string | number | null;
260
+ top3: number;
261
+ top10: number;
262
+ }>;
263
+ };
209
264
  }
210
265
  declare function renderSeoResponse(data: SeoResponseData, width: number): VNode;
211
266
  interface SpiderResponseData {
@@ -266,6 +321,23 @@ interface SpiderResponseData {
266
321
  };
267
322
  };
268
323
  };
324
+ serp?: NonNullable<SeoResponseData['serp']>;
325
+ security?: {
326
+ pages?: number;
327
+ blockedPages?: number;
328
+ captchaPages?: number;
329
+ captchaProviders?: Record<string, number>;
330
+ attempts?: number;
331
+ retries?: number;
332
+ transportUsage?: {
333
+ undici?: number;
334
+ curl?: number;
335
+ };
336
+ avgAttempts?: number;
337
+ avgTtfbMs?: number;
338
+ avgTotalMs?: number;
339
+ avgDownloadMs?: number;
340
+ };
269
341
  }
270
342
  declare function renderSpiderResponse(data: SpiderResponseData, width: number): VNode;
271
343
  declare function renderError(data: {
@@ -313,6 +313,90 @@ function renderTlsResponse(data, width) {
313
313
  }
314
314
  return Box({ flexDirection: 'column', width }, ...children);
315
315
  }
316
+ function toPercent(value) {
317
+ if (typeof value === 'number' && Number.isFinite(value))
318
+ return Math.max(0, Math.min(100, Math.round(value)));
319
+ const parsed = typeof value === 'string' ? parseFloat(value) : NaN;
320
+ if (Number.isFinite(parsed))
321
+ return Math.max(0, Math.min(100, Math.round(parsed)));
322
+ return 0;
323
+ }
324
+ function renderSerpSection(payload, width, isSpider = false) {
325
+ const serp = payload.serp;
326
+ if (!serp)
327
+ return [];
328
+ const summary = serp.summary || {};
329
+ const queriesRequested = Math.max(0, summary.queriesRequested || summary.queriesExecuted || 0);
330
+ const queriesFound = Math.max(0, summary.queriesFound || 0);
331
+ const queriesBlocked = Math.max(0, summary.queriesBlocked || 0);
332
+ const queriesCaptcha = Math.max(0, summary.queriesCaptcha || 0);
333
+ const foundRate = queriesRequested > 0 ? Math.round((queriesFound / queriesRequested) * 100) : 0;
334
+ const avgTop = summary.avgTopPosition;
335
+ const top3 = summary.top3Count || 0;
336
+ const top10 = summary.top10Count || 0;
337
+ const children = [];
338
+ children.push(Box({ marginTop: 1 }, Text({ color: themeColor('foreground'), bold: true }, isSpider ? 'SERP on Spider Campaign' : 'SERP Campaign')));
339
+ children.push(Box({ flexDirection: 'row' }, Text({ color: themeColor('success') }, `🔎 Queries: ${queriesFound}/${queriesRequested} `), Text({ color: themeColor('warning') }, `⚠ blocked ${queriesBlocked} `), Text({ color: themeColor('warning') }, `🧩 captcha ${queriesCaptcha} `), Text({ color: themeColor('accent') }, `✅ hitRate ${foundRate}% `), Text({ color: themeColor('mutedForeground') }, `🏆 Top3 ${top3} `), Text({ color: themeColor('mutedForeground') }, `🏅 Top10 ${top10} `), avgTop !== null && avgTop !== undefined ? Text({ color: themeColor('foreground') }, `📈 avgPos ${Math.round(avgTop)}`) : null));
340
+ if (serp.campaign) {
341
+ const campaignConfidence = serp.campaign.confidence || 'low';
342
+ const campaignActive = serp.campaign.active ? 'active' : 'inactive';
343
+ const color = campaignConfidence === 'high' ? themeColor('success') : campaignConfidence === 'medium' ? themeColor('warning') : themeColor('mutedForeground');
344
+ children.push(Box({ flexDirection: 'row' }, Text({ color: campaignActive === 'active' ? themeColor('success') : themeColor('mutedForeground') }, `Campaign ${campaignActive}`), Text({ color }, ` confidence ${campaignConfidence}`)));
345
+ if ((serp.campaign.evidence || []).length > 0) {
346
+ const evidence = serp.campaign.evidence.slice(0, 2);
347
+ children.push(Box({ marginTop: 1 }, Text({ color: themeColor('mutedForeground') }, `Evidence: ${evidence.join(' • ')}`)));
348
+ }
349
+ }
350
+ if ((summary.competitorCoverage?.organicUniqueDomains !== undefined || summary.competitorCoverage?.paidUniqueDomains !== undefined)) {
351
+ children.push(Text({ color: themeColor('mutedForeground') }, `Competition: ${summary.competitorCoverage?.organicUniqueDomains || 0} organic domains, ${summary.competitorCoverage?.paidUniqueDomains || 0} paid domains`));
352
+ }
353
+ const organic = (summary.topOrganicCompetitors || []).slice(0, 6);
354
+ if (organic.length > 0) {
355
+ const barData = organic.map((item) => ({
356
+ label: item.domain.replace(/^www\./, '').slice(0, 20),
357
+ value: Math.max(0, item.matchedKeywords || item.totalOutperformedQueries || 0),
358
+ color: themeColor('success'),
359
+ }));
360
+ children.push(Box({ marginTop: 1 }, Text({ color: themeColor('foreground'), bold: true }, 'Main organic competitors')));
361
+ children.push(BarChart({
362
+ data: barData,
363
+ orientation: 'horizontal',
364
+ width: Math.min(45, width - 4),
365
+ showValues: true,
366
+ }));
367
+ }
368
+ const resultFoundSeries = (serp.results || []).slice(0, 12).map((item) => (item.found ? 100 : 0));
369
+ if (resultFoundSeries.length > 1) {
370
+ children.push(Box({ marginTop: 1 }, Text({ color: themeColor('foreground'), bold: true }, 'SERP hit history')));
371
+ children.push(Box({ flexDirection: 'row' }, Sparkline({
372
+ data: resultFoundSeries,
373
+ width: Math.min(45, width - 4),
374
+ color: 'green',
375
+ max: 100,
376
+ min: 0,
377
+ }), Box({ marginLeft: 1 }, Text({ color: themeColor('foreground'), dim: true }, `${resultFoundSeries.filter(v => v === 100).length}/${resultFoundSeries.length}`))));
378
+ }
379
+ const pageRates = (serp.pageComparison || [])
380
+ .map((page) => ({
381
+ pageUrl: page.pageUrl,
382
+ rate: toPercent(page.appearanceRate),
383
+ }))
384
+ .slice(0, 10);
385
+ if (pageRates.length > 1) {
386
+ children.push(Box({ marginTop: 1 }, Text({ color: themeColor('foreground'), bold: true }, 'Top page hit rate')));
387
+ children.push(Sparkline({
388
+ data: pageRates.map((item) => item.rate),
389
+ width: Math.min(45, width - 4),
390
+ color: 'blue',
391
+ max: 100,
392
+ min: 0,
393
+ }));
394
+ children.push(Box({ flexDirection: 'column' }, ...pageRates
395
+ .slice(0, 4)
396
+ .map((page) => Text({ color: themeColor('mutedForeground') }, ` ${page.pageUrl.slice(0, 38)} (${page.rate}%)`))));
397
+ }
398
+ return children;
399
+ }
316
400
  function renderSeoResponse(data, width) {
317
401
  try {
318
402
  const children = [];
@@ -515,6 +599,9 @@ function renderSeoResponse(data, width) {
515
599
  children.push(renderBar('Images', comp.images || 0));
516
600
  children.push(renderBar('Links', comp.links || 0));
517
601
  }
602
+ if (data.serp) {
603
+ children.push(...renderSerpSection(data, contentWidth, false));
604
+ }
518
605
  return Box({ flexDirection: 'column', width }, ...children);
519
606
  }
520
607
  catch (err) {
@@ -524,6 +611,7 @@ function renderSeoResponse(data, width) {
524
611
  function renderSpiderResponse(data, width) {
525
612
  const children = [];
526
613
  const hasSeo = !!data.seo;
614
+ const contentWidth = Math.max(40, width - 4);
527
615
  children.push(Box({ flexDirection: 'row' }, Text({ color: themeColor('primary'), bold: true }, hasSeo ? '🔍 SEO Spider Report' : '🕷️ Spider Crawl Report')));
528
616
  if (data.url) {
529
617
  children.push(Text({ color: themeColor('mutedForeground') }, `URL: ${data.url}`));
@@ -536,6 +624,32 @@ function renderSpiderResponse(data, width) {
536
624
  if (data.assets) {
537
625
  children.push(Box({ flexDirection: 'row' }, Text({ color: themeColor('mutedForeground') }, `🖼 Images: ${data.assets.images || 0} `), Text({ color: themeColor('mutedForeground') }, `📜 Scripts: ${data.assets.scripts || 0} `), Text({ color: themeColor('mutedForeground') }, `🎨 Stylesheets: ${data.assets.stylesheets || 0}`)));
538
626
  }
627
+ if (data.security) {
628
+ const security = data.security;
629
+ const totalPages = security.pages || (data.pages || 0);
630
+ const blockedPages = security.blockedPages || 0;
631
+ const captchaPages = security.captchaPages || 0;
632
+ const captchaProviders = security.captchaProviders || {};
633
+ const transportUsage = security.transportUsage || {};
634
+ const undiciRequests = transportUsage.undici || 0;
635
+ const curlRequests = transportUsage.curl || 0;
636
+ const transportTotal = undiciRequests + curlRequests;
637
+ const blockedRate = totalPages > 0 ? Math.round((blockedPages / totalPages) * 100) : 0;
638
+ const blockedColor = blockedRate === 0 ? themeColor('success') : blockedRate <= 15 ? themeColor('warning') : themeColor('error');
639
+ const providerSummary = Object.entries(captchaProviders)
640
+ .map(([provider, count]) => `${provider}: ${count}`)
641
+ .join(', ');
642
+ children.push(Box({ marginTop: 1 }, Text({ color: themeColor('foreground'), bold: true }, 'Anti-Bot Signals')));
643
+ children.push(Box({ flexDirection: 'row' }, Text({ color: blockedColor }, `🚫 blocked ${blockedPages}/${totalPages} (${blockedRate}%) `), Text({ color: themeColor('warning') }, `🤖 captcha ${captchaPages}`), Text({ color: themeColor('mutedForeground') }, ` 🔁 attempts ${security.attempts || 0} │ retries ${security.retries || 0}`)));
644
+ children.push(Box({ flexDirection: 'row' }, Text({ color: themeColor('success') }, `Avg attempts: ${security.avgAttempts !== undefined ? security.avgAttempts.toFixed(2) : 'n/a'}`), Text({ color: themeColor('mutedForeground') }, ` Avg TTFB: ${security.avgTtfbMs ?? 'n/a'}ms`), Text({ color: themeColor('mutedForeground') }, ` Avg total: ${security.avgTotalMs ?? 'n/a'}ms`), Text({ color: themeColor('mutedForeground') }, ` Avg download: ${security.avgDownloadMs ?? 'n/a'}ms`)));
645
+ children.push(Text({ color: themeColor('mutedForeground') }, `Captcha providers: ${providerSummary || 'n/a'}`));
646
+ if (transportTotal > 0) {
647
+ children.push(Text({ color: themeColor('mutedForeground') }, `Transport split: curl ${curlRequests} (${Math.round((curlRequests / transportTotal) * 100)}%) / undici ${undiciRequests} (${Math.round((undiciRequests / transportTotal) * 100)}%)`));
648
+ }
649
+ else {
650
+ children.push(Text({ color: themeColor('mutedForeground') }, `Transport split: not enough data`));
651
+ }
652
+ }
539
653
  if (data.seo) {
540
654
  const seo = data.seo;
541
655
  children.push(Box({ marginTop: 1 }, Text({ color: themeColor('foreground'), bold: true }, 'SEO Summary')));
@@ -592,6 +706,9 @@ function renderSpiderResponse(data, width) {
592
706
  }
593
707
  }
594
708
  }
709
+ if (data.serp) {
710
+ children.push(...renderSerpSection(data, contentWidth, true));
711
+ }
595
712
  if (data.topPages && data.topPages.length > 0) {
596
713
  children.push(Box({ marginTop: 1 }, Text({ color: themeColor('foreground'), bold: true }, `Top ${Math.min(10, data.topPages.length)} Pages`)));
597
714
  for (const page of data.topPages.slice(0, 10)) {