yt-zmp 0.0.0-stage → 1.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 +131 -2
- package/auth.js +63 -0
- package/cipher.js +172 -0
- package/client.js +83 -0
- package/cookie-test.js +36 -0
- package/direct-test.js +73 -0
- package/download-test.js +61 -0
- package/downloader.js +201 -0
- package/extractor.js +685 -0
- package/formats.js +177 -0
- package/index.js +66 -0
- package/media.js +239 -0
- package/package.json +34 -4
- package/parser.js +57 -0
- package/test.js +190 -0
- package/transcoder.js +120 -0
- package/utils.js +40 -0
package/README.md
CHANGED
|
@@ -1,3 +1,132 @@
|
|
|
1
|
-
#
|
|
1
|
+
# YouTube Media Downloader (experimental)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Módulo Node.js em fase de testes para consultar vídeos do YouTube e baixar uma mídia a partir dos streams que o YouTube disponibilizar.
|
|
4
|
+
|
|
5
|
+
O download é implementado pelo próprio módulo, sem usar `yt-dlp`. O extrator tenta clientes internos do YouTube (`ANDROID`, `WEB`, `MWEB`, `IOS` e `TVHTML5`) e usa o perfil que retornar os streams. Os perfis Android e iOS usam versões e parâmetros de dispositivo compatíveis com os clientes atuais observados no código do yt-dlp.
|
|
6
|
+
|
|
7
|
+
Para áudio, o seletor prioriza `audio/mp4` (AAC, geralmente itag 140) antes de WebM/Opus, pois esse stream é mais compatível com download direto e conversão para MP3.
|
|
8
|
+
|
|
9
|
+
Se um formato retornar 403, o módulo tenta os demais streams de áudio disponíveis e repete a requisição sem `Range` antes de renovar a extração. Os cookies de sessão continuam sendo usados somente nas requisições ao YouTube.
|
|
10
|
+
|
|
11
|
+
Quando o YouTube entrega `signatureCipher`, o módulo carrega o JavaScript do player em um sandbox isolado e reutiliza a rotina de assinatura do próprio player antes de montar a URL do stream. Há também um decifrador heurístico de fallback para versões mais simples do player.
|
|
12
|
+
|
|
13
|
+
Para baixar o vídeo, combinar vídeo e áudio com FFmpeg e entregar somente o áudio final, use `video-audio`:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
node download-test.js "URL_DO_YOUTUBE" video-audio 720 mp3
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Esse modo cria temporariamente um MP4 com vídeo e áudio, extrai o áudio com FFmpeg e remove o arquivo temporário. Use `m4a` para preservar o áudio AAC sem recodificação ou `mp3` para converter com LAME.
|
|
20
|
+
|
|
21
|
+
## Sessão autenticada opcional
|
|
22
|
+
|
|
23
|
+
Para vídeos que exigem uma conta autenticada, o módulo aceita um arquivo de cookies no formato Netscape. Use somente cookies da sua própria conta, mantenha o arquivo fora do repositório e nunca cole os valores em logs ou mensagens. O módulo usa os cookies e o `SAPISIDHASH` apenas nas requisições ao YouTube; eles não são enviados ao host `googlevideo.com`.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
YOUTUBE_COOKIE_FILE=/caminho/privado/youtube-cookies.txt \
|
|
27
|
+
node download-test.js "https://www.youtube.com/watch?v=VIDEO_ID" audio 0 mp3
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Pela API:
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
await YouTube.downloadMedia(url, {
|
|
34
|
+
kind: 'audio',
|
|
35
|
+
audioFormat: 'm4a',
|
|
36
|
+
cookieFile: '/caminho/privado/youtube-cookies.txt'
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
O arquivo deve conter linhas Netscape com domínio, caminho, expiração, nome e valor. Cookies autenticados não garantem a superação de todos os desafios anti-bot, CAPTCHA ou exigências de verificação do YouTube; nesses casos, o módulo retorna o motivo informado pelo serviço.
|
|
41
|
+
|
|
42
|
+
## GVS PO token
|
|
43
|
+
|
|
44
|
+
Versões recentes do YouTube podem exigir um **GVS PO token** para autorizar as URLs de mídia. O yt-zmp não tenta fabricar esse token nem usa o yt-dlp para obtê-lo. Quando você possui um token legítimo para a sessão/cliente, forneça-o por variável de ambiente; ele será acrescentado como `pot` às URLs de stream durante a extração:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
YOUTUBE_GVS_PO_TOKEN='TOKEN_BASE64URL' \
|
|
48
|
+
YOUTUBE_COOKIE_FILE=/caminho/privado/youtube-cookies.txt \
|
|
49
|
+
node download-test.js "https://www.youtube.com/watch?v=VIDEO_ID" video-audio 720 mp3
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Também é possível passar `poToken` diretamente em `extract`, `downloadMedia` ou `downloadAudioFromVideo`. O valor não deve ser salvo no repositório nem impresso em logs. Sem PO token, alguns formatos podem aparecer na extração, mas retornar HTTP 403 no host `googlevideo.com`; nesse caso trocar cabeçalhos ou repetir a mesma URL não resolve a autorização ausente.
|
|
53
|
+
|
|
54
|
+
Para testar a sessão sem baixar mídia:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
YOUTUBE_COOKIE_FILE=/caminho/privado/youtube-cookies.txt \
|
|
58
|
+
npm run auth:test -- "https://www.youtube.com/watch?v=VIDEO_ID"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
O teste informa apenas se o arquivo foi lido, quantos cookies foram encontrados, se o `SAPISIDHASH` foi gerado e se o YouTube retornou metadados/formatos. Ele nunca imprime os valores dos cookies.
|
|
62
|
+
|
|
63
|
+
## Uso rápido
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npm run download:test -- "https://www.youtube.com/watch?v=VIDEO_ID" muxed 720
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
O terceiro argumento é opcional. Tipos disponíveis:
|
|
70
|
+
|
|
71
|
+
- `muxed`: vídeo com áudio no mesmo stream, quando disponível;
|
|
72
|
+
- `audio`: melhor stream de áudio disponível;
|
|
73
|
+
- `video`: melhor stream de vídeo disponível, que pode não conter áudio.
|
|
74
|
+
|
|
75
|
+
Para áudio, o formato padrão é `m4a`, baixado diretamente do stream AAC original. Para gerar MP3, o módulo baixa o stream de áudio e faz a conversão local com FFmpeg:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
node download-test.js "https://www.youtube.com/watch?v=VIDEO_ID" audio 0 mp3
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Ou pela API:
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
await YouTube.downloadMedia(url, {
|
|
85
|
+
kind: 'audio',
|
|
86
|
+
audioFormat: 'mp3',
|
|
87
|
+
mp3Quality: '2'
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
O formato `m4a` não exige FFmpeg. O formato `mp3` exige que o executável `ffmpeg` esteja instalado e disponível no PATH; também é possível informar `ffmpegPath` na API.
|
|
92
|
+
|
|
93
|
+
Quando o cliente do YouTube fornecer somente um formato muxado, como o itag 18, o modo `audio` agora baixa esse arquivo e extrai o áudio com FFmpeg em vez de falhar por ausência de áudio adaptativo. O mesmo fallback é usado por `video-audio`. Na linha de comando, a altura pode ser informada como `720` ou `720p`.
|
|
94
|
+
|
|
95
|
+
## Vídeo em qualidade maior
|
|
96
|
+
|
|
97
|
+
Streams muxados (`muxed`) costumam ficar limitados a 360p. Para obter uma resolução maior, use `kind: 'video'`: o módulo baixa o stream de vídeo adaptativo e o melhor áudio separadamente e combina os dois localmente com FFmpeg.
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
node download-test.js "https://www.youtube.com/watch?v=VIDEO_ID" video 1080
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Pela API:
|
|
104
|
+
|
|
105
|
+
```js
|
|
106
|
+
await YouTube.downloadMedia(url, {
|
|
107
|
+
kind: 'video',
|
|
108
|
+
height: 1080,
|
|
109
|
+
mergeAudio: true
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Se a resolução solicitada não existir, o módulo escolhe a melhor resolução disponível abaixo do limite informado. A combinação de vídeo de alta resolução exige FFmpeg; `mergeAudio: false` permite salvar apenas o stream de vídeo.
|
|
114
|
+
|
|
115
|
+
Os arquivos são salvos em `./downloads` por padrão. Também é possível usar a API:
|
|
116
|
+
|
|
117
|
+
```js
|
|
118
|
+
import YouTube from './index.js';
|
|
119
|
+
|
|
120
|
+
const result = await YouTube.downloadMedia(url, {
|
|
121
|
+
kind: 'audio',
|
|
122
|
+
outputDir: './downloads'
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Status experimental
|
|
127
|
+
|
|
128
|
+
Este projeto ainda está em testes. As URLs de stream são temporárias, os formatos disponíveis variam por vídeo e o YouTube pode alterar seus endpoints ou exigir novos parâmetros. O módulo combina streams adaptativos de vídeo e áudio e converte áudio para MP3 localmente com FFmpeg. Use somente em conteúdos que você tem autorização para baixar e respeite os termos aplicáveis.
|
|
129
|
+
|
|
130
|
+
## Erro 403 em `googlevideo.com`
|
|
131
|
+
|
|
132
|
+
Não reutilize nem salve a URL `googlevideo.com` retornada pelo YouTube. Ela é assinada, expira rapidamente e pode ficar vinculada ao IP que fez a extração. O fluxo `downloadMedia()` extrai uma URL nova a cada tentativa e baixa imediatamente, mas ainda pode falhar se a extração e o download forem executados em máquinas ou redes diferentes. Em caso de 403 persistente, verifique primeiro se o cliente exige GVS PO token, se a extração e o download ocorrem na mesma máquina/rede e se a URL ainda está dentro de `expiresInSeconds`. O downloader valida respostas parciais e tenta formatos alternativos, mas não consegue contornar uma política de autorização do servidor sem o token necessário. O projeto continua independente do yt-dlp; o código dele foi usado apenas como referência técnica.
|
package/auth.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
|
|
4
|
+
function parseNetscapeCookies(filePath) {
|
|
5
|
+
const text = fs.readFileSync(filePath, 'utf8');
|
|
6
|
+
const now = Math.floor(Date.now() / 1000);
|
|
7
|
+
return text
|
|
8
|
+
.split(/\r?\n/)
|
|
9
|
+
.filter(line => line && (!line.startsWith('#') || line.startsWith('#HttpOnly_')))
|
|
10
|
+
.map(line => line.startsWith('#HttpOnly_') ? line.slice('#HttpOnly_'.length) : line)
|
|
11
|
+
.map(line => line.split('\t'))
|
|
12
|
+
.filter(parts => parts.length >= 7)
|
|
13
|
+
.filter(parts => !parts[4] || Number(parts[4]) === 0 || Number(parts[4]) > now)
|
|
14
|
+
.map(parts => ({
|
|
15
|
+
domain: parts[0].toLowerCase(),
|
|
16
|
+
path: parts[2] || '/',
|
|
17
|
+
secure: parts[3] === 'TRUE',
|
|
18
|
+
name: parts[5],
|
|
19
|
+
value: parts.slice(6).join('\t')
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function cookieHeader(cookies, hostname) {
|
|
24
|
+
const host = hostname.toLowerCase();
|
|
25
|
+
const selected = cookies.filter(cookie => {
|
|
26
|
+
const domain = cookie.domain
|
|
27
|
+
.replace(/^#HttpOnly_/, '')
|
|
28
|
+
.replace(/^\./, '');
|
|
29
|
+
return host === domain || host.endsWith(`.${domain}`);
|
|
30
|
+
});
|
|
31
|
+
return selected.map(cookie => `${cookie.name}=${cookie.value}`).join('; ');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function loadYouTubeAuth(cookieFile, options = {}) {
|
|
35
|
+
if (!cookieFile) return null;
|
|
36
|
+
const cookies = parseNetscapeCookies(cookieFile);
|
|
37
|
+
const cookie = cookieHeader(cookies, 'youtube.com');
|
|
38
|
+
if (!cookie) {
|
|
39
|
+
throw new Error('O arquivo de cookies não contém cookies do YouTube.');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const sapisidCookie = cookies.find(item =>
|
|
43
|
+
['SAPISID', '__Secure-3PAPISID', '__Secure-1PAPISID'].includes(item.name)
|
|
44
|
+
);
|
|
45
|
+
const headers = {
|
|
46
|
+
Cookie: cookie
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
if (sapisidCookie && options.sapisid !== false) {
|
|
50
|
+
const origin = options.origin || 'https://www.youtube.com';
|
|
51
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
52
|
+
const digest = crypto
|
|
53
|
+
.createHash('sha1')
|
|
54
|
+
.update(`${timestamp} ${sapisidCookie.value} ${origin}`)
|
|
55
|
+
.digest('hex');
|
|
56
|
+
headers.Authorization = `SAPISIDHASH ${timestamp}_${digest}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
headers,
|
|
61
|
+
cookieCount: cookies.length
|
|
62
|
+
};
|
|
63
|
+
}
|
package/cipher.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import vm from 'node:vm';
|
|
2
|
+
|
|
3
|
+
function decodeEscapes(value) {
|
|
4
|
+
return value
|
|
5
|
+
.replace(/\\u0026/g, '&')
|
|
6
|
+
.replace(/\\u003d/g, '=')
|
|
7
|
+
.replace(/\\u0027/g, "'")
|
|
8
|
+
.replace(/\\u003c/g, '<')
|
|
9
|
+
.replace(/\\u003e/g, '>')
|
|
10
|
+
.replace(/\\u002f/g, '/');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function createPlayerContext() {
|
|
14
|
+
const context = {
|
|
15
|
+
console,
|
|
16
|
+
setTimeout,
|
|
17
|
+
clearTimeout,
|
|
18
|
+
setInterval,
|
|
19
|
+
clearInterval,
|
|
20
|
+
URL,
|
|
21
|
+
URLSearchParams,
|
|
22
|
+
TextDecoder,
|
|
23
|
+
TextEncoder
|
|
24
|
+
};
|
|
25
|
+
context.window = context;
|
|
26
|
+
context.self = context;
|
|
27
|
+
context.globalThis = context;
|
|
28
|
+
context.navigator = {
|
|
29
|
+
userAgent: 'Mozilla/5.0',
|
|
30
|
+
language: 'pt-BR',
|
|
31
|
+
languages: ['pt-BR']
|
|
32
|
+
};
|
|
33
|
+
context.location = {
|
|
34
|
+
href: 'https://m.youtube.com/',
|
|
35
|
+
origin: 'https://m.youtube.com',
|
|
36
|
+
host: 'm.youtube.com',
|
|
37
|
+
hostname: 'm.youtube.com',
|
|
38
|
+
protocol: 'https:'
|
|
39
|
+
};
|
|
40
|
+
context.document = {
|
|
41
|
+
cookie: '',
|
|
42
|
+
createElement() {
|
|
43
|
+
return {
|
|
44
|
+
style: {},
|
|
45
|
+
setAttribute() {},
|
|
46
|
+
appendChild() {}
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
documentElement: {},
|
|
50
|
+
body: {},
|
|
51
|
+
head: {},
|
|
52
|
+
addEventListener() {},
|
|
53
|
+
removeEventListener() {}
|
|
54
|
+
};
|
|
55
|
+
context.XMLHttpRequest = class {
|
|
56
|
+
open() {}
|
|
57
|
+
send() {}
|
|
58
|
+
setRequestHeader() {}
|
|
59
|
+
addEventListener() {}
|
|
60
|
+
};
|
|
61
|
+
return context;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function createRuntimeTools(source) {
|
|
65
|
+
const signatureCall = source.match(
|
|
66
|
+
/if\([^\n]{0,80}\.s\)[\s\S]{0,500}?([\w$]+)\((\d+),(\d+),([\w$]+)\((\d+),(\d+),[^)]*\.s\)\)/
|
|
67
|
+
);
|
|
68
|
+
const outer = signatureCall?.[1] || 'ca';
|
|
69
|
+
const outerA = signatureCall?.[2] || '15';
|
|
70
|
+
const outerB = signatureCall?.[3] || '5768';
|
|
71
|
+
const inner = signatureCall?.[4] || 'ic';
|
|
72
|
+
const innerA = signatureCall?.[5] || '3';
|
|
73
|
+
const innerB = signatureCall?.[6] || '3124';
|
|
74
|
+
const marker = `g.__ytzmpDecipher=function(s){return ${outer}(${outerA},${outerB},${inner}(${innerA},${innerB},s));};g.__ytzmpTransform=function(s){try{var u=new g.hc(s,!0),n=u.get("n");if(n){var v=new URL(s);v.searchParams.set("n",n);return v.toString();}}catch(e){}return s;};`;
|
|
75
|
+
const injected = source.replace(
|
|
76
|
+
/\}\)\(_yt_player\);\s*$/,
|
|
77
|
+
`${marker}})(_yt_player);`
|
|
78
|
+
);
|
|
79
|
+
if (injected === source) return null;
|
|
80
|
+
|
|
81
|
+
const context = createPlayerContext();
|
|
82
|
+
try {
|
|
83
|
+
vm.runInNewContext(injected, context, {
|
|
84
|
+
timeout: 15000,
|
|
85
|
+
codeGeneration: { strings: false, wasm: false }
|
|
86
|
+
});
|
|
87
|
+
const decipher = context._yt_player?.__ytzmpDecipher;
|
|
88
|
+
const transform = context._yt_player?.__ytzmpTransform;
|
|
89
|
+
if (typeof decipher !== 'function') return null;
|
|
90
|
+
return {
|
|
91
|
+
decipher: value => {
|
|
92
|
+
try {
|
|
93
|
+
return String(decipher(String(value)));
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
transform: value => {
|
|
99
|
+
try {
|
|
100
|
+
return typeof transform === 'function'
|
|
101
|
+
? String(transform(String(value)))
|
|
102
|
+
: value;
|
|
103
|
+
} catch {
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function applyOperation(value, operation, amount = 0) {
|
|
114
|
+
if (operation === 'reverse') value.reverse();
|
|
115
|
+
if (operation === 'splice') value.splice(0, amount);
|
|
116
|
+
if (operation === 'slice') value = value.slice(amount);
|
|
117
|
+
if (operation === 'swap') {
|
|
118
|
+
const index = amount % value.length;
|
|
119
|
+
[value[0], value[index]] = [value[index], value[0]];
|
|
120
|
+
}
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function createHeuristicDecipher(source) {
|
|
125
|
+
const functionPattern = /(?:function\s+([\w$]+)|([\w$]+)\s*=\s*function)\s*\(([\w$]+)\)\s*\{([\s\S]{0,3000}?)\}/g;
|
|
126
|
+
let match;
|
|
127
|
+
while ((match = functionPattern.exec(source))) {
|
|
128
|
+
const argument = match[3];
|
|
129
|
+
const body = match[4];
|
|
130
|
+
if (!body.includes('.split(\"\")') || !body.includes('.join(\"\")')) continue;
|
|
131
|
+
const operations = [];
|
|
132
|
+
const statements = body.split(';').map(item => item.trim()).filter(Boolean);
|
|
133
|
+
for (const statement of statements) {
|
|
134
|
+
if (statement.includes(`${argument}.reverse()`)) operations.push(['reverse', 0]);
|
|
135
|
+
let operation = statement.match(new RegExp(`\\b${argument}\\.splice\\(0,(\\d+)\\)`));
|
|
136
|
+
if (operation) operations.push(['splice', Number(operation[1])]);
|
|
137
|
+
operation = statement.match(new RegExp(`\\b${argument}\\s*=\\s*${argument}\\.slice\\((\\d+)\\)`));
|
|
138
|
+
if (operation) operations.push(['slice', Number(operation[1])]);
|
|
139
|
+
}
|
|
140
|
+
if (!operations.length) continue;
|
|
141
|
+
return signature => {
|
|
142
|
+
let value = String(signature).split('');
|
|
143
|
+
for (const [operation, amount] of operations) value = applyOperation(value, operation, amount);
|
|
144
|
+
return value.join('');
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function createDecipher(playerJavaScript) {
|
|
151
|
+
return createRuntimeTools(playerJavaScript)?.decipher || createHeuristicDecipher(playerJavaScript);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function createPlayerTools(playerJavaScript) {
|
|
155
|
+
return createRuntimeTools(playerJavaScript);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function extractPlayerJavaScriptUrl(html) {
|
|
159
|
+
const matches = [
|
|
160
|
+
html.match(/"PLAYER_JS_URL":"([^"]+)"/),
|
|
161
|
+
html.match(/"jsUrl":"([^"]+)"/),
|
|
162
|
+
html.match(/\/s\/player\/[^"']+\/base\.js/)
|
|
163
|
+
];
|
|
164
|
+
for (const match of matches) {
|
|
165
|
+
if (!match?.[1] && !match?.[0]) continue;
|
|
166
|
+
const raw = match[1] || match[0];
|
|
167
|
+
const decoded = decodeEscapes(raw);
|
|
168
|
+
if (decoded.startsWith('http')) return decoded;
|
|
169
|
+
if (decoded.startsWith('/')) return `https://www.youtube.com${decoded}`;
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
package/client.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const DEFAULT_HEADERS = {
|
|
2
|
+
'User-Agent':
|
|
3
|
+
'Mozilla/5.0 (Linux; Android 13; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36',
|
|
4
|
+
|
|
5
|
+
'Accept-Language':
|
|
6
|
+
'pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7',
|
|
7
|
+
|
|
8
|
+
'Accept':
|
|
9
|
+
'*/*'
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export async function request(
|
|
13
|
+
url,
|
|
14
|
+
options = {}
|
|
15
|
+
) {
|
|
16
|
+
const headers = {
|
|
17
|
+
...DEFAULT_HEADERS,
|
|
18
|
+
...(options.headers || {})
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const response =
|
|
22
|
+
await fetch(url, {
|
|
23
|
+
...options,
|
|
24
|
+
|
|
25
|
+
headers,
|
|
26
|
+
|
|
27
|
+
redirect: 'follow'
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (!response.ok) {
|
|
31
|
+
let body = '';
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
body =
|
|
35
|
+
await response.text();
|
|
36
|
+
} catch {}
|
|
37
|
+
|
|
38
|
+
const retryAfter = response.headers.get('retry-after');
|
|
39
|
+
const suffix = response.status === 429
|
|
40
|
+
? `; o YouTube limitou temporariamente esta rede${retryAfter ? `; Retry-After: ${retryAfter}s` : ''}`
|
|
41
|
+
: '';
|
|
42
|
+
const error =
|
|
43
|
+
new Error(
|
|
44
|
+
`HTTP ${response.status}: ${response.statusText}${suffix}`
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
error.status =
|
|
48
|
+
response.status;
|
|
49
|
+
|
|
50
|
+
error.body =
|
|
51
|
+
body.slice(0, 1000);
|
|
52
|
+
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return response;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function getText(
|
|
60
|
+
url,
|
|
61
|
+
options = {}
|
|
62
|
+
) {
|
|
63
|
+
const response =
|
|
64
|
+
await request(
|
|
65
|
+
url,
|
|
66
|
+
options
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
return response.text();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function getJson(
|
|
73
|
+
url,
|
|
74
|
+
options = {}
|
|
75
|
+
) {
|
|
76
|
+
const response =
|
|
77
|
+
await request(
|
|
78
|
+
url,
|
|
79
|
+
options
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
return response.json();
|
|
83
|
+
}
|
package/cookie-test.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import YouTube, { loadYouTubeAuth } from './index.js';
|
|
2
|
+
|
|
3
|
+
const cookieFile = process.env.YOUTUBE_COOKIE_FILE;
|
|
4
|
+
const url = process.argv[2];
|
|
5
|
+
|
|
6
|
+
if (!cookieFile) {
|
|
7
|
+
console.error('Defina YOUTUBE_COOKIE_FILE apontando para o arquivo Netscape.');
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const auth = loadYouTubeAuth(cookieFile);
|
|
13
|
+
console.log('Arquivo de cookies: OK');
|
|
14
|
+
console.log('Cookies lidos:', auth.cookieCount);
|
|
15
|
+
console.log('SAPISIDHASH gerado:', Boolean(auth.headers.Authorization));
|
|
16
|
+
|
|
17
|
+
if (!url) {
|
|
18
|
+
console.log('Sessão não testada contra o YouTube: informe uma URL para continuar.');
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
console.log('\nConsultando o YouTube com a sessão...');
|
|
23
|
+
const data = await YouTube.extract(url, {
|
|
24
|
+
headers: auth.headers
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
console.log('Sessão aceita para consulta: SIM');
|
|
28
|
+
console.log('Título:', data.title || '(não informado)');
|
|
29
|
+
console.log('Cliente:', data.client?.name || '(não informado)');
|
|
30
|
+
console.log('Formatos encontrados:', data.formats.length);
|
|
31
|
+
console.log('Áudios encontrados:', data.formats.filter(item => item.mimeType?.startsWith('audio/')).length);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.error('\nFalha no teste:', error.message);
|
|
34
|
+
console.error('Nenhum valor de cookie foi exibido.');
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
package/direct-test.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const url = process.argv[2];
|
|
2
|
+
|
|
3
|
+
if (!url) {
|
|
4
|
+
console.log(
|
|
5
|
+
'Uso: node direct-test.js "URL_DO_STREAM"'
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
console.log('Testando URL diretamente...\n');
|
|
13
|
+
|
|
14
|
+
const response = await fetch(url, {
|
|
15
|
+
method: 'GET',
|
|
16
|
+
redirect: 'follow',
|
|
17
|
+
headers: {
|
|
18
|
+
'User-Agent':
|
|
19
|
+
'Mozilla/5.0 (Linux; Android 13; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36',
|
|
20
|
+
|
|
21
|
+
'Accept':
|
|
22
|
+
'*/*'
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
console.log(
|
|
27
|
+
'Status:',
|
|
28
|
+
response.status,
|
|
29
|
+
response.statusText
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
console.log(
|
|
33
|
+
'Content-Type:',
|
|
34
|
+
response.headers.get('content-type')
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
console.log(
|
|
38
|
+
'Content-Length:',
|
|
39
|
+
response.headers.get('content-length')
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
console.log(
|
|
43
|
+
'Location:',
|
|
44
|
+
response.headers.get('location')
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
const text =
|
|
49
|
+
await response.text();
|
|
50
|
+
|
|
51
|
+
console.log(
|
|
52
|
+
'\nResposta do servidor:\n'
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
console.log(
|
|
56
|
+
text.slice(0, 1000)
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log(
|
|
63
|
+
'\nA URL foi aceita.'
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error(
|
|
68
|
+
'\nERRO:',
|
|
69
|
+
error.message
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
package/download-test.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import YouTube from './index.js';
|
|
2
|
+
|
|
3
|
+
const url = process.argv[2];
|
|
4
|
+
const kind = process.argv[3] || 'muxed';
|
|
5
|
+
const height = process.argv[4]
|
|
6
|
+
? Number(String(process.argv[4]).replace(/p$/i, ''))
|
|
7
|
+
: undefined;
|
|
8
|
+
const audioFormat = process.argv[5] || 'm4a';
|
|
9
|
+
|
|
10
|
+
if (!url) {
|
|
11
|
+
console.log('Uso: node download-test.js "URL_DO_YOUTUBE" [audio|video|video-audio|muxed] [altura] [m4a|mp3]');
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!['audio', 'video', 'video-audio', 'muxed'].includes(kind)) {
|
|
16
|
+
console.error('Tipo inválido. Use audio, video, video-audio ou muxed.');
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!['m4a', 'mp3'].includes(audioFormat)) {
|
|
21
|
+
console.error('Formato de áudio inválido. Use m4a ou mp3.');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (height !== undefined && !Number.isFinite(height)) {
|
|
26
|
+
console.error('Altura inválida. Use, por exemplo, 360, 720 ou 720p.');
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
console.log('\n[EXPERIMENTAL] Consultando e baixando mídia do YouTube...\n');
|
|
32
|
+
|
|
33
|
+
const downloadOptions = {
|
|
34
|
+
height,
|
|
35
|
+
audioFormat,
|
|
36
|
+
outputDir: './downloads',
|
|
37
|
+
cookieFile: process.env.YOUTUBE_COOKIE_FILE,
|
|
38
|
+
poToken: process.env.YOUTUBE_GVS_PO_TOKEN
|
|
39
|
+
};
|
|
40
|
+
const result = kind === 'video-audio'
|
|
41
|
+
? await YouTube.downloadAudioFromVideo(url, downloadOptions)
|
|
42
|
+
: await YouTube.downloadMedia(url, { ...downloadOptions, kind });
|
|
43
|
+
|
|
44
|
+
console.log('\n=== DOWNLOAD OK ===');
|
|
45
|
+
console.log('Título:', result.title);
|
|
46
|
+
console.log('Tipo:', result.kind);
|
|
47
|
+
if (result.kind === 'video') console.log('Áudio combinado:', 'sim');
|
|
48
|
+
if (result.sourceKind === 'video') console.log('Fonte processada:', 'vídeo baixado e áudio extraído com FFmpeg');
|
|
49
|
+
if (result.audioFormat) console.log('Formato de áudio:', result.audioFormat);
|
|
50
|
+
console.log('Itag:', result.format.itag);
|
|
51
|
+
console.log('Formato original:', result.format.mimeType);
|
|
52
|
+
console.log('Qualidade:', result.format.quality || 'n/a');
|
|
53
|
+
console.log('Arquivo:', result.path);
|
|
54
|
+
console.log('Tamanho:', result.size, 'bytes');
|
|
55
|
+
if (result.format?.sourceClient) console.log('Cliente:', result.format.sourceClient);
|
|
56
|
+
console.log('PO token aplicado:', result.format?.poTokenApplied ? 'sim' : 'não');
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error('\nERRO:', error.message);
|
|
59
|
+
if (process.env.DEBUG) console.error(error.stack);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|