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.
- package/dist/core/domain-extractors.js +12 -4
- package/dist/core/youtube.js +232 -0
- package/package.json +1 -1
|
@@ -1165,8 +1165,8 @@ async function youtubeExtractor(_html, url) {
|
|
|
1165
1165
|
]);
|
|
1166
1166
|
}
|
|
1167
1167
|
// Run transcript fetch and oEmbed fetch in parallel
|
|
1168
|
-
//
|
|
1169
|
-
const transcriptPromise = withTimeout(getYouTubeTranscript(url),
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
package/dist/core/youtube.js
CHANGED
|
@@ -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.
|
|
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",
|