subto 2.0.2 → 3.0.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
@@ -1,209 +1,16 @@
1
- # Subto.One
1
+ # Subto CLI
2
2
 
3
- Comprehensive Website Analysis & AI Code Surgery - 100% Free Forever
3
+ Install:
4
4
 
5
- ## Features
6
-
7
- - **Deep Runtime Analysis**: Full Playwright-based crawling with JavaScript execution
8
- - **Network Interception**: Captures all requests, responses, and timing data
9
- - **Interaction Simulation**: Programmatically tests buttons, inputs, and interactive elements
10
- - **Lighthouse Integration**: Performance, accessibility, SEO, and best practices scoring
11
- - **SEO & Markup Validation**: W3C Validator, Google Mobile-Friendly Test integration
12
- - **Performance Testing**: Google PageSpeed, WebPageTest, GTmetrix integration
13
- - **Security Audit**: Mozilla Observatory, Security Headers, SSL Labs, Safe Browsing integration
14
- - **Malware Detection**: VirusTotal, Hybrid Analysis, URLScan.io support
15
- - **AI API Selection**: Ask AI to choose the best scanning API for your needs
16
- - **No-JS Differential**: Compares JS-enabled vs disabled behavior
17
- - **AI Code Surgeon**: Upload your code and get AI-powered fixes
18
-
19
- ## Quick Start
20
-
21
- ### Prerequisites
22
-
23
- - Node.js 18+
24
- - npm or yarn
25
-
26
- ### Installation
27
-
28
- ```bash
29
- # Install dependencies
30
- npm install
31
-
32
- # Install Playwright browsers
33
- npx playwright install chromium
34
-
35
- # Copy environment variables
36
- cp .env.example .env
37
- ```
38
-
39
- ### Configuration
40
-
41
- Edit `.env` and add your OpenRouter API key for AI features:
42
-
43
- ```
44
- OPENROUTER_API_KEY=your_key_here
45
- ```
46
-
47
- Get a free API key at [OpenRouter](https://openrouter.ai)
48
-
49
- ### Optional API Keys
50
-
51
- Add these to your `.env` for enhanced scanning capabilities (all have generous free tiers):
52
-
53
- ```
54
- # Performance & Speed (Scalable Free Options)
55
- GOOGLE_PAGESPEED_API_KEY=your_key # Google PageSpeed Insights
56
- WEBPAGETEST_API_KEY=your_key # WebPageTest (public instance available)
57
- GOOGLE_MOBILE_FRIENDLY_API_KEY=your_key # Google Mobile-Friendly Test
58
-
59
- # SEO & Markup Validation
60
- # W3C Markup Validator (no key needed) # HTML validation for SEO
61
-
62
- # Security & Malware (No/Low Limits)
63
- VIRUSTOTAL_API_KEY=your_key # VirusTotal (500 requests/day free)
64
- GOOGLE_SAFE_BROWSING_API_KEY=your_key # Google Safe Browsing
65
- URLSCAN_API_KEY=your_key # URLScan.io (free tier)
66
- HYBRID_ANALYSIS_API_KEY=your_key # Hybrid Analysis (free tier)
67
-
68
- # Always Free (No API Keys Needed)
69
- # Mozilla Observatory, Security Headers, SSL Labs
70
- ```
71
-
72
- All APIs have free tiers. See `.env.example` for details.
73
-
74
- ### Running
75
-
76
- ```bash
77
- # Development
78
- npm run dev
79
-
80
- # Production
81
- npm start
82
- ```
83
-
84
- Visit `http://localhost:3000`
85
-
86
- ## Architecture
87
-
88
- ```
89
- quantumreasoning/
90
- ├── public/ # Frontend assets
91
- │ ├── index.html # Single-page app
92
- │ ├── styles.css # Exact styling spec
93
- │ └── app.js # Frontend logic
94
- ├── server/
95
- │ ├── index.js # Express server + WebSocket
96
- │ └── modules/
97
- │ ├── scan-pipeline.js # 7-phase analysis engine
98
- │ ├── ai-analyzer.js # OpenRouter AI integration
99
- │ ├── file-manager.js # Upload/ZIP handling
100
- │ └── data-store.js # In-memory storage
101
- ├── package.json
102
- └── .env.example
103
5
  ```
104
-
105
- ## Scan Pipeline
106
-
107
- 1. **Fetch Initial HTML** - Loads page with Playwright
108
- 2. **Execute JavaScript Runtime** - Captures all JS files and builds AST
109
- 3. **Intercept Network Requests** - Records all network activity
110
- 4. **Simulate Interactions** - Hovers, clicks, types on interactive elements
111
- 5. **Run Lighthouse** - Google PageSpeed Insights API
112
- 6. **Audit Security** - Mozilla Observatory + OWASP checks
113
- 7. **No-JS Differential** - Compares behavior without JavaScript
114
-
115
- ## API Endpoints
116
-
117
- ### Scan
118
-
119
- ```
120
- POST /api/v1/scan
121
- Body: { "url": "https://subto.one" }
122
- Response: { "scanId": "uuid", "status": "started" }
123
- ```
124
-
125
- Notes on queuing and rate limiting:
126
-
127
- - Concurrency limit: the server allows up to `MAX_CONCURRENT_SCANS` (default 50) scans to run concurrently.
128
- - If the server is at capacity, API clients can opt into an automatic queue by sending the header `X-Accept-Queue: true` with the POST request. In that case the request will be accepted and queued; the response will be `202 Accepted` with JSON `{ scanId, status: 'queued', queuePosition }`.
129
- - If the client does not opt into queuing and the server is at capacity, the API will return `429 Too Many Requests` with a short message instructing the client to retry or set `X-Accept-Queue: true`.
130
- - The UI automatically sets `X-Accept-Queue: true` and displays `Queuing` plus the user's position (e.g., `You are #3 in queue`).
131
-
132
-
133
- ### Get Results
134
-
135
- ```
136
- GET /api/v1/scan/:scanId
137
- Response: Full scan data
6
+ npm install -g subto
138
7
  ```
139
8
 
140
- ### AI Analysis
9
+ Usage:
141
10
 
142
11
  ```
143
- POST /api/v1/ai/analyze
144
- Body: { "scanId": "uuid", "files": [...] }
145
- Response: { "summary": "...", "changes": [...] }
146
- ```
147
-
148
- ## File Upload
149
-
150
- ### Supported Types
151
- - JavaScript: `.js`, `.ts`, `.jsx`, `.tsx`
152
- - Styles: `.css`, `.scss`
153
- - Markup: `.html`, `.vue`, `.svelte`
154
- - Data: `.json`, `.env`, `.md`
155
-
156
- ### Limits
157
- - Single file: 50 MB max
158
- - Total upload: 250 MB max
159
- - File count: 5,000 files max
160
-
161
- ### Excluded Folders
162
- - `node_modules/`
163
- - `.git/`
164
- - `.next/`, `dist/`, `build/`
165
-
166
- ## AI Models (Free Tier)
167
-
168
- - **Default**: `deepseek/deepseek-r1:free`
169
- - **Code Analysis**: `qwen/qwen3-coder:free`
170
- - **Security**: `mistralai/devstral-small:free`
171
-
172
- ### Rate Limits
173
- - 5 seconds between requests
174
- - 50 requests per day
175
-
176
- ## Data Retention
177
-
178
- All scan data is automatically deleted after 24 hours. No user accounts required.
179
-
180
- ## Security
181
-
182
- - No binary file uploads
183
- - Server-side validation even if client-side passes
184
- - Streaming uploads to disk, not memory
185
- - Directory traversal prevention
186
- - MIME type validation
187
-
188
- ## License
189
-
190
- MIT
191
-
192
- ## Deployment (Docker + nginx reverse-proxy)
193
-
194
- This repo includes a simple production-ready scaffold under `deploy/` that runs the Node app behind an nginx reverse-proxy which terminates TLS and forwards requests (including WebSocket upgrades) to the app.
195
-
196
- Quick local test (self-signed cert):
197
-
198
- ```bash
199
- cd deploy
200
- ./mk-self-signed.sh # creates deploy/certs/fullchain.pem and privkey.pem
201
- docker compose up --build
12
+ subto login # store API key
13
+ subto scan <url> # request a scan
202
14
  ```
203
15
 
204
- Production notes:
205
- - Use Node.js 22+ in your runtime (required by Lighthouse v13).
206
- - Terminate TLS at your load balancer (Cloud Load Balancer, nginx, etc.) and forward plain HTTP to the Node container.
207
- - Ensure WebSocket upgrades are forwarded by the proxy.
208
- - Provide secrets via environment variables or your hosting secret manager (do NOT commit secrets).
209
-
16
+ This package is a production CLI; it intentionally omits development instructions.
package/bin/subto.js ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ /* CLI executable that runs the package's `run` function */
3
+ try {
4
+ const mod = require('../index.js');
5
+ if (mod && typeof mod.run === 'function') {
6
+ // pass process.argv (node, script, ...user args)
7
+ mod.run(process.argv).catch(err => {
8
+ console.error('Error:', err && err.message ? err.message : String(err));
9
+ process.exit(1);
10
+ });
11
+ } else {
12
+ console.error('subto: CLI entrypoint not found.');
13
+ process.exit(1);
14
+ }
15
+ } catch (err) {
16
+ console.error('subto: failed to start:', err && err.message ? err.message : String(err));
17
+ process.exit(1);
18
+ }
@@ -0,0 +1,108 @@
1
+ # Subto CLI
2
+
3
+ This folder contains the Subto command-line client which is a thin wrapper around the Subto.One API.
4
+
5
+ Installation
6
+
7
+ - Global (recommended via npm):
8
+
9
+ ```bash
10
+ npm install -g subto-cli
11
+ ```
12
+
13
+ - Or run locally from the `cli` folder during development:
14
+
15
+ ```bash
16
+ cd cli
17
+ npm install
18
+ node bin/subto.js --help
19
+ ```
20
+
21
+ Local testing (before publishing)
22
+
23
+ - Link the package locally and test the installed command:
24
+
25
+ ```bash
26
+ cd cli
27
+ npm link
28
+ subto --help
29
+ ```
30
+
31
+ Commands
32
+
33
+ - `subto login`
34
+ - Prompts for your API key and stores it in `~/.subto/config.json`.
35
+
36
+ - `subto scan <url>`
37
+ - Requests a scan for `<url>` from the Subto API. Example body:
38
+
39
+ ```json
40
+ {
41
+ "url": "https://example.com",
42
+ "source": "cli",
43
+ "client": { "name": "subto-cli", "version": "3.0.0" }
44
+ }
45
+ ```
46
+
47
+ Notes
48
+
49
+ - The CLI calls the remote API and respects server-side rate limiting. If a rate limit is encountered the CLI prints a friendly message like:
50
+
51
+ ```
52
+ Rate limit reached. Try again in X seconds.
53
+ ```
54
+
55
+ - The API key is stored at `~/.subto/config.json` with restrictive permissions (0600).
56
+ - The CLI never logs or prints API keys or other secrets.
57
+ - The CLI never performs website scanning locally — it only calls the remote API.
58
+
59
+ - `--wait` flag: block and poll until the scan completes
60
+
61
+ Example:
62
+
63
+ ```bash
64
+ subto scan https://example.com --wait
65
+ ```
66
+
67
+ This will poll the server every 5 seconds (respecting `Retry-After`) and display queue position, progress percentage and stages as provided by the API, then print the full JSON result when finished.
68
+
69
+ Default behavior
70
+
71
+ - By default the CLI will automatically poll when the server immediately returns a queued/started response. If you prefer to return immediately, use `--no-wait`.
72
+
73
+ Example (do not wait):
74
+
75
+ ```bash
76
+ subto scan https://example.com --no-wait
77
+ ```
78
+
79
+ Publishing the CLI
80
+
81
+ Exact steps to publish (do not run automatically):
82
+
83
+ ```bash
84
+ npm login
85
+ npm publish
86
+ ```
87
+
88
+ Pre-publish check
89
+
90
+ The package runs a `prepublishOnly` script which verifies `bin/subto.js` exists and that the `bin` mapping is correct. The script also attempts to set the executable bit on the entry file.
91
+
92
+ API docs section (link)
93
+
94
+ See the bundled API docs excerpt for the Subto CLI in `./docs/cli-section.md`.
95
+
96
+ Download
97
+
98
+ After publishing or packing, a distributable tarball will be available under `./dist/` e.g.:
99
+
100
+ ```
101
+ ./dist/subto-3.0.0.tgz
102
+ ```
103
+
104
+ You can download that file directly and install locally with:
105
+
106
+ ```bash
107
+ npm install -g ./dist/subto-3.0.0.tgz
108
+ ```
@@ -0,0 +1,27 @@
1
+ ## Subto CLI
2
+
3
+ Installation
4
+
5
+ - Install via npm:
6
+
7
+ ```
8
+ npm install -g subto-cli
9
+ ```
10
+
11
+ Commands
12
+
13
+ - `subto login`
14
+ - Prompts for your API key and stores it in `~/.subto/config.json`.
15
+
16
+ - `subto scan <url>`
17
+ - Requests a scan for `<url>` from the Subto API.
18
+
19
+ Notes
20
+
21
+ - The CLI calls the remote API and respects server-side rate limiting. If a rate limit is encountered the CLI prints a friendly message like:
22
+
23
+ ```
24
+ Rate limit reached. Try again in X seconds.
25
+ ```
26
+
27
+ - For more information and installation help see `/cli/README.md` in this repository.
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env node
2
1
  /* Subto CLI implementation */
3
2
  const { Command } = require('commander');
4
3
  const fs = require('fs').promises;
@@ -12,83 +11,7 @@ const chalk = (_chalk && _chalk.default) ? _chalk.default : _chalk;
12
11
  const CONFIG_DIR = path.join(os.homedir(), '.subto');
13
12
  const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
14
13
  const DEFAULT_API_BASE = 'https://subto.one';
15
- const CLIENT_META = { name: 'subto-cli', version: '0.1.6' };
16
-
17
- // Simple terminal UI helper (no extra deps) for polished spinner/progress
18
- class TerminalUI {
19
- constructor() {
20
- this.frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
21
- this.i = 0;
22
- this.timer = null;
23
- this.visible = false;
24
- this.lastRenderLines = 0;
25
- }
26
-
27
- start(message) {
28
- if (!process.stdout.isTTY) { console.log(message); return; }
29
- this.visible = true;
30
- this.message = message || '';
31
- this._render();
32
- this.timer = setInterval(() => { this.i = (this.i + 1) % this.frames.length; this._render(); }, 80);
33
- }
34
-
35
- update(state) {
36
- this.state = Object.assign({}, this.state, state);
37
- if (this.visible) this._render();
38
- }
39
-
40
- stop() {
41
- if (this.timer) clearInterval(this.timer);
42
- this.timer = null;
43
- if (this.visible && process.stdout.isTTY) {
44
- // clear previous render
45
- for (let j = 0; j < this.lastRenderLines; j++) {
46
- readline.clearLine(process.stdout, 0);
47
- readline.cursorTo(process.stdout, 0);
48
- if (j < this.lastRenderLines - 1) process.stdout.write('\u001b[1A');
49
- }
50
- this.visible = false;
51
- }
52
- }
53
-
54
- _render() {
55
- const out = [];
56
- const spin = this.frames[this.i];
57
- const title = this.message || '';
58
- const status = this.state && this.state.status ? String(this.state.status) : '';
59
- const stage = this.state && this.state.stage ? String(this.state.stage) : '';
60
- const qpos = this.state && (this.state.queuePosition !== undefined) ? `Queue: ${this.state.queuePosition}` : '';
61
- const progress = (this.state && (this.state.progress !== undefined)) ? Number(this.state.progress) : null;
62
-
63
- // Build a compact progress bar
64
- let bar = '';
65
- if (progress !== null && !Number.isNaN(progress)) {
66
- const pct = Math.max(0, Math.min(100, Math.round(progress)));
67
- const width = 30;
68
- const filled = Math.round((pct / 100) * width);
69
- bar = '[' + chalk.cyan('='.repeat(filled)) + chalk.dim('-'.repeat(width - filled)) + `] ${pct}%`;
70
- }
71
-
72
- out.push(`${chalk.bold.cyan(spin)} ${chalk.bold(title)}`);
73
- if (status) out.push(`${chalk.green('Status:')} ${status}`);
74
- if (stage) out.push(`${chalk.dim('Stage:')} ${stage}`);
75
- if (qpos) out.push(chalk.magenta(qpos));
76
- if (bar) out.push(bar);
77
-
78
- // Erase previous
79
- if (process.stdout.isTTY) {
80
- for (let j = 0; j < this.lastRenderLines; j++) {
81
- readline.clearLine(process.stdout, 0);
82
- readline.cursorTo(process.stdout, 0);
83
- process.stdout.write('\u001b[1A');
84
- }
85
- }
86
-
87
- const payload = out.join('\n') + '\n';
88
- process.stdout.write(payload);
89
- this.lastRenderLines = payload.split('\n').length;
90
- }
91
- }
14
+ const CLIENT_META = { name: 'subto-cli', version: '3.0.0' };
92
15
 
93
16
  function configFilePath() { return CONFIG_PATH; }
94
17
 
@@ -148,7 +71,7 @@ async function postScan(url, apiKey) {
148
71
  const body = { url, source: 'cli', client: CLIENT_META };
149
72
  const fetchFn = global.fetch;
150
73
  if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
151
- const res = await fetchFn(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': `Bearer ${apiKey}`, 'User-Agent': `${CLIENT_META.name}/${CLIENT_META.version}` }, body: JSON.stringify(body) });
74
+ const res = await fetchFn(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, 'User-Agent': `${CLIENT_META.name}/${CLIENT_META.version}` }, body: JSON.stringify(body) });
152
75
  const text = await res.text();
153
76
  let data = null; try { data = text ? JSON.parse(text) : null; } catch (e) { data = text; }
154
77
  return { status: res.status, headers: res.headers, body: data };
@@ -156,26 +79,19 @@ async function postScan(url, apiKey) {
156
79
 
157
80
  function printScanSummary(obj) {
158
81
  if (!obj || typeof obj !== 'object') { console.log(obj); return; }
159
- const rows = [];
160
- if (obj.id) rows.push(['ID', obj.id]);
161
- if (obj.url) rows.push(['URL', obj.url]);
162
- if (obj.status) rows.push(['Status', obj.status]);
163
- if (obj.createdAt) rows.push(['Created', obj.createdAt]);
164
- if (obj.summary) rows.push(['Summary', obj.summary]);
165
- const extra = Object.keys(obj).filter(k => !['id','url','status','createdAt','summary'].includes(k));
166
- if (extra.length) rows.push(['Additional', extra.join(', ')]);
167
-
168
- const labelWidth = Math.max(...rows.map(r => r[0].length), 0);
169
- console.log(chalk.bgBlue.black(' SCAN ') + ' ' + chalk.bold('Summary'));
170
- for (const [label, value] of rows) {
171
- const padded = label.padEnd(labelWidth);
172
- console.log(chalk.green(` ${padded}:`) + ' ' + chalk.white(String(value)));
173
- }
82
+ console.log(chalk.bold('Scan result:'));
83
+ if (obj.id) console.log(chalk.green(' ID:') + ' ' + obj.id);
84
+ if (obj.url) console.log(chalk.green(' URL:') + ' ' + obj.url);
85
+ if (obj.status) console.log(chalk.green(' Status:') + ' ' + obj.status);
86
+ if (obj.createdAt) console.log(chalk.green(' Created:') + ' ' + obj.createdAt);
87
+ if (obj.summary) console.log(chalk.green(' Summary:') + ' ' + obj.summary);
88
+ const keys = Object.keys(obj).filter(k => !['id','url','status','createdAt','summary'].includes(k));
89
+ if (keys.length) console.log(chalk.dim(' Additional keys: ' + keys.join(', ')));
174
90
  }
175
91
 
176
92
  async function run(argv) {
177
93
  const program = new Command();
178
- program.name('subto').description('Subto CLI — wrapper around Subto.One API').version(CLIENT_META.version || '0.1.0');
94
+ program.name('subto').description('Subto CLI — wrapper around Subto.One API').version(CLIENT_META.version || '3.0.0');
179
95
 
180
96
  program.command('login').description('Store your API key in ~/.subto/config.json').action(async () => {
181
97
  try {
@@ -191,7 +107,6 @@ async function run(argv) {
191
107
  .command('scan <url>')
192
108
  .description('Request a scan for <url> via the Subto API')
193
109
  .option('--json', 'Output raw JSON')
194
- .option('--full', 'When available, fetch offloaded large results (GCS) and print them')
195
110
  .option('--wait', 'Poll for completion and show progress')
196
111
  .option('--no-wait', 'Do not poll; return immediately')
197
112
  .action(async (url, opts) => {
@@ -222,16 +137,6 @@ async function run(argv) {
222
137
  return;
223
138
  }
224
139
 
225
- // If server already returned full structured results (not HTML), show them
226
- if (!shouldPoll && resp.body && typeof resp.body === 'object') {
227
- // Heuristic: if response contains detailed scan fields, print full JSON
228
- const hasDetailed = resp.body.results || resp.body.audits || resp.body.performance || resp.body.overview || resp.body.summary;
229
- if (hasDetailed) {
230
- console.log(JSON.stringify(resp.body, null, 2));
231
- return;
232
- }
233
- }
234
-
235
140
  // If we should poll, poll the scan resource until completion and print progress.
236
141
  if (shouldPoll) {
237
142
  const scanId = (resp.body && (resp.body.scanId || resp.body.id || resp.body.scan_id));
@@ -246,8 +151,7 @@ async function run(argv) {
246
151
 
247
152
  const sleep = ms => new Promise(r => setTimeout(r, ms));
248
153
  let interval = 5000;
249
- const ui = new TerminalUI();
250
- ui.start('Queued scan. Polling for progress...');
154
+ console.log(chalk.blue('Queued scan. Polling for progress...'));
251
155
 
252
156
  let recheckedAfterTerminal = false;
253
157
  while (true) {
@@ -266,14 +170,14 @@ async function run(argv) {
266
170
  try { data = await r.json(); } catch (e) { data = null; }
267
171
 
268
172
  if (data) {
269
- ui.update({
270
- queuePosition: data.queuePosition,
271
- progress: data.progress !== undefined ? data.progress : (data.lastProgress || data.progress),
272
- status: data.status || '',
273
- stage: data.stage || ''
274
- });
173
+ if (data.queuePosition !== undefined) console.log(chalk.cyan('Queue position:'), data.queuePosition);
174
+ if (data.progress !== undefined) console.log(chalk.cyan('Progress:'), String(data.progress) + '%');
175
+ const rawStatus = data.status !== undefined && data.status !== null ? String(data.status) : '';
176
+ const displayStatus = rawStatus.trim();
177
+ if (displayStatus) console.log(chalk.cyan('Status:'), displayStatus);
178
+ if (data.stage) console.log(chalk.dim('Stage:'), data.stage);
275
179
  } else {
276
- ui.update({ status: 'waiting for server...' });
180
+ console.log(chalk.dim('Waiting for server response...'));
277
181
  }
278
182
 
279
183
  const statusStr = data && data.status ? String(data.status).toLowerCase().trim() : '';
@@ -287,85 +191,13 @@ async function run(argv) {
287
191
  const hasPayload = payloadKeys.length > 0;
288
192
  if (!hasPayload && !recheckedAfterTerminal) {
289
193
  recheckedAfterTerminal = true;
290
- ui.update({ status: 'finalizing results...' });
194
+ console.log(chalk.yellow('Status is terminal but results not yet available — rechecking shortly...'));
291
195
  await new Promise(r => setTimeout(r, 2000));
292
196
  continue; // loop will fetch again
293
197
  }
294
- // render final state then stop UI
295
- ui.update({ status: 'completed', progress: 100 });
296
- ui.stop();
297
-
298
- console.log('\n' + chalk.bgGreen.black(' DONE ') + ' ' + chalk.bold('Scan finished. Full results:') + '\n');
299
-
300
- // If user asked for full and results were offloaded to GCS, try fetching that
301
- if (opts.full && data && (data.resultsGcsPath || (data.results && data.results.resultsGcsPath))) {
302
- try {
303
- const gcsPath = data.resultsGcsPath || (data.results && data.results.resultsGcsPath);
304
- const publicUrl = (gcsPath || '').replace(/^gs:\/\//, 'https://storage.googleapis.com/');
305
- console.log(chalk.cyan('Fetching offloaded results from:'), publicUrl);
306
- const r2 = await fetchFn(publicUrl, { headers: { 'User-Agent': `${CLIENT_META.name}/${CLIENT_META.version}` } });
307
- if (r2.ok) {
308
- const txt = await r2.text();
309
- try { const parsed = JSON.parse(txt); console.log(JSON.stringify(parsed, null, 2)); } catch (e) { console.log(txt); }
310
- return;
311
- } else {
312
- console.log(chalk.yellow('Could not fetch offloaded results; status:'), r2.status);
313
- }
314
- } catch (e) {
315
- console.log(chalk.yellow('Failed to fetch offloaded results:'), e && e.message ? e.message : e);
316
- }
317
- }
318
-
319
- // Print video URL if present (prefer a canonical URL)
320
- try {
321
- const vid = (data && data.results && data.results.videoUrl) || data.videoUrl || (data && data.video);
322
- if (vid && typeof vid === 'string' && vid.startsWith('http')) {
323
- console.log(chalk.cyan('Video URL:'), vid);
324
- }
325
- } catch (e) {}
326
-
327
- // Sanitize large HTML blobs from output and print a concise summary.
328
- function sanitize(obj) {
329
- try {
330
- const cloned = JSON.parse(JSON.stringify(obj));
331
- const htmlKeys = ['html','mainHtml','pageHtml','fullHtml','renderedHtml','fetchedHtml','documentHtml','bodyHtml','responseBody'];
332
- const walk = (o) => {
333
- if (!o || typeof o !== 'object') return;
334
- for (const k of Object.keys(o)) {
335
- const v = o[k];
336
- if (typeof v === 'string') {
337
- const lowered = k.toLowerCase();
338
- if (htmlKeys.includes(lowered) || (v.length > 1024 && /<\/?html|<!doctype html/i.test(v))) {
339
- o[k] = `-- omitted HTML (length: ${v.length}) --`;
340
- } else if (v.length > 2000) {
341
- o[k] = v.slice(0, 2000) + `... (truncated, total ${v.length} chars)`;
342
- }
343
- } else if (typeof v === 'object') {
344
- walk(v);
345
- }
346
- }
347
- };
348
- walk(cloned);
349
- return cloned;
350
- } catch (e) { return obj; }
351
- }
352
-
353
- // Attempt to print a short human-friendly summary if available
354
- try {
355
- const summaryParts = [];
356
- const results = data && (data.results || data); // support both shapes
357
- if (results.performance || results.perf || results.performanceScore) {
358
- const p = results.performance || results.perf || results.performanceScore;
359
- summaryParts.push(`Performance: ${p}`);
360
- }
361
- if (results.accessibility) summaryParts.push(`Accessibility: ${results.accessibility}`);
362
- if (results.seo) summaryParts.push(`SEO: ${results.seo}`);
363
- if (results.bestPractices || results.best_practices) summaryParts.push(`Best Practices: ${results.bestPractices || results.best_practices}`);
364
- if (results.security || results.securityGrade) summaryParts.push(`Security: ${results.security || results.securityGrade}`);
365
- if (summaryParts.length) console.log(chalk.bold('Summary:'), summaryParts.join(' • '));
366
- } catch (e) {}
367
198
 
368
- console.log(JSON.stringify(sanitize(data), null, 2));
199
+ console.log(chalk.green('Scan finished. Full results:'));
200
+ console.log(JSON.stringify(data, null, 2));
369
201
  return;
370
202
  }
371
203