recker 1.0.72 → 1.0.73

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.
@@ -1,6 +1,114 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import { createHash } from 'node:crypto';
1
5
  import { createClient } from '../../core/client.js';
2
6
  import { analyzeSeo } from '../../seo/analyzer.js';
3
7
  import { seoSpider } from '../../seo/seo-spider.js';
8
+ const DEFAULT_SEO_CACHE_TTL_SEC = 6 * 60 * 60;
9
+ const SEO_REPORT_DIR = join(tmpdir(), 'recker', 'seo');
10
+ function normalizeStringArray(value) {
11
+ if (!Array.isArray(value))
12
+ return undefined;
13
+ const items = value.map(item => String(item).trim()).filter(Boolean);
14
+ if (items.length === 0)
15
+ return undefined;
16
+ return items;
17
+ }
18
+ function safeHost(url) {
19
+ try {
20
+ const parsed = new URL(url);
21
+ const host = parsed.hostname || parsed.host;
22
+ return host.replace(/[^a-z0-9.-]/gi, '_') || 'unknown';
23
+ }
24
+ catch {
25
+ return url.replace(/[^a-z0-9.-]/gi, '_').slice(0, 64) || 'unknown';
26
+ }
27
+ }
28
+ function hashArgs(value) {
29
+ return createHash('sha1').update(JSON.stringify(value)).digest('hex').slice(0, 12);
30
+ }
31
+ function resolveReportPersistence(args, options) {
32
+ const output = typeof args.output === 'string' ? args.output : undefined;
33
+ const outputDir = typeof args.outputDir === 'string' ? args.outputDir : undefined;
34
+ const persist = args.persist === true;
35
+ const persistenceEnabled = Boolean(output || outputDir || persist);
36
+ const cacheEnabled = persistenceEnabled ? (typeof args.cache === 'boolean' ? args.cache : true) : false;
37
+ const forceRefresh = args.forceRefresh === true;
38
+ const cacheTtlInput = typeof args.cacheTtlSec === 'number'
39
+ ? args.cacheTtlSec
40
+ : Number.isFinite(Number(args.cacheTtlSec))
41
+ ? Number(args.cacheTtlSec)
42
+ : undefined;
43
+ const cacheTtlSec = cacheEnabled
44
+ ? Math.max(0, cacheTtlInput ?? DEFAULT_SEO_CACHE_TTL_SEC)
45
+ : 0;
46
+ if (!persistenceEnabled) {
47
+ return {
48
+ enabled: false,
49
+ reportPath: null,
50
+ cacheEnabled: false,
51
+ cacheTtlSec: 0,
52
+ forceRefresh,
53
+ };
54
+ }
55
+ const host = safeHost(options.url);
56
+ const hash = hashArgs(options.cacheKey);
57
+ const fileName = `${options.toolSlug}--${host}--${hash}.json`;
58
+ const reportPath = output
59
+ ? output
60
+ : outputDir
61
+ ? join(outputDir, fileName)
62
+ : join(SEO_REPORT_DIR, fileName);
63
+ return {
64
+ enabled: true,
65
+ reportPath,
66
+ cacheEnabled,
67
+ cacheTtlSec,
68
+ forceRefresh,
69
+ };
70
+ }
71
+ async function readCachedReport(path, ttlSec) {
72
+ if (!path || ttlSec <= 0)
73
+ return null;
74
+ try {
75
+ const stats = await fs.stat(path);
76
+ const ageSec = (Date.now() - stats.mtimeMs) / 1000;
77
+ if (ageSec > ttlSec) {
78
+ return null;
79
+ }
80
+ const text = await fs.readFile(path, 'utf8');
81
+ const report = JSON.parse(text);
82
+ if (report && typeof report === 'object') {
83
+ delete report.reportMeta;
84
+ delete report.note;
85
+ }
86
+ return {
87
+ report,
88
+ ageSec,
89
+ bytes: stats.size,
90
+ savedAt: new Date(stats.mtimeMs).toISOString(),
91
+ };
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ }
97
+ async function writeReport(path, payload) {
98
+ await fs.mkdir(dirname(path), { recursive: true });
99
+ await fs.writeFile(path, payload, 'utf8');
100
+ return { bytes: Buffer.byteLength(payload), savedAt: new Date().toISOString() };
101
+ }
102
+ function buildReportMeta(params) {
103
+ return {
104
+ reportPath: params.reportPath ?? null,
105
+ cacheHit: params.cacheHit ?? false,
106
+ source: params.source,
107
+ ...(params.cacheAgeSec !== undefined ? { cacheAgeSec: params.cacheAgeSec } : {}),
108
+ ...(params.savedAt ? { savedAt: params.savedAt } : {}),
109
+ ...(params.reportBytes !== undefined ? { reportBytes: params.reportBytes } : {}),
110
+ };
111
+ }
4
112
  function formatCheck(check) {
5
113
  const icon = check.status === 'pass' ? '✓' : check.status === 'fail' ? '✗' : '⚠';
6
114
  let line = `${icon} [${check.status.toUpperCase()}] ${check.name}: ${check.message}`;
@@ -75,14 +183,46 @@ function generateQuickWins(report) {
75
183
  }
76
184
  async function seoAnalyze(args) {
77
185
  const url = String(args.url || '');
78
- const categories = args.categories;
186
+ const categories = normalizeStringArray(args.categories);
79
187
  if (!url) {
80
188
  return {
81
189
  content: [{ type: 'text', text: 'Error: url is required' }],
82
190
  isError: true,
83
191
  };
84
192
  }
193
+ const persistence = resolveReportPersistence(args, {
194
+ toolSlug: 'seo',
195
+ url,
196
+ cacheKey: {
197
+ url,
198
+ categories: categories ? [...categories].sort() : [],
199
+ },
200
+ });
85
201
  try {
202
+ if (persistence.enabled && persistence.cacheEnabled && !persistence.forceRefresh && persistence.reportPath) {
203
+ const cached = await readCachedReport(persistence.reportPath, persistence.cacheTtlSec);
204
+ if (cached) {
205
+ const reportMeta = buildReportMeta({
206
+ reportPath: persistence.reportPath,
207
+ cacheHit: true,
208
+ cacheAgeSec: cached.ageSec,
209
+ savedAt: cached.savedAt,
210
+ reportBytes: cached.bytes,
211
+ source: 'cached',
212
+ });
213
+ const output = {
214
+ ...cached.report,
215
+ reportMeta,
216
+ note: `Cache hit: report loaded from ${persistence.reportPath}`,
217
+ };
218
+ return {
219
+ content: [{
220
+ type: 'text',
221
+ text: JSON.stringify(output, null, 2),
222
+ }],
223
+ };
224
+ }
225
+ }
86
226
  const client = createClient({ timeout: 30000 });
87
227
  const response = await client.get(url);
88
228
  const html = await response.text();
@@ -135,10 +275,56 @@ async function seoAnalyze(args) {
135
275
  images: report.images,
136
276
  technical: report.technical,
137
277
  };
278
+ const reportPayload = JSON.stringify(output, null, 2);
279
+ let reportMeta = buildReportMeta({
280
+ reportPath: persistence.reportPath,
281
+ cacheHit: false,
282
+ source: 'fresh',
283
+ });
284
+ let note = persistence.enabled
285
+ ? `Report generated (not yet saved).`
286
+ : 'Report generated (not persisted). Set persist=true to save and enable caching.';
287
+ if (persistence.enabled && persistence.reportPath) {
288
+ try {
289
+ const saved = await writeReport(persistence.reportPath, reportPayload);
290
+ reportMeta = buildReportMeta({
291
+ reportPath: persistence.reportPath,
292
+ cacheHit: false,
293
+ savedAt: saved.savedAt,
294
+ reportBytes: saved.bytes,
295
+ source: 'fresh',
296
+ });
297
+ note = `Report saved to ${persistence.reportPath}`;
298
+ }
299
+ catch (error) {
300
+ const message = `Failed to save report to ${persistence.reportPath}: ${error.message}`;
301
+ const failedOutput = {
302
+ ...output,
303
+ reportMeta,
304
+ note: message,
305
+ };
306
+ return {
307
+ content: [{
308
+ type: 'text',
309
+ text: JSON.stringify(failedOutput, null, 2),
310
+ }],
311
+ isError: true,
312
+ };
313
+ }
314
+ }
315
+ else {
316
+ reportMeta = buildReportMeta({
317
+ reportPath: null,
318
+ cacheHit: false,
319
+ reportBytes: Buffer.byteLength(reportPayload),
320
+ savedAt: new Date().toISOString(),
321
+ source: 'fresh',
322
+ });
323
+ }
138
324
  return {
139
325
  content: [{
140
326
  type: 'text',
141
- text: JSON.stringify(output, null, 2),
327
+ text: JSON.stringify({ ...output, reportMeta, note }, null, 2),
142
328
  }],
143
329
  };
144
330
  }
@@ -165,6 +351,41 @@ async function seoSpiderCrawl(args) {
165
351
  };
166
352
  }
167
353
  try {
354
+ const persistence = resolveReportPersistence(args, {
355
+ toolSlug: 'seo-spider',
356
+ url,
357
+ cacheKey: {
358
+ url,
359
+ maxPages,
360
+ maxDepth,
361
+ concurrency,
362
+ transport,
363
+ },
364
+ });
365
+ if (persistence.enabled && persistence.cacheEnabled && !persistence.forceRefresh && persistence.reportPath) {
366
+ const cached = await readCachedReport(persistence.reportPath, persistence.cacheTtlSec);
367
+ if (cached) {
368
+ const reportMeta = buildReportMeta({
369
+ reportPath: persistence.reportPath,
370
+ cacheHit: true,
371
+ cacheAgeSec: cached.ageSec,
372
+ savedAt: cached.savedAt,
373
+ reportBytes: cached.bytes,
374
+ source: 'cached',
375
+ });
376
+ const output = {
377
+ ...cached.report,
378
+ reportMeta,
379
+ note: `Cache hit: report loaded from ${persistence.reportPath}`,
380
+ };
381
+ return {
382
+ content: [{
383
+ type: 'text',
384
+ text: JSON.stringify(output, null, 2),
385
+ }],
386
+ };
387
+ }
388
+ }
168
389
  const result = await seoSpider(url, {
169
390
  seo: true,
170
391
  maxPages,
@@ -226,10 +447,56 @@ async function seoSpiderCrawl(args) {
226
447
  if (recommendations.length > 0) {
227
448
  output.recommendations = recommendations;
228
449
  }
450
+ const reportPayload = JSON.stringify(output, null, 2);
451
+ let reportMeta = buildReportMeta({
452
+ reportPath: persistence.reportPath,
453
+ cacheHit: false,
454
+ source: 'fresh',
455
+ });
456
+ let note = persistence.enabled
457
+ ? 'Report generated (not yet saved).'
458
+ : 'Report generated (not persisted). Set persist=true to save and enable caching.';
459
+ if (persistence.enabled && persistence.reportPath) {
460
+ try {
461
+ const saved = await writeReport(persistence.reportPath, reportPayload);
462
+ reportMeta = buildReportMeta({
463
+ reportPath: persistence.reportPath,
464
+ cacheHit: false,
465
+ savedAt: saved.savedAt,
466
+ reportBytes: saved.bytes,
467
+ source: 'fresh',
468
+ });
469
+ note = `Report saved to ${persistence.reportPath}`;
470
+ }
471
+ catch (error) {
472
+ const message = `Failed to save report to ${persistence.reportPath}: ${error.message}`;
473
+ const failedOutput = {
474
+ ...output,
475
+ reportMeta,
476
+ note: message,
477
+ };
478
+ return {
479
+ content: [{
480
+ type: 'text',
481
+ text: JSON.stringify(failedOutput, null, 2),
482
+ }],
483
+ isError: true,
484
+ };
485
+ }
486
+ }
487
+ else {
488
+ reportMeta = buildReportMeta({
489
+ reportPath: null,
490
+ cacheHit: false,
491
+ reportBytes: Buffer.byteLength(reportPayload),
492
+ savedAt: new Date().toISOString(),
493
+ source: 'fresh',
494
+ });
495
+ }
229
496
  return {
230
497
  content: [{
231
498
  type: 'text',
232
- text: JSON.stringify(output, null, 2),
499
+ text: JSON.stringify({ ...output, reportMeta, note }, null, 2),
233
500
  }],
234
501
  };
235
502
  }
@@ -330,6 +597,7 @@ Returns:
330
597
  - Warnings and recommendations
331
598
  - OpenGraph/social meta analysis
332
599
  - Request timing breakdown
600
+ - Optional report persistence and caching for AI agent workflows
333
601
 
334
602
  Perfect for analyzing your localhost dev server or any public URL. Categories include: meta, content, links, images, technical, security, performance, mobile, accessibility, schema, structural, i18n, PWA, social, e-commerce, local SEO, Core Web Vitals, readability, crawlability, internal linking, and best practices.`,
335
603
  inputSchema: {
@@ -344,6 +612,30 @@ Perfect for analyzing your localhost dev server or any public URL. Categories in
344
612
  items: { type: 'string' },
345
613
  description: 'Filter by specific categories (e.g., ["meta", "security", "performance"]). Leave empty for all.',
346
614
  },
615
+ output: {
616
+ type: 'string',
617
+ description: 'Save report to explicit file path',
618
+ },
619
+ outputDir: {
620
+ type: 'string',
621
+ description: 'Save report to directory with auto-generated filename',
622
+ },
623
+ persist: {
624
+ type: 'boolean',
625
+ description: 'Save report to default temp path (os.tmpdir()/recker/seo)',
626
+ },
627
+ cache: {
628
+ type: 'boolean',
629
+ description: 'Reuse existing saved report if present and within TTL (default: true when persistence enabled)',
630
+ },
631
+ cacheTtlSec: {
632
+ type: 'number',
633
+ description: `Cache TTL in seconds (default: ${DEFAULT_SEO_CACHE_TTL_SEC})`,
634
+ },
635
+ forceRefresh: {
636
+ type: 'boolean',
637
+ description: 'Bypass cache and recompute even if a saved report exists',
638
+ },
347
639
  },
348
640
  required: ['url'],
349
641
  },
@@ -357,7 +649,7 @@ Detects site-wide issues:
357
649
  - Orphan pages (no internal links pointing to them)
358
650
  - Pages with low SEO scores
359
651
 
360
- Returns per-page scores and prioritized recommendations for improving overall site SEO. Great for auditing a full site before launch or finding issues across your dev environment.`,
652
+ Returns per-page scores and prioritized recommendations for improving overall site SEO. Great for auditing a full site before launch or finding issues across your dev environment. Supports optional report persistence and caching for AI agent workflows.`,
361
653
  inputSchema: {
362
654
  type: 'object',
363
655
  properties: {
@@ -386,6 +678,30 @@ Returns per-page scores and prioritized recommendations for improving overall si
386
678
  description: 'HTTP transport: auto (try undici, fallback to curl on WAF block), undici (fast), curl (curl-impersonate for protected sites)',
387
679
  default: 'auto',
388
680
  },
681
+ output: {
682
+ type: 'string',
683
+ description: 'Save report to explicit file path',
684
+ },
685
+ outputDir: {
686
+ type: 'string',
687
+ description: 'Save report to directory with auto-generated filename',
688
+ },
689
+ persist: {
690
+ type: 'boolean',
691
+ description: 'Save report to default temp path (os.tmpdir()/recker/seo)',
692
+ },
693
+ cache: {
694
+ type: 'boolean',
695
+ description: 'Reuse existing saved report if present and within TTL (default: true when persistence enabled)',
696
+ },
697
+ cacheTtlSec: {
698
+ type: 'number',
699
+ description: `Cache TTL in seconds (default: ${DEFAULT_SEO_CACHE_TTL_SEC})`,
700
+ },
701
+ forceRefresh: {
702
+ type: 'boolean',
703
+ description: 'Bypass cache and recompute even if a saved report exists',
704
+ },
389
705
  },
390
706
  required: ['url'],
391
707
  },
package/dist/version.js CHANGED
@@ -1,4 +1,4 @@
1
- const VERSION = '1.0.72';
1
+ const VERSION = '1.0.73';
2
2
  let _version = null;
3
3
  export async function getVersion() {
4
4
  if (_version)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recker",
3
- "version": "1.0.72",
3
+ "version": "1.0.73",
4
4
  "description": "Multi-Protocol SDK for the AI Era - HTTP, WebSocket, DNS, FTP, SFTP, Telnet, HLS unified with AI providers and MCP tools",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",