webpeel 0.20.14 → 0.20.17

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.
@@ -1165,8 +1165,8 @@ async function youtubeExtractor(_html, url) {
1165
1165
  ]);
1166
1166
  }
1167
1167
  // Run transcript fetch and oEmbed fetch in parallel
1168
- // Browser-rendered fetch takes ~10s use 15s timeout so browser has time to render
1169
- const transcriptPromise = withTimeout(getYouTubeTranscript(url), 15000);
1168
+ // Proxy-based extraction takes 2-5s, but retry logic may need more time
1169
+ const transcriptPromise = withTimeout(getYouTubeTranscript(url), 30000);
1170
1170
  const oembedPromise = fetchJson(`https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`);
1171
1171
  const noembedPromise = fetchJson(`https://noembed.com/embed?url=${encodeURIComponent(url)}`).catch(() => null);
1172
1172
  const [transcriptResult, oembedResult, noembedResult] = await Promise.allSettled([
@@ -1233,7 +1233,9 @@ async function youtubeExtractor(_html, url) {
1233
1233
  parts.push(headerLine);
1234
1234
  // Summary section
1235
1235
  if (transcript.summary && hasTranscript) {
1236
- parts.push(`## Summary\n\n${transcript.summary}`);
1236
+ let summaryText = transcript.summary;
1237
+ summaryText = summaryText.replace(/([.!?])\s+(?=[A-Z])/g, '$1\n\n');
1238
+ parts.push(`## Summary\n\n${summaryText}`);
1237
1239
  }
1238
1240
  else if (!hasTranscript && transcript.fullText) {
1239
1241
  parts.push(`## Description\n\n${transcript.fullText}`);
@@ -1249,8 +1251,14 @@ async function youtubeExtractor(_html, url) {
1249
1251
  parts.push(`## Chapters\n\n${chLines}`);
1250
1252
  }
1251
1253
  // Full Transcript section (only if we have real transcript segments)
1254
+ // Add intelligent paragraph breaks for readability
1252
1255
  if (hasTranscript) {
1253
- parts.push(`## Full Transcript\n\n${transcript.fullText}`);
1256
+ let readableText = transcript.fullText;
1257
+ // Break into paragraphs: after sentence-ending punctuation followed by a capital letter
1258
+ readableText = readableText.replace(/([.!?])\s+(?=[A-Z])/g, '$1\n\n');
1259
+ // Collapse any triple+ newlines
1260
+ readableText = readableText.replace(/\n{3,}/g, '\n\n');
1261
+ parts.push(`## Full Transcript\n\n${readableText}`);
1254
1262
  }
1255
1263
  const cleanContent = parts.join('\n\n');
1256
1264
  return { domain: 'youtube.com', type: 'video', structured, cleanContent };
@@ -6,6 +6,9 @@
6
6
  * track URLs, fetch the timedtext XML, and return structured transcript data.
7
7
  */
8
8
  import { execFile } from 'node:child_process';
9
+ import * as http from 'node:http';
10
+ import * as https from 'node:https';
11
+ import * as tls from 'node:tls';
9
12
  import { readFile, unlink } from 'node:fs/promises';
10
13
  import { tmpdir } from 'node:os';
11
14
  import { join } from 'node:path';
@@ -231,6 +234,217 @@ export function extractSummary(fullText) {
231
234
  return words.slice(0, 200).join(' ') + '...';
232
235
  }
233
236
  // ---------------------------------------------------------------------------
237
+ // Proxy-based InnerTube transcript extraction
238
+ // ---------------------------------------------------------------------------
239
+ // Webshare residential proxy config — reads from env vars on Render.
240
+ // Locally, falls back to direct fetch (residential IP already works).
241
+ const PROXY_HOST = process.env.WEBSHARE_PROXY_HOST || 'p.webshare.io';
242
+ const PROXY_BASE_PORT = parseInt(process.env.WEBSHARE_PROXY_PORT || '10000', 10);
243
+ const PROXY_USER = process.env.WEBSHARE_PROXY_USER || '';
244
+ const PROXY_PASS = process.env.WEBSHARE_PROXY_PASS || '';
245
+ // With paid Webshare backbone plan, each US slot has its own port:
246
+ // slot N → port (PROXY_BASE_PORT + N - 1), username: USER-US-N
247
+ const PROXY_MAX_US_SLOTS = parseInt(process.env.WEBSHARE_PROXY_SLOTS || '44744', 10);
248
+ function isProxyConfigured() {
249
+ return !!(PROXY_USER && PROXY_PASS);
250
+ }
251
+ /**
252
+ * Make an HTTP(S) request through the Webshare CONNECT proxy with a specific
253
+ * slotted username (e.g. "argtnlhz-5"). This ensures both the /player call
254
+ * and the caption XML fetch go through the same residential IP.
255
+ */
256
+ function proxyRequestSlotted(slottedUser, proxyPort, targetUrl, opts = {}) {
257
+ const url = new URL(targetUrl);
258
+ const timeout = opts.timeoutMs ?? 20000;
259
+ return new Promise((resolve, reject) => {
260
+ const proxyAuth = Buffer.from(`${slottedUser}:${PROXY_PASS}`).toString('base64');
261
+ const proxyReq = http.request({
262
+ host: PROXY_HOST,
263
+ port: proxyPort,
264
+ method: 'CONNECT',
265
+ path: `${url.hostname}:443`,
266
+ headers: { 'Proxy-Authorization': `Basic ${proxyAuth}` },
267
+ });
268
+ const timer = setTimeout(() => {
269
+ proxyReq.destroy();
270
+ reject(new Error('Proxy request timed out'));
271
+ }, timeout);
272
+ proxyReq.on('connect', (res, socket) => {
273
+ if (res.statusCode !== 200) {
274
+ clearTimeout(timer);
275
+ socket.destroy();
276
+ reject(new Error(`Proxy CONNECT failed: ${res.statusCode}`));
277
+ return;
278
+ }
279
+ const tlsSocket = tls.connect({ host: url.hostname, socket, servername: url.hostname }, () => {
280
+ const reqHeaders = {
281
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
282
+ 'Accept-Language': 'en-US,en;q=0.9',
283
+ 'Cookie': 'CONSENT=YES+; SOCS=CAI',
284
+ ...(opts.headers ?? {}),
285
+ };
286
+ const req = https.request({
287
+ hostname: url.hostname,
288
+ path: url.pathname + url.search,
289
+ method: opts.method ?? 'GET',
290
+ createConnection: () => tlsSocket,
291
+ headers: reqHeaders,
292
+ }, (response) => {
293
+ let data = '';
294
+ response.on('data', (chunk) => {
295
+ data += chunk;
296
+ });
297
+ response.on('end', () => {
298
+ clearTimeout(timer);
299
+ resolve({ status: response.statusCode ?? 0, body: data });
300
+ });
301
+ });
302
+ req.on('error', (e) => {
303
+ clearTimeout(timer);
304
+ reject(e);
305
+ });
306
+ if (opts.body)
307
+ req.write(opts.body);
308
+ req.end();
309
+ });
310
+ tlsSocket.on('error', (e) => {
311
+ clearTimeout(timer);
312
+ reject(e);
313
+ });
314
+ });
315
+ proxyReq.on('error', (e) => {
316
+ clearTimeout(timer);
317
+ reject(e);
318
+ });
319
+ proxyReq.end();
320
+ });
321
+ }
322
+ /**
323
+ * Fetch YouTube transcript via InnerTube /player API through Webshare proxy.
324
+ *
325
+ * This replicates the approach used by the Python `youtube-transcript-api` library:
326
+ * 1. POST to /youtubei/v1/player with ANDROID client context
327
+ * 2. Get caption track URLs WITHOUT the `exp=xpe` parameter
328
+ * 3. Fetch caption XML from those clean URLs (returns actual data, not 0 bytes)
329
+ *
330
+ * All requests go through the residential proxy to bypass YouTube's cloud IP blocking.
331
+ */
332
+ async function getTranscriptViaProxy(videoId, preferredLang) {
333
+ // Try multiple proxy slots from the 44K+ US residential pool.
334
+ // Pick random slots across the pool for even distribution and to avoid
335
+ // rate-limited IPs. Try up to MAX_RETRIES different slots.
336
+ const MAX_RETRIES = 5;
337
+ const usedSlots = new Set();
338
+ const INNERTUBE_API_KEY = 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8';
339
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
340
+ // Pick a random US slot we haven't tried yet
341
+ let slot;
342
+ do {
343
+ slot = Math.floor(Math.random() * PROXY_MAX_US_SLOTS) + 1;
344
+ } while (usedSlots.has(slot) && usedSlots.size < PROXY_MAX_US_SLOTS);
345
+ usedSlots.add(slot);
346
+ const proxyUser = `${PROXY_USER}-US-${slot}`;
347
+ const proxyPort = PROXY_BASE_PORT + slot - 1;
348
+ const doProxyRequest = (url, opts = {}) => proxyRequestSlotted(proxyUser, proxyPort, url, opts);
349
+ try {
350
+ // Step 1: Call InnerTube /player with ANDROID client
351
+ // ANDROID client returns caption URLs WITHOUT exp=xpe (avoids 0-byte responses).
352
+ const playerResp = await doProxyRequest(`https://www.youtube.com/youtubei/v1/player?key=${INNERTUBE_API_KEY}`, {
353
+ method: 'POST',
354
+ body: JSON.stringify({
355
+ context: { client: { clientName: 'ANDROID', clientVersion: '20.10.38' } },
356
+ videoId,
357
+ }),
358
+ headers: { 'Content-Type': 'application/json' },
359
+ });
360
+ if (playerResp.status !== 200) {
361
+ console.log(`[webpeel] [youtube] Proxy US-${slot} (port ${proxyPort}): /player returned ${playerResp.status}`);
362
+ continue;
363
+ }
364
+ const playerData = JSON.parse(playerResp.body);
365
+ const captionTracks = playerData?.captions?.playerCaptionsTracklistRenderer?.captionTracks;
366
+ if (!captionTracks || captionTracks.length === 0) {
367
+ console.log(`[webpeel] [youtube] Proxy US-${slot} (port ${proxyPort}): no caption tracks`);
368
+ continue;
369
+ }
370
+ // Pick best matching language track
371
+ let track = captionTracks.find((t) => t.languageCode === preferredLang);
372
+ if (!track) {
373
+ track = captionTracks.find((t) => t.languageCode === 'en') ?? captionTracks[0];
374
+ }
375
+ const captionUrl = track.baseUrl;
376
+ if (captionUrl.includes('exp=xpe')) {
377
+ console.log(`[webpeel] [youtube] Proxy US-${slot} (port ${proxyPort}): caption URL has exp=xpe, skipping`);
378
+ continue;
379
+ }
380
+ // Step 2: Fetch caption XML through the SAME proxy slot (same residential IP)
381
+ const capResp = await doProxyRequest(captionUrl);
382
+ if (!capResp.body ||
383
+ capResp.body.length === 0 ||
384
+ capResp.status === 429 ||
385
+ capResp.body.includes('<title>Sorry...</title>')) {
386
+ console.log(`[webpeel] [youtube] Proxy US-${slot} (port ${proxyPort}): caption XML failed (status=${capResp.status}, bytes=${capResp.body?.length ?? 0})`);
387
+ continue; // Try next slot
388
+ }
389
+ // Parse XML segments — handles both <text start="" dur=""> and <p t="" d=""> formats
390
+ const xmlSegments = [
391
+ ...capResp.body.matchAll(/<(?:text|p)\s[^>]*?(?:start|t)="([^"]*)"[^>]*?(?:dur|d)="([^"]*)"[^>]*>([\s\S]*?)<\/(?:text|p)>/g),
392
+ ];
393
+ if (xmlSegments.length === 0) {
394
+ console.log(`[webpeel] [youtube] Proxy US-${slot} (port ${proxyPort}): no segments parsed from XML`);
395
+ continue;
396
+ }
397
+ const segments = xmlSegments
398
+ .map((m) => ({
399
+ text: decodeHtmlEntities(m[3].replace(/<[^>]+>/g, '').replace(/\n/g, ' ').trim()),
400
+ start: parseFloat(m[1]) / (m[1].includes('.') ? 1 : 1000),
401
+ duration: parseFloat(m[2]) / (m[2].includes('.') ? 1 : 1000),
402
+ }))
403
+ .filter((s) => s.text.length > 0);
404
+ if (segments.length === 0)
405
+ continue;
406
+ // Extract metadata from player response
407
+ const vd = playerData.videoDetails ?? {};
408
+ const mf = playerData.microformat?.playerMicroformatRenderer ?? {};
409
+ const title = vd.title ?? '';
410
+ const channel = vd.author ?? '';
411
+ const lengthSeconds = parseInt(vd.lengthSeconds ?? mf.lengthSeconds ?? '0', 10);
412
+ const description = (vd.shortDescription ?? mf.description?.simpleText ?? '').trim();
413
+ const publishDate = mf.publishDate ?? mf.uploadDate ?? '';
414
+ const availableLanguages = captionTracks.map((t) => t.languageCode);
415
+ const fullText = segments.map((s) => s.text).join(' ').replace(/\s+/g, ' ').trim();
416
+ const wordCount = fullText.split(/\s+/).filter(Boolean).length;
417
+ const chapters = parseChaptersFromDescription(description);
418
+ const keyPoints = extractKeyPoints(segments, chapters, lengthSeconds);
419
+ const summary = extractSummary(fullText);
420
+ console.log(`[webpeel] [youtube] Proxy slot ${slot} success: ${segments.length} segments, ${wordCount} words`);
421
+ return {
422
+ videoId,
423
+ title,
424
+ channel,
425
+ duration: formatDuration(lengthSeconds),
426
+ language: track.languageCode ?? preferredLang,
427
+ segments,
428
+ fullText,
429
+ availableLanguages,
430
+ description,
431
+ publishDate,
432
+ chapters: chapters.length > 0 ? chapters : undefined,
433
+ keyPoints: keyPoints.length > 0 ? keyPoints : undefined,
434
+ summary,
435
+ wordCount,
436
+ };
437
+ }
438
+ catch (err) {
439
+ console.log(`[webpeel] [youtube] Proxy slot ${slot} error:`, err?.message);
440
+ continue;
441
+ }
442
+ }
443
+ // All slots exhausted
444
+ console.log('[webpeel] [youtube] All proxy slots exhausted');
445
+ return null;
446
+ }
447
+ // ---------------------------------------------------------------------------
234
448
  // Transcript extraction
235
449
  // ---------------------------------------------------------------------------
236
450
  /**
@@ -246,6 +460,24 @@ export async function getYouTubeTranscript(url, options = {}) {
246
460
  }
247
461
  const preferredLang = options.language ?? 'en';
248
462
  const videoUrl = `https://www.youtube.com/watch?v=${videoId}`;
463
+ // --- Path P: Proxy-based InnerTube (primary for cloud servers) ---
464
+ // Uses Webshare residential proxy + ANDROID InnerTube /player API.
465
+ // This is the approach used by every major YouTube transcript service
466
+ // (youtubetotranscript.com, youtube-transcript.io, etc.)
467
+ if (!process.env.VITEST && isProxyConfigured()) {
468
+ console.log('[webpeel] [youtube] Trying path P: proxy-based InnerTube (residential proxy)');
469
+ try {
470
+ const proxyResult = await getTranscriptViaProxy(videoId, preferredLang);
471
+ if (proxyResult && proxyResult.segments.length > 0) {
472
+ console.log(`[webpeel] [youtube] Path P success: ${proxyResult.segments.length} segments, ${proxyResult.wordCount} words`);
473
+ return proxyResult;
474
+ }
475
+ console.log('[webpeel] [youtube] Path P returned empty/null, falling through');
476
+ }
477
+ catch (err) {
478
+ console.log('[webpeel] [youtube] Path P failed:', err?.message);
479
+ }
480
+ }
249
481
  // --- Path 0: youtube-transcript-plus (fastest — uses InnerTube API, ~1s) ---
250
482
  // This library calls YouTube's internal InnerTube API directly via POST request,
251
483
  // bypassing the IP-locked timedtext XML URLs. Works reliably from cloud servers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webpeel",
3
- "version": "0.20.14",
3
+ "version": "0.20.17",
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",