webpeel 0.20.4 → 0.20.5

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.
@@ -13,6 +13,14 @@ import { parseActions, formatError, fetchViaApi, outputResult, writeStdout, buil
13
13
  // ─── runFetch ─────────────────────────────────────────────────────────────────
14
14
  // Main fetch handler — shared with the `pipe` and `ask` subcommands
15
15
  export async function runFetch(url, options) {
16
+ // --content-only: override all output flags — we just want raw content
17
+ if (options.contentOnly) {
18
+ options.silent = true;
19
+ // Disable json/text/html — we output content directly
20
+ options.json = false;
21
+ options.html = false;
22
+ options.text = false;
23
+ }
16
24
  // Handle --format flag: maps to existing boolean flags
17
25
  if (options.format) {
18
26
  const fmt = options.format.toLowerCase();
@@ -30,9 +38,10 @@ export async function runFetch(url, options) {
30
38
  }
31
39
  // Smart defaults: when piped (not a TTY), default to silent JSON + budget
32
40
  // BUT respect explicit --format flag (user chose the output format)
41
+ // AND respect --content-only (raw content output, no JSON wrapper)
33
42
  const isPiped = !process.stdout.isTTY;
34
43
  const hasExplicitFormat = options.format && ['text', 'html', 'markdown', 'md'].includes(options.format.toLowerCase());
35
- if (isPiped && !options.html && !options.text && !hasExplicitFormat) {
44
+ if (isPiped && !options.html && !options.text && !hasExplicitFormat && !options.contentOnly) {
36
45
  if (!options.json)
37
46
  options.json = true;
38
47
  if (!options.silent)
@@ -284,11 +293,38 @@ export async function runFetch(url, options) {
284
293
  cachedResult.extracted = extractedCached;
285
294
  }
286
295
  }
287
- await outputResult(cachedResult, options, { cached: true });
296
+ if (options.contentOnly) {
297
+ await writeStdout(cachedResult.content + '\n');
298
+ }
299
+ else {
300
+ await outputResult(cachedResult, options, { cached: true });
301
+ }
288
302
  process.exit(0);
289
303
  }
290
304
  }
291
- const spinner = options.silent ? null : ora('Fetching...').start();
305
+ // --progress: show escalation steps on stderr (overrides spinner)
306
+ let progressInterval;
307
+ const progressStart = Date.now();
308
+ if (options.progress) {
309
+ process.stderr.write(`[simple] Fetching ${url}...\n`);
310
+ // Show escalation hints based on elapsed time (best-effort approximations)
311
+ const progressSteps = [
312
+ { afterMs: 2500, message: '[simple] Waiting for response...' },
313
+ { afterMs: 6000, message: '[browser] Simple too slow — escalating to browser render...' },
314
+ { afterMs: 12000, message: '[browser] Rendering with Chromium...' },
315
+ { afterMs: 20000, message: '[stealth] Escalating to stealth mode...' },
316
+ ];
317
+ let stepIdx = 0;
318
+ progressInterval = setInterval(() => {
319
+ const elapsed = Date.now() - progressStart;
320
+ while (stepIdx < progressSteps.length && elapsed >= progressSteps[stepIdx].afterMs) {
321
+ process.stderr.write(`${progressSteps[stepIdx].message}\n`);
322
+ stepIdx++;
323
+ }
324
+ }, 500);
325
+ }
326
+ // Suppress spinner when --progress is active (progress lines replace it)
327
+ const spinner = (options.silent || options.progress) ? null : ora('Fetching...').start();
292
328
  try {
293
329
  // Validate options
294
330
  if (options.wait && (options.wait < 0 || options.wait > 60000)) {
@@ -528,7 +564,22 @@ export async function runFetch(url, options) {
528
564
  if (resolvedProfileName) {
529
565
  touchProfile(resolvedProfileName);
530
566
  }
531
- if (spinner) {
567
+ // Stop progress interval and show final result
568
+ if (progressInterval) {
569
+ clearInterval(progressInterval);
570
+ progressInterval = undefined;
571
+ }
572
+ if (options.progress) {
573
+ const method = result.method || 'simple';
574
+ const elapsedSec = ((result.elapsed || (Date.now() - progressStart)) / 1000).toFixed(1);
575
+ const tokenCount = (result.tokens || 0).toLocaleString();
576
+ // Show escalation arrow if browser/stealth was needed
577
+ if (method !== 'simple') {
578
+ process.stderr.write(`[simple] → [${method}] escalated\n`);
579
+ }
580
+ process.stderr.write(`[${method}] Done — ${tokenCount} tokens in ${elapsedSec}s\n`);
581
+ }
582
+ else if (spinner) {
532
583
  const domainTag = result.domainData
533
584
  ? ` [${result.domainData.domain}:${result.domainData.type}]`
534
585
  : '';
@@ -866,11 +917,27 @@ export async function runFetch(url, options) {
866
917
  result.extracted = extracted;
867
918
  }
868
919
  }
869
- // Output results (default path)
870
- await outputResult(result, options, {
871
- cached: false,
872
- truncated: contentTruncated || undefined,
873
- });
920
+ // --content-only: output raw content only, no wrapper
921
+ if (options.contentOnly) {
922
+ await writeStdout(result.content + '\n');
923
+ }
924
+ else {
925
+ // Output results (default path)
926
+ await outputResult(result, options, {
927
+ cached: false,
928
+ truncated: contentTruncated || undefined,
929
+ });
930
+ // Token savings display (our unique selling point)
931
+ if (!options.json && !options.silent && result.tokenSavingsPercent) {
932
+ const savings = result.tokenSavingsPercent;
933
+ const raw = result.rawTokenEstimate;
934
+ const optimized = result.tokens || 0;
935
+ if (savings > 0) {
936
+ const rawStr = raw ? `${raw.toLocaleString()}→${optimized.toLocaleString()} tokens` : `${optimized.toLocaleString()} tokens`;
937
+ process.stderr.write(`\x1b[32m💰 Token savings: ${savings}% smaller than raw HTML (${rawStr})\x1b[0m\n`);
938
+ }
939
+ }
940
+ }
874
941
  }
875
942
  // Clean up and exit
876
943
  await cleanup();
@@ -976,6 +1043,8 @@ export function registerFetchCommands(program) {
976
1043
  .option('--wait-selector <css>', 'Wait for CSS selector before extracting (auto-enables --render)')
977
1044
  .option('--block-resources <types>', 'Block resource types, comma-separated: image,stylesheet,font,media,script (auto-enables --render)')
978
1045
  .option('--format <type>', 'Output format: markdown (default), text, html, json')
1046
+ .option('--content-only', 'Output only the raw content field (no metadata, no JSON wrapper) — ideal for piping to LLMs')
1047
+ .option('--progress', 'Show engine escalation steps (simple → browser → stealth) with timing')
979
1048
  .action(async (url, options) => {
980
1049
  await runFetch(url, options);
981
1050
  });
@@ -23,6 +23,7 @@ export function registerSearchCommands(program) {
23
23
  .option('--budget <n>', 'Token budget for site-search result content', parseInt)
24
24
  .option('-s, --silent', 'Silent mode')
25
25
  .option('--proxy <url>', 'Proxy URL for requests (http://host:port, socks5://user:pass@host:port)')
26
+ .option('--fetch', 'Also fetch and include content from each result URL')
26
27
  .option('--agent', 'Agent mode: sets --json, --silent, and --budget 4000 (override with --budget N)')
27
28
  .action(async (query, options) => {
28
29
  // --agent sets sensible defaults for AI agents; explicit flags override
@@ -178,9 +179,61 @@ export function registerSearchCommands(program) {
178
179
  const searchData = await searchRes.json();
179
180
  // API returns { success: true, data: { web: [...] } } or { results: [...] }
180
181
  let results = searchData.data?.web || searchData.data?.results || searchData.results || [];
182
+ // Client-side ad filtering: remove DuckDuckGo ads that slip through the server
183
+ results = results.filter(r => {
184
+ // Filter DDG-internal URLs
185
+ try {
186
+ const parsed = new URL(r.url);
187
+ if (parsed.hostname === 'duckduckgo.com')
188
+ return false;
189
+ if (parsed.searchParams.has('ad_domain') ||
190
+ parsed.searchParams.has('ad_provider') ||
191
+ parsed.searchParams.has('ad_type'))
192
+ return false;
193
+ }
194
+ catch {
195
+ return false;
196
+ }
197
+ // Filter ad snippets
198
+ if (r.snippet && (r.snippet.includes('Ad ·') ||
199
+ r.snippet.includes('Ad Viewing ads is privacy protected by DuckDuckGo') ||
200
+ r.snippet.toLowerCase().startsWith('ad ·')))
201
+ return false;
202
+ return true;
203
+ });
181
204
  if (spinner) {
182
205
  spinner.succeed(`Found ${results.length} results`);
183
206
  }
207
+ // --fetch: fetch content from each result
208
+ if (options.fetch && results.length > 0) {
209
+ const fetchCfg = loadConfig();
210
+ const fetchApiKey = fetchCfg.apiKey || process.env.WEBPEEL_API_KEY;
211
+ const fetchApiUrl = process.env.WEBPEEL_API_URL || 'https://api.webpeel.dev';
212
+ if (fetchApiKey) {
213
+ const fetchSpinner = isSilent ? null : ora(`Fetching content from ${results.length} results...`).start();
214
+ await Promise.all(results.map(async (result) => {
215
+ try {
216
+ const fetchParams = new URLSearchParams({ url: result.url });
217
+ if (options.budget)
218
+ fetchParams.set('budget', String(options.budget || 2000));
219
+ const fetchRes = await fetch(`${fetchApiUrl}/v1/fetch?${fetchParams}`, {
220
+ headers: { Authorization: `Bearer ${fetchApiKey}` },
221
+ signal: AbortSignal.timeout(20000),
222
+ });
223
+ if (fetchRes.ok) {
224
+ const fetchData = await fetchRes.json();
225
+ result.content = fetchData.content || fetchData.data?.content || '';
226
+ }
227
+ }
228
+ catch { /* skip on error */ }
229
+ }));
230
+ if (fetchSpinner)
231
+ fetchSpinner.succeed('Content fetched');
232
+ }
233
+ else if (!isSilent) {
234
+ console.error('Warning: --fetch requires API key (run: webpeel auth <key>)');
235
+ }
236
+ }
184
237
  // Show usage footer for free/anonymous users
185
238
  if (usageCheck.usageInfo && !isSilent) {
186
239
  showUsageFooter(usageCheck.usageInfo, usageCheck.isAnonymous || false, false);
@@ -196,10 +249,24 @@ export function registerSearchCommands(program) {
196
249
  await writeStdout(jsonStr + '\n');
197
250
  }
198
251
  else {
199
- for (const result of results) {
200
- console.log(`\n${result.title}`);
201
- console.log(result.url);
202
- console.log(result.snippet);
252
+ // Human-readable numbered results
253
+ if (results.length === 0) {
254
+ await writeStdout('No results found.\n');
255
+ }
256
+ else {
257
+ await writeStdout(`\n`);
258
+ for (const [i, result] of results.entries()) {
259
+ await writeStdout(`${i + 1}. ${result.title}\n`);
260
+ await writeStdout(` ${result.url}\n`);
261
+ if (result.snippet) {
262
+ await writeStdout(` ${result.snippet}\n`);
263
+ }
264
+ if (result.content) {
265
+ const preview = result.content.slice(0, 500);
266
+ await writeStdout(`\n --- Content ---\n${preview}${result.content.length > 500 ? '\n [...]' : ''}\n`);
267
+ }
268
+ await writeStdout('\n');
269
+ }
203
270
  }
204
271
  }
205
272
  process.exit(0);
package/dist/cli/utils.js CHANGED
@@ -508,13 +508,11 @@ export async function outputResult(result, options, extra = {}) {
508
508
  // Default: full output
509
509
  if (options.json) {
510
510
  // Build clean JSON output with guaranteed top-level fields
511
+ // Note: elapsed/method/tokens are placed at the END so `tail -3` shows perf metrics
511
512
  const output = {
512
513
  url: result.url,
513
514
  title: result.metadata?.title || result.title || null,
514
- tokens: result.tokens || 0,
515
515
  fetchedAt: new Date().toISOString(),
516
- method: result.method || 'simple',
517
- elapsed: result.elapsed,
518
516
  content: result.content,
519
517
  };
520
518
  // Add optional fields only if present (filter out undefined/null values from metadata)
@@ -529,6 +527,10 @@ export async function outputResult(result, options, extra = {}) {
529
527
  }
530
528
  if (result.links?.length)
531
529
  output.links = result.links;
530
+ if (result.tokenSavingsPercent !== undefined)
531
+ output.tokenSavingsPercent = result.tokenSavingsPercent;
532
+ if (result.rawTokenEstimate !== undefined)
533
+ output.rawTokenEstimate = result.rawTokenEstimate;
532
534
  if (result.images?.length)
533
535
  output.images = result.images;
534
536
  if (result.structured)
@@ -562,6 +564,10 @@ export async function outputResult(result, options, extra = {}) {
562
564
  if (extra.totalAvailable !== undefined)
563
565
  output.totalAvailable = extra.totalAvailable;
564
566
  output._meta = { version: cliVersion, method: result.method || 'simple', timing: result.timing, serverMarkdown: result.serverMarkdown || false };
567
+ // Perf metrics at the end — `tail -3` shows: elapsed | method | tokens
568
+ output.elapsed = result.elapsed;
569
+ output.method = result.method || 'simple';
570
+ output.tokens = result.tokens || 0;
565
571
  await writeStdout(JSON.stringify(output, null, 2) + '\n');
566
572
  }
567
573
  else {
@@ -586,10 +592,11 @@ export async function outputResult(result, options, extra = {}) {
586
592
  }
587
593
  // Stream content immediately to stdout — consumer gets it without waiting
588
594
  await writeStdout(result.content + '\n');
589
- // Append timing summary to stderr so it doesn't pollute piped content
590
- if (!options.silent) {
595
+ // Append timing summary to stderr (always doesn't pollute stdout pipe)
596
+ {
591
597
  const totalMs = result.timing?.total ?? result.elapsed;
592
- process.stderr.write(`\n--- ${result.tokens} tokens · ${totalMs}ms ---\n`);
598
+ const method = result.method || 'simple';
599
+ process.stderr.write(`\n--- ${totalMs}ms | ${method} | ${result.tokens} tokens ---\n`);
593
600
  }
594
601
  }
595
602
  }
@@ -83,7 +83,7 @@ export declare const providerStats: ProviderStatsTracker;
83
83
  export declare class StealthSearchProvider implements SearchProvider {
84
84
  readonly id: SearchProviderId;
85
85
  readonly requiresApiKey = false;
86
- /** Validate and normalize a URL; returns null if invalid/non-http */
86
+ /** Validate and normalize a URL; returns null if invalid/non-http or a DDG ad URL */
87
87
  private validateUrl;
88
88
  /**
89
89
  * Scrape DuckDuckGo HTML endpoint with stealth browser.
@@ -145,7 +145,7 @@ export declare class GoogleSearchProvider implements SearchProvider {
145
145
  * m[n]=past n months, y[n]=past n years.
146
146
  */
147
147
  private mapFreshnessToDateRestrict;
148
- /** Validate URL; returns null if invalid/non-http */
148
+ /** Validate URL; returns null if invalid/non-http or a DDG ad URL */
149
149
  private validateUrl;
150
150
  /**
151
151
  * Stealth browser scrape of google.com/search.
@@ -84,6 +84,30 @@ function decodeDdgUrl(rawUrl) {
84
84
  return rawUrl;
85
85
  }
86
86
  }
87
+ /** Returns true if a URL looks like a DuckDuckGo ad or tracking link */
88
+ function isDdgAdUrl(url) {
89
+ try {
90
+ const parsed = new URL(url);
91
+ // DDG-internal ad redirect paths
92
+ if (parsed.hostname === 'duckduckgo.com')
93
+ return true;
94
+ // URLs with known ad tracking query params
95
+ if (parsed.searchParams.has('ad_domain') ||
96
+ parsed.searchParams.has('ad_provider') ||
97
+ parsed.searchParams.has('ad_type'))
98
+ return true;
99
+ return false;
100
+ }
101
+ catch {
102
+ return false;
103
+ }
104
+ }
105
+ /** Returns true if a snippet is a DuckDuckGo ad snippet */
106
+ function isDdgAdSnippet(snippet) {
107
+ return snippet.includes('Ad ·') ||
108
+ snippet.includes('Ad Viewing ads is privacy protected by DuckDuckGo') ||
109
+ snippet.toLowerCase().startsWith('ad ·');
110
+ }
87
111
  class ProviderStatsTracker {
88
112
  history = new Map();
89
113
  windowSize;
@@ -182,14 +206,19 @@ function normalizeUrlForDedupe(rawUrl) {
182
206
  export class StealthSearchProvider {
183
207
  id = 'stealth';
184
208
  requiresApiKey = false;
185
- /** Validate and normalize a URL; returns null if invalid/non-http */
209
+ /** Validate and normalize a URL; returns null if invalid/non-http or a DDG ad URL */
186
210
  validateUrl(rawUrl) {
187
211
  try {
188
212
  const parsed = new URL(rawUrl);
189
213
  if (!['http:', 'https:'].includes(parsed.protocol))
190
214
  return null;
191
- // Filter DuckDuckGo ad redirect URLs (e.g. duckduckgo.com/y.js?ad_domain=...)
192
- if (parsed.hostname === 'duckduckgo.com' && parsed.pathname === '/y.js')
215
+ // Filter all DuckDuckGo URLs (internal links, ad redirects, etc.)
216
+ if (parsed.hostname === 'duckduckgo.com')
217
+ return null;
218
+ // Filter URLs with ad tracking query params
219
+ if (parsed.searchParams.has('ad_domain') ||
220
+ parsed.searchParams.has('ad_provider') ||
221
+ parsed.searchParams.has('ad_type'))
193
222
  return null;
194
223
  return parsed.href;
195
224
  }
@@ -236,10 +265,16 @@ export class StealthSearchProvider {
236
265
  const snippet = cleanText(snippetRaw, { maxLen: 500, stripEllipsisPadding: true });
237
266
  if (!title || !rawUrl)
238
267
  return;
268
+ // Filter ad snippets
269
+ if (isDdgAdSnippet(snippet))
270
+ return;
239
271
  // Extract real URL from DDG redirect param
240
272
  const finalUrl = decodeDdgUrl(rawUrl);
241
273
  if (!finalUrl)
242
274
  return; // filtered out (DDG internal link)
275
+ // Filter ad URLs
276
+ if (isDdgAdUrl(finalUrl))
277
+ return;
243
278
  const validated = this.validateUrl(finalUrl);
244
279
  if (!validated)
245
280
  return;
@@ -532,10 +567,16 @@ export class DuckDuckGoProvider {
532
567
  let snippet = cleanText(snippetRaw, { maxLen: 500, stripEllipsisPadding: true });
533
568
  if (!title || !rawUrl)
534
569
  return;
570
+ // Filter ad snippets (DuckDuckGo injects ad labels into snippets)
571
+ if (isDdgAdSnippet(snippet))
572
+ return;
535
573
  // Extract actual URL from DuckDuckGo redirect; filter DDG internal/ad URLs
536
574
  const decoded = decodeDdgUrl(rawUrl);
537
575
  if (!decoded)
538
576
  return; // filtered out (DDG internal link or ad redirect)
577
+ // Filter ad URLs
578
+ if (isDdgAdUrl(decoded))
579
+ return;
539
580
  // SECURITY: Validate and sanitize results — only allow HTTP/HTTPS URLs
540
581
  let url;
541
582
  try {
@@ -813,14 +854,19 @@ export class GoogleSearchProvider {
813
854
  };
814
855
  return map[tbs];
815
856
  }
816
- /** Validate URL; returns null if invalid/non-http */
857
+ /** Validate URL; returns null if invalid/non-http or a DDG ad URL */
817
858
  validateUrl(rawUrl) {
818
859
  try {
819
860
  const parsed = new URL(rawUrl);
820
861
  if (!['http:', 'https:'].includes(parsed.protocol))
821
862
  return null;
822
- // Filter DuckDuckGo ad redirect URLs (e.g. duckduckgo.com/y.js?ad_domain=...)
823
- if (parsed.hostname === 'duckduckgo.com' && parsed.pathname === '/y.js')
863
+ // Filter all DuckDuckGo URLs (internal links, ad redirects, etc.)
864
+ if (parsed.hostname === 'duckduckgo.com')
865
+ return null;
866
+ // Filter URLs with ad tracking query params
867
+ if (parsed.searchParams.has('ad_domain') ||
868
+ parsed.searchParams.has('ad_provider') ||
869
+ parsed.searchParams.has('ad_type'))
824
870
  return null;
825
871
  return parsed.href;
826
872
  }
@@ -597,8 +597,12 @@ export async function smartFetch(url, options = {}) {
597
597
  .then((result) => ({ type: 'simple-success', result }))
598
598
  .catch((error) => ({ type: 'simple-error', error }));
599
599
  if (simpleResult.type === 'simple-success') {
600
- // Check if the content is suspiciously thin or has SPA indicators escalate to browser if so
601
- if (shouldEscalateForLowContent(simpleResult.result) || hasSpaIndicators(simpleResult.result.html)) {
600
+ // Check if the content is suspiciously thin, looks like an SPA shell, or is a shell page
601
+ // (looksLikeShellPage catches partial renders with 200-500 visible chars that
602
+ // shouldEscalateForLowContent misses — improves consistency on sites like China Daily)
603
+ if (shouldEscalateForLowContent(simpleResult.result) ||
604
+ hasSpaIndicators(simpleResult.result.html) ||
605
+ looksLikeShellPage(simpleResult.result)) {
602
606
  shouldUseBrowser = true;
603
607
  }
604
608
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webpeel",
3
- "version": "0.20.4",
3
+ "version": "0.20.5",
4
4
  "description": "Fast web fetcher for AI agents - stealth mode, crawl mode, page actions, structured extraction, PDF parsing, smart escalation from simple HTTP to headless browser",
5
5
  "author": "Jake Liu",
6
6
  "license": "AGPL-3.0-only",