Adiciona ferramenta VideoMind para transcrição e análise de vídeos com resumo customizado
This commit is contained in:
+9
@@ -1392,6 +1392,15 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.4"
|
"node": ">=0.4"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/youtube-transcript": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/youtube-transcript/-/youtube-transcript-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-NDCjwad113TGybbYF51y9Z4tcwzBHUZWQdF9veULNca18L+FdDbHHtTHIr69WVa3bB90l67S8kN0HtL2JO9fhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
# youtube-transcript
|
||||||
|
|
||||||
|
[](https://badge.fury.io/js/youtube-transcript)
|
||||||
|
|
||||||
|
I wanted to extract a transcript from a youtube video but there was only a python script so I created this to do it in node.
|
||||||
|
This package use unofficial YTB API so it can be broken over the time if no update appears.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm i youtube-transcript
|
||||||
|
```
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ yarn add youtube-transcript
|
||||||
|
```
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ pnpm add youtube-transcript
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { fetchTranscript } from 'youtube-transcript';
|
||||||
|
|
||||||
|
fetchTranscript('videoId or URL').then(console.log);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
- fetchTranscript(videoId: string [,options: TranscriptConfig]): Promise<TranscriptResponse[]>;
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
**[MIT](LICENSE)** Licensed
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
export declare class YoutubeTranscriptError extends Error {
|
||||||
|
constructor(message: any);
|
||||||
|
}
|
||||||
|
export declare class YoutubeTranscriptTooManyRequestError extends YoutubeTranscriptError {
|
||||||
|
constructor();
|
||||||
|
}
|
||||||
|
export declare class YoutubeTranscriptVideoUnavailableError extends YoutubeTranscriptError {
|
||||||
|
constructor(videoId: string);
|
||||||
|
}
|
||||||
|
export declare class YoutubeTranscriptDisabledError extends YoutubeTranscriptError {
|
||||||
|
constructor(videoId: string);
|
||||||
|
}
|
||||||
|
export declare class YoutubeTranscriptNotAvailableError extends YoutubeTranscriptError {
|
||||||
|
constructor(videoId: string);
|
||||||
|
}
|
||||||
|
export declare class YoutubeTranscriptNotAvailableLanguageError extends YoutubeTranscriptError {
|
||||||
|
constructor(lang: string, availableLangs: string[], videoId: string);
|
||||||
|
}
|
||||||
|
export interface TranscriptConfig {
|
||||||
|
lang?: string;
|
||||||
|
fetch?: typeof globalThis.fetch;
|
||||||
|
}
|
||||||
|
export interface TranscriptResponse {
|
||||||
|
text: string;
|
||||||
|
duration: number;
|
||||||
|
offset: number;
|
||||||
|
lang?: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Class to retrieve transcript if exist
|
||||||
|
*/
|
||||||
|
export declare class YoutubeTranscript {
|
||||||
|
/**
|
||||||
|
* Fetch transcript from YTB Video
|
||||||
|
* @param videoId Video url or video identifier
|
||||||
|
* @param config Get transcript in a specific language ISO
|
||||||
|
*/
|
||||||
|
static fetchTranscript(videoId: string, config?: TranscriptConfig): Promise<TranscriptResponse[]>;
|
||||||
|
/**
|
||||||
|
* Fetch transcript via the InnerTube API (Android client context)
|
||||||
|
*/
|
||||||
|
private static fetchViaInnerTube;
|
||||||
|
/**
|
||||||
|
* Fetch transcript via web page HTML scraping (fallback)
|
||||||
|
*/
|
||||||
|
private static fetchViaWebPage;
|
||||||
|
/**
|
||||||
|
* Extract a JSON object assigned to a global variable in inline script tags
|
||||||
|
*/
|
||||||
|
private static parseInlineJson;
|
||||||
|
/**
|
||||||
|
* Given caption tracks, select the right one, fetch and parse the transcript XML
|
||||||
|
*/
|
||||||
|
private static fetchTranscriptFromTracks;
|
||||||
|
/**
|
||||||
|
* Parse transcript XML, supporting both srv3 format (<p t="ms">) and
|
||||||
|
* classic format (<text start="s" dur="s">)
|
||||||
|
*/
|
||||||
|
private static parseTranscriptXml;
|
||||||
|
/**
|
||||||
|
* Decode common HTML entities in transcript text
|
||||||
|
*/
|
||||||
|
private static decodeEntities;
|
||||||
|
/**
|
||||||
|
* Retrieve video id from url or string
|
||||||
|
* @param videoId video url or video id
|
||||||
|
*/
|
||||||
|
private static retrieveVideoId;
|
||||||
|
}
|
||||||
|
export declare function fetchTranscript(videoId: string, config?: TranscriptConfig): Promise<TranscriptResponse[]>;
|
||||||
+269
@@ -0,0 +1,269 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.YoutubeTranscript = exports.YoutubeTranscriptNotAvailableLanguageError = exports.YoutubeTranscriptNotAvailableError = exports.YoutubeTranscriptDisabledError = exports.YoutubeTranscriptVideoUnavailableError = exports.YoutubeTranscriptTooManyRequestError = exports.YoutubeTranscriptError = void 0;
|
||||||
|
exports.fetchTranscript = fetchTranscript;
|
||||||
|
const RE_YOUTUBE = /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/i;
|
||||||
|
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36,gzip(gfe)';
|
||||||
|
const RE_XML_TRANSCRIPT = /<text start="([^"]*)" dur="([^"]*)">([^<]*)<\/text>/g;
|
||||||
|
const INNERTUBE_API_URL = 'https://www.youtube.com/youtubei/v1/player?prettyPrint=false';
|
||||||
|
const INNERTUBE_CLIENT_VERSION = '20.10.38';
|
||||||
|
const INNERTUBE_CONTEXT = {
|
||||||
|
client: {
|
||||||
|
clientName: 'ANDROID',
|
||||||
|
clientVersion: INNERTUBE_CLIENT_VERSION,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const INNERTUBE_USER_AGENT = `com.google.android.youtube/${INNERTUBE_CLIENT_VERSION} (Linux; U; Android 14)`;
|
||||||
|
class YoutubeTranscriptError extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super(`[YoutubeTranscript] 🚨 ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.YoutubeTranscriptError = YoutubeTranscriptError;
|
||||||
|
class YoutubeTranscriptTooManyRequestError extends YoutubeTranscriptError {
|
||||||
|
constructor() {
|
||||||
|
super('YouTube is receiving too many requests from this IP and now requires solving a captcha to continue');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.YoutubeTranscriptTooManyRequestError = YoutubeTranscriptTooManyRequestError;
|
||||||
|
class YoutubeTranscriptVideoUnavailableError extends YoutubeTranscriptError {
|
||||||
|
constructor(videoId) {
|
||||||
|
super(`The video is no longer available (${videoId})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.YoutubeTranscriptVideoUnavailableError = YoutubeTranscriptVideoUnavailableError;
|
||||||
|
class YoutubeTranscriptDisabledError extends YoutubeTranscriptError {
|
||||||
|
constructor(videoId) {
|
||||||
|
super(`Transcript is disabled on this video (${videoId})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.YoutubeTranscriptDisabledError = YoutubeTranscriptDisabledError;
|
||||||
|
class YoutubeTranscriptNotAvailableError extends YoutubeTranscriptError {
|
||||||
|
constructor(videoId) {
|
||||||
|
super(`No transcripts are available for this video (${videoId})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.YoutubeTranscriptNotAvailableError = YoutubeTranscriptNotAvailableError;
|
||||||
|
class YoutubeTranscriptNotAvailableLanguageError extends YoutubeTranscriptError {
|
||||||
|
constructor(lang, availableLangs, videoId) {
|
||||||
|
super(`No transcripts are available in ${lang} this video (${videoId}). Available languages: ${availableLangs.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.YoutubeTranscriptNotAvailableLanguageError = YoutubeTranscriptNotAvailableLanguageError;
|
||||||
|
/**
|
||||||
|
* Class to retrieve transcript if exist
|
||||||
|
*/
|
||||||
|
class YoutubeTranscript {
|
||||||
|
/**
|
||||||
|
* Fetch transcript from YTB Video
|
||||||
|
* @param videoId Video url or video identifier
|
||||||
|
* @param config Get transcript in a specific language ISO
|
||||||
|
*/
|
||||||
|
static async fetchTranscript(videoId, config) {
|
||||||
|
const identifier = this.retrieveVideoId(videoId);
|
||||||
|
// Try InnerTube API first
|
||||||
|
const innerTubeResult = await this.fetchViaInnerTube(identifier, config);
|
||||||
|
if (innerTubeResult) {
|
||||||
|
return innerTubeResult;
|
||||||
|
}
|
||||||
|
// Fall back to HTML scraping
|
||||||
|
return this.fetchViaWebPage(identifier, videoId, config);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Fetch transcript via the InnerTube API (Android client context)
|
||||||
|
*/
|
||||||
|
static async fetchViaInnerTube(identifier, config) {
|
||||||
|
try {
|
||||||
|
const fetchFn = config?.fetch ?? fetch;
|
||||||
|
const resp = await fetchFn(INNERTUBE_API_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': INNERTUBE_USER_AGENT,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
context: INNERTUBE_CONTEXT,
|
||||||
|
videoId: identifier,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!resp.ok)
|
||||||
|
return undefined;
|
||||||
|
const data = await resp.json();
|
||||||
|
const captionTracks = data?.captions?.playerCaptionsTracklistRenderer?.captionTracks;
|
||||||
|
if (!Array.isArray(captionTracks) || captionTracks.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return this.fetchTranscriptFromTracks(captionTracks, identifier, config);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Fetch transcript via web page HTML scraping (fallback)
|
||||||
|
*/
|
||||||
|
static async fetchViaWebPage(identifier, originalVideoId, config) {
|
||||||
|
const fetchFn = config?.fetch ?? fetch;
|
||||||
|
const videoPageResponse = await fetchFn(`https://www.youtube.com/watch?v=${identifier}`, {
|
||||||
|
headers: {
|
||||||
|
...(config?.lang && { 'Accept-Language': config.lang }),
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const videoPageBody = await videoPageResponse.text();
|
||||||
|
if (videoPageBody.includes('class="g-recaptcha"')) {
|
||||||
|
throw new YoutubeTranscriptTooManyRequestError();
|
||||||
|
}
|
||||||
|
if (!videoPageBody.includes('"playabilityStatus":')) {
|
||||||
|
throw new YoutubeTranscriptVideoUnavailableError(originalVideoId);
|
||||||
|
}
|
||||||
|
const playerResponse = this.parseInlineJson(videoPageBody, 'ytInitialPlayerResponse');
|
||||||
|
const captionTracks = playerResponse?.captions?.playerCaptionsTracklistRenderer?.captionTracks;
|
||||||
|
if (!Array.isArray(captionTracks) || captionTracks.length === 0) {
|
||||||
|
throw new YoutubeTranscriptDisabledError(originalVideoId);
|
||||||
|
}
|
||||||
|
return this.fetchTranscriptFromTracks(captionTracks, originalVideoId, config);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Extract a JSON object assigned to a global variable in inline script tags
|
||||||
|
*/
|
||||||
|
static parseInlineJson(html, globalName) {
|
||||||
|
const startToken = `var ${globalName} = `;
|
||||||
|
const startIndex = html.indexOf(startToken);
|
||||||
|
if (startIndex === -1)
|
||||||
|
return null;
|
||||||
|
const jsonStart = startIndex + startToken.length;
|
||||||
|
let depth = 0;
|
||||||
|
for (let i = jsonStart; i < html.length; i++) {
|
||||||
|
if (html[i] === '{')
|
||||||
|
depth++;
|
||||||
|
else if (html[i] === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(html.slice(jsonStart, i + 1));
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Given caption tracks, select the right one, fetch and parse the transcript XML
|
||||||
|
*/
|
||||||
|
static async fetchTranscriptFromTracks(captionTracks, videoId, config) {
|
||||||
|
if (config?.lang &&
|
||||||
|
!captionTracks.some((track) => track.languageCode === config?.lang)) {
|
||||||
|
throw new YoutubeTranscriptNotAvailableLanguageError(config?.lang, captionTracks.map((track) => track.languageCode), videoId);
|
||||||
|
}
|
||||||
|
const track = config?.lang
|
||||||
|
? captionTracks.find((track) => track.languageCode === config?.lang)
|
||||||
|
: captionTracks[0];
|
||||||
|
const transcriptURL = track.baseUrl;
|
||||||
|
// Validate URL to prevent SSRF
|
||||||
|
try {
|
||||||
|
const captionUrl = new URL(transcriptURL);
|
||||||
|
if (!captionUrl.hostname.endsWith('.youtube.com')) {
|
||||||
|
throw new YoutubeTranscriptNotAvailableError(videoId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
if (e instanceof YoutubeTranscriptError)
|
||||||
|
throw e;
|
||||||
|
throw new YoutubeTranscriptNotAvailableError(videoId);
|
||||||
|
}
|
||||||
|
const fetchFn = config?.fetch ?? fetch;
|
||||||
|
const transcriptResponse = await fetchFn(transcriptURL, {
|
||||||
|
headers: {
|
||||||
|
...(config?.lang && { 'Accept-Language': config.lang }),
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!transcriptResponse.ok) {
|
||||||
|
throw new YoutubeTranscriptNotAvailableError(videoId);
|
||||||
|
}
|
||||||
|
const transcriptBody = await transcriptResponse.text();
|
||||||
|
const lang = config?.lang ?? captionTracks[0].languageCode;
|
||||||
|
return this.parseTranscriptXml(transcriptBody, lang);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Parse transcript XML, supporting both srv3 format (<p t="ms">) and
|
||||||
|
* classic format (<text start="s" dur="s">)
|
||||||
|
*/
|
||||||
|
static parseTranscriptXml(xml, lang) {
|
||||||
|
const results = [];
|
||||||
|
// Try srv3 format first: <p t="ms" d="ms"><s>word</s>...</p>
|
||||||
|
const pRegex = /<p\s+t="(\d+)"\s+d="(\d+)"[^>]*>([\s\S]*?)<\/p>/g;
|
||||||
|
let match;
|
||||||
|
while ((match = pRegex.exec(xml)) !== null) {
|
||||||
|
const startMs = parseInt(match[1], 10);
|
||||||
|
const durMs = parseInt(match[2], 10);
|
||||||
|
const inner = match[3];
|
||||||
|
let text = '';
|
||||||
|
const sRegex = /<s[^>]*>([^<]*)<\/s>/g;
|
||||||
|
let sMatch;
|
||||||
|
while ((sMatch = sRegex.exec(inner)) !== null) {
|
||||||
|
text += sMatch[1];
|
||||||
|
}
|
||||||
|
if (!text) {
|
||||||
|
text = inner.replace(/<[^>]+>/g, '');
|
||||||
|
}
|
||||||
|
text = this.decodeEntities(text).trim();
|
||||||
|
if (text) {
|
||||||
|
results.push({
|
||||||
|
text,
|
||||||
|
duration: durMs,
|
||||||
|
offset: startMs,
|
||||||
|
lang,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (results.length > 0)
|
||||||
|
return results;
|
||||||
|
// Fall back to classic format: <text start="s" dur="s">content</text>
|
||||||
|
const classicResults = [...xml.matchAll(RE_XML_TRANSCRIPT)];
|
||||||
|
return classicResults.map((result) => ({
|
||||||
|
text: this.decodeEntities(result[3]),
|
||||||
|
duration: parseFloat(result[2]),
|
||||||
|
offset: parseFloat(result[1]),
|
||||||
|
lang,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Decode common HTML entities in transcript text
|
||||||
|
*/
|
||||||
|
static decodeEntities(text) {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
|
||||||
|
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Retrieve video id from url or string
|
||||||
|
* @param videoId video url or video id
|
||||||
|
*/
|
||||||
|
static retrieveVideoId(videoId) {
|
||||||
|
if (videoId.length === 11) {
|
||||||
|
return videoId;
|
||||||
|
}
|
||||||
|
const matchId = videoId.match(RE_YOUTUBE);
|
||||||
|
if (matchId && matchId.length) {
|
||||||
|
return matchId[1];
|
||||||
|
}
|
||||||
|
throw new YoutubeTranscriptError('Impossible to retrieve Youtube video ID.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.YoutubeTranscript = YoutubeTranscript;
|
||||||
|
//^ class is kept for backward compatibility
|
||||||
|
function fetchTranscript(videoId, config) {
|
||||||
|
return YoutubeTranscript.fetchTranscript(videoId, config);
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=index.js.map
|
||||||
+1
File diff suppressed because one or more lines are too long
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "commonjs"
|
||||||
|
}
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
export declare class YoutubeTranscriptError extends Error {
|
||||||
|
constructor(message: any);
|
||||||
|
}
|
||||||
|
export declare class YoutubeTranscriptTooManyRequestError extends YoutubeTranscriptError {
|
||||||
|
constructor();
|
||||||
|
}
|
||||||
|
export declare class YoutubeTranscriptVideoUnavailableError extends YoutubeTranscriptError {
|
||||||
|
constructor(videoId: string);
|
||||||
|
}
|
||||||
|
export declare class YoutubeTranscriptDisabledError extends YoutubeTranscriptError {
|
||||||
|
constructor(videoId: string);
|
||||||
|
}
|
||||||
|
export declare class YoutubeTranscriptNotAvailableError extends YoutubeTranscriptError {
|
||||||
|
constructor(videoId: string);
|
||||||
|
}
|
||||||
|
export declare class YoutubeTranscriptNotAvailableLanguageError extends YoutubeTranscriptError {
|
||||||
|
constructor(lang: string, availableLangs: string[], videoId: string);
|
||||||
|
}
|
||||||
|
export interface TranscriptConfig {
|
||||||
|
lang?: string;
|
||||||
|
fetch?: typeof globalThis.fetch;
|
||||||
|
}
|
||||||
|
export interface TranscriptResponse {
|
||||||
|
text: string;
|
||||||
|
duration: number;
|
||||||
|
offset: number;
|
||||||
|
lang?: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Class to retrieve transcript if exist
|
||||||
|
*/
|
||||||
|
export declare class YoutubeTranscript {
|
||||||
|
/**
|
||||||
|
* Fetch transcript from YTB Video
|
||||||
|
* @param videoId Video url or video identifier
|
||||||
|
* @param config Get transcript in a specific language ISO
|
||||||
|
*/
|
||||||
|
static fetchTranscript(videoId: string, config?: TranscriptConfig): Promise<TranscriptResponse[]>;
|
||||||
|
/**
|
||||||
|
* Fetch transcript via the InnerTube API (Android client context)
|
||||||
|
*/
|
||||||
|
private static fetchViaInnerTube;
|
||||||
|
/**
|
||||||
|
* Fetch transcript via web page HTML scraping (fallback)
|
||||||
|
*/
|
||||||
|
private static fetchViaWebPage;
|
||||||
|
/**
|
||||||
|
* Extract a JSON object assigned to a global variable in inline script tags
|
||||||
|
*/
|
||||||
|
private static parseInlineJson;
|
||||||
|
/**
|
||||||
|
* Given caption tracks, select the right one, fetch and parse the transcript XML
|
||||||
|
*/
|
||||||
|
private static fetchTranscriptFromTracks;
|
||||||
|
/**
|
||||||
|
* Parse transcript XML, supporting both srv3 format (<p t="ms">) and
|
||||||
|
* classic format (<text start="s" dur="s">)
|
||||||
|
*/
|
||||||
|
private static parseTranscriptXml;
|
||||||
|
/**
|
||||||
|
* Decode common HTML entities in transcript text
|
||||||
|
*/
|
||||||
|
private static decodeEntities;
|
||||||
|
/**
|
||||||
|
* Retrieve video id from url or string
|
||||||
|
* @param videoId video url or video id
|
||||||
|
*/
|
||||||
|
private static retrieveVideoId;
|
||||||
|
}
|
||||||
|
export declare function fetchTranscript(videoId: string, config?: TranscriptConfig): Promise<TranscriptResponse[]>;
|
||||||
+258
@@ -0,0 +1,258 @@
|
|||||||
|
const RE_YOUTUBE = /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/i;
|
||||||
|
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36,gzip(gfe)';
|
||||||
|
const RE_XML_TRANSCRIPT = /<text start="([^"]*)" dur="([^"]*)">([^<]*)<\/text>/g;
|
||||||
|
const INNERTUBE_API_URL = 'https://www.youtube.com/youtubei/v1/player?prettyPrint=false';
|
||||||
|
const INNERTUBE_CLIENT_VERSION = '20.10.38';
|
||||||
|
const INNERTUBE_CONTEXT = {
|
||||||
|
client: {
|
||||||
|
clientName: 'ANDROID',
|
||||||
|
clientVersion: INNERTUBE_CLIENT_VERSION,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const INNERTUBE_USER_AGENT = `com.google.android.youtube/${INNERTUBE_CLIENT_VERSION} (Linux; U; Android 14)`;
|
||||||
|
export class YoutubeTranscriptError extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super(`[YoutubeTranscript] 🚨 ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class YoutubeTranscriptTooManyRequestError extends YoutubeTranscriptError {
|
||||||
|
constructor() {
|
||||||
|
super('YouTube is receiving too many requests from this IP and now requires solving a captcha to continue');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class YoutubeTranscriptVideoUnavailableError extends YoutubeTranscriptError {
|
||||||
|
constructor(videoId) {
|
||||||
|
super(`The video is no longer available (${videoId})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class YoutubeTranscriptDisabledError extends YoutubeTranscriptError {
|
||||||
|
constructor(videoId) {
|
||||||
|
super(`Transcript is disabled on this video (${videoId})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class YoutubeTranscriptNotAvailableError extends YoutubeTranscriptError {
|
||||||
|
constructor(videoId) {
|
||||||
|
super(`No transcripts are available for this video (${videoId})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class YoutubeTranscriptNotAvailableLanguageError extends YoutubeTranscriptError {
|
||||||
|
constructor(lang, availableLangs, videoId) {
|
||||||
|
super(`No transcripts are available in ${lang} this video (${videoId}). Available languages: ${availableLangs.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Class to retrieve transcript if exist
|
||||||
|
*/
|
||||||
|
export class YoutubeTranscript {
|
||||||
|
/**
|
||||||
|
* Fetch transcript from YTB Video
|
||||||
|
* @param videoId Video url or video identifier
|
||||||
|
* @param config Get transcript in a specific language ISO
|
||||||
|
*/
|
||||||
|
static async fetchTranscript(videoId, config) {
|
||||||
|
const identifier = this.retrieveVideoId(videoId);
|
||||||
|
// Try InnerTube API first
|
||||||
|
const innerTubeResult = await this.fetchViaInnerTube(identifier, config);
|
||||||
|
if (innerTubeResult) {
|
||||||
|
return innerTubeResult;
|
||||||
|
}
|
||||||
|
// Fall back to HTML scraping
|
||||||
|
return this.fetchViaWebPage(identifier, videoId, config);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Fetch transcript via the InnerTube API (Android client context)
|
||||||
|
*/
|
||||||
|
static async fetchViaInnerTube(identifier, config) {
|
||||||
|
try {
|
||||||
|
const fetchFn = config?.fetch ?? fetch;
|
||||||
|
const resp = await fetchFn(INNERTUBE_API_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': INNERTUBE_USER_AGENT,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
context: INNERTUBE_CONTEXT,
|
||||||
|
videoId: identifier,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!resp.ok)
|
||||||
|
return undefined;
|
||||||
|
const data = await resp.json();
|
||||||
|
const captionTracks = data?.captions?.playerCaptionsTracklistRenderer?.captionTracks;
|
||||||
|
if (!Array.isArray(captionTracks) || captionTracks.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return this.fetchTranscriptFromTracks(captionTracks, identifier, config);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Fetch transcript via web page HTML scraping (fallback)
|
||||||
|
*/
|
||||||
|
static async fetchViaWebPage(identifier, originalVideoId, config) {
|
||||||
|
const fetchFn = config?.fetch ?? fetch;
|
||||||
|
const videoPageResponse = await fetchFn(`https://www.youtube.com/watch?v=${identifier}`, {
|
||||||
|
headers: {
|
||||||
|
...(config?.lang && { 'Accept-Language': config.lang }),
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const videoPageBody = await videoPageResponse.text();
|
||||||
|
if (videoPageBody.includes('class="g-recaptcha"')) {
|
||||||
|
throw new YoutubeTranscriptTooManyRequestError();
|
||||||
|
}
|
||||||
|
if (!videoPageBody.includes('"playabilityStatus":')) {
|
||||||
|
throw new YoutubeTranscriptVideoUnavailableError(originalVideoId);
|
||||||
|
}
|
||||||
|
const playerResponse = this.parseInlineJson(videoPageBody, 'ytInitialPlayerResponse');
|
||||||
|
const captionTracks = playerResponse?.captions?.playerCaptionsTracklistRenderer?.captionTracks;
|
||||||
|
if (!Array.isArray(captionTracks) || captionTracks.length === 0) {
|
||||||
|
throw new YoutubeTranscriptDisabledError(originalVideoId);
|
||||||
|
}
|
||||||
|
return this.fetchTranscriptFromTracks(captionTracks, originalVideoId, config);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Extract a JSON object assigned to a global variable in inline script tags
|
||||||
|
*/
|
||||||
|
static parseInlineJson(html, globalName) {
|
||||||
|
const startToken = `var ${globalName} = `;
|
||||||
|
const startIndex = html.indexOf(startToken);
|
||||||
|
if (startIndex === -1)
|
||||||
|
return null;
|
||||||
|
const jsonStart = startIndex + startToken.length;
|
||||||
|
let depth = 0;
|
||||||
|
for (let i = jsonStart; i < html.length; i++) {
|
||||||
|
if (html[i] === '{')
|
||||||
|
depth++;
|
||||||
|
else if (html[i] === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(html.slice(jsonStart, i + 1));
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Given caption tracks, select the right one, fetch and parse the transcript XML
|
||||||
|
*/
|
||||||
|
static async fetchTranscriptFromTracks(captionTracks, videoId, config) {
|
||||||
|
if (config?.lang &&
|
||||||
|
!captionTracks.some((track) => track.languageCode === config?.lang)) {
|
||||||
|
throw new YoutubeTranscriptNotAvailableLanguageError(config?.lang, captionTracks.map((track) => track.languageCode), videoId);
|
||||||
|
}
|
||||||
|
const track = config?.lang
|
||||||
|
? captionTracks.find((track) => track.languageCode === config?.lang)
|
||||||
|
: captionTracks[0];
|
||||||
|
const transcriptURL = track.baseUrl;
|
||||||
|
// Validate URL to prevent SSRF
|
||||||
|
try {
|
||||||
|
const captionUrl = new URL(transcriptURL);
|
||||||
|
if (!captionUrl.hostname.endsWith('.youtube.com')) {
|
||||||
|
throw new YoutubeTranscriptNotAvailableError(videoId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
if (e instanceof YoutubeTranscriptError)
|
||||||
|
throw e;
|
||||||
|
throw new YoutubeTranscriptNotAvailableError(videoId);
|
||||||
|
}
|
||||||
|
const fetchFn = config?.fetch ?? fetch;
|
||||||
|
const transcriptResponse = await fetchFn(transcriptURL, {
|
||||||
|
headers: {
|
||||||
|
...(config?.lang && { 'Accept-Language': config.lang }),
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!transcriptResponse.ok) {
|
||||||
|
throw new YoutubeTranscriptNotAvailableError(videoId);
|
||||||
|
}
|
||||||
|
const transcriptBody = await transcriptResponse.text();
|
||||||
|
const lang = config?.lang ?? captionTracks[0].languageCode;
|
||||||
|
return this.parseTranscriptXml(transcriptBody, lang);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Parse transcript XML, supporting both srv3 format (<p t="ms">) and
|
||||||
|
* classic format (<text start="s" dur="s">)
|
||||||
|
*/
|
||||||
|
static parseTranscriptXml(xml, lang) {
|
||||||
|
const results = [];
|
||||||
|
// Try srv3 format first: <p t="ms" d="ms"><s>word</s>...</p>
|
||||||
|
const pRegex = /<p\s+t="(\d+)"\s+d="(\d+)"[^>]*>([\s\S]*?)<\/p>/g;
|
||||||
|
let match;
|
||||||
|
while ((match = pRegex.exec(xml)) !== null) {
|
||||||
|
const startMs = parseInt(match[1], 10);
|
||||||
|
const durMs = parseInt(match[2], 10);
|
||||||
|
const inner = match[3];
|
||||||
|
let text = '';
|
||||||
|
const sRegex = /<s[^>]*>([^<]*)<\/s>/g;
|
||||||
|
let sMatch;
|
||||||
|
while ((sMatch = sRegex.exec(inner)) !== null) {
|
||||||
|
text += sMatch[1];
|
||||||
|
}
|
||||||
|
if (!text) {
|
||||||
|
text = inner.replace(/<[^>]+>/g, '');
|
||||||
|
}
|
||||||
|
text = this.decodeEntities(text).trim();
|
||||||
|
if (text) {
|
||||||
|
results.push({
|
||||||
|
text,
|
||||||
|
duration: durMs,
|
||||||
|
offset: startMs,
|
||||||
|
lang,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (results.length > 0)
|
||||||
|
return results;
|
||||||
|
// Fall back to classic format: <text start="s" dur="s">content</text>
|
||||||
|
const classicResults = [...xml.matchAll(RE_XML_TRANSCRIPT)];
|
||||||
|
return classicResults.map((result) => ({
|
||||||
|
text: this.decodeEntities(result[3]),
|
||||||
|
duration: parseFloat(result[2]),
|
||||||
|
offset: parseFloat(result[1]),
|
||||||
|
lang,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Decode common HTML entities in transcript text
|
||||||
|
*/
|
||||||
|
static decodeEntities(text) {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
|
||||||
|
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Retrieve video id from url or string
|
||||||
|
* @param videoId video url or video id
|
||||||
|
*/
|
||||||
|
static retrieveVideoId(videoId) {
|
||||||
|
if (videoId.length === 11) {
|
||||||
|
return videoId;
|
||||||
|
}
|
||||||
|
const matchId = videoId.match(RE_YOUTUBE);
|
||||||
|
if (matchId && matchId.length) {
|
||||||
|
return matchId[1];
|
||||||
|
}
|
||||||
|
throw new YoutubeTranscriptError('Impossible to retrieve Youtube video ID.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//^ class is kept for backward compatibility
|
||||||
|
export function fetchTranscript(videoId, config) {
|
||||||
|
return YoutubeTranscript.fetchTranscript(videoId, config);
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=index.js.map
|
||||||
+1
File diff suppressed because one or more lines are too long
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"name": "youtube-transcript",
|
||||||
|
"version": "1.3.1",
|
||||||
|
"description": "Fetch transcript from a youtube video",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/commonjs/index.js",
|
||||||
|
"module": "./dist/esm/index.js",
|
||||||
|
"types": "./dist/commonjs/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
"./package.json": "./package.json",
|
||||||
|
".": {
|
||||||
|
"import": {
|
||||||
|
"types": "./dist/esm/index.d.ts",
|
||||||
|
"default": "./dist/esm/index.js"
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"types": "./dist/commonjs/index.d.ts",
|
||||||
|
"default": "./dist/commonjs/index.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tshy": {
|
||||||
|
"exports": {
|
||||||
|
"./package.json": "./package.json",
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"author": "Kakulukian",
|
||||||
|
"keywords": [
|
||||||
|
"youtube",
|
||||||
|
"transcript"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"tshy": "^3.0.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"repository": "https://github.com/Kakulukian/youtube-transcript.git",
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/Kakulukian/youtube-transcript",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tshy"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+11
-1
@@ -13,7 +13,8 @@
|
|||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"msedge-tts": "^2.0.5",
|
"msedge-tts": "^2.0.5",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"pg": "^8.21.0"
|
"pg": "^8.21.0",
|
||||||
|
"youtube-transcript": "^1.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
@@ -1404,6 +1405,15 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.4"
|
"node": ">=0.4"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/youtube-transcript": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/youtube-transcript/-/youtube-transcript-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-NDCjwad113TGybbYF51y9Z4tcwzBHUZWQdF9veULNca18L+FdDbHHtTHIr69WVa3bB90l67S8kN0HtL2JO9fhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -13,6 +13,7 @@
|
|||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"msedge-tts": "^2.0.5",
|
"msedge-tts": "^2.0.5",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"pg": "^8.21.0"
|
"pg": "^8.21.0",
|
||||||
|
"youtube-transcript": "^1.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+227
@@ -4489,6 +4489,233 @@ const initApp = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// VIDEOMIND (TRANSCRIÇÃO E ANÁLISE DE VÍDEO PEDAGÓGICO)
|
||||||
|
// ============================================================
|
||||||
|
const videoMindModal = document.getElementById('videoMindModal');
|
||||||
|
const btnCloseVideoMindModal = document.getElementById('btnCloseVideoMindModal');
|
||||||
|
const barBtnVideoMind = document.getElementById('barBtnVideoMind');
|
||||||
|
|
||||||
|
const btnGenerateVideoMind = document.getElementById('btnGenerateVideoMind');
|
||||||
|
const videoMindUrlInput = document.getElementById('videoMindUrlInput');
|
||||||
|
const videoMindLoader = document.getElementById('videoMindLoader');
|
||||||
|
const videoMindLoaderText = document.getElementById('videoMindLoaderText');
|
||||||
|
const videoMindResultArea = document.getElementById('videoMindResultArea');
|
||||||
|
|
||||||
|
const videoMindThumbnail = document.getElementById('videoMindThumbnail');
|
||||||
|
const videoMindTitle = document.getElementById('videoMindTitle');
|
||||||
|
const videoMindAuthor = document.getElementById('videoMindAuthor');
|
||||||
|
const videoMindReportArea = document.getElementById('videoMindReportArea');
|
||||||
|
|
||||||
|
const btnCopyVideoMindReport = document.getElementById('btnCopyVideoMindReport');
|
||||||
|
const btnDownloadVideoMindReport = document.getElementById('btnDownloadVideoMindReport');
|
||||||
|
const btnPdfVideoMindReport = document.getElementById('btnPdfVideoMindReport');
|
||||||
|
|
||||||
|
let selectedVideoMindLines = 20;
|
||||||
|
let activeVideoMindReport = null;
|
||||||
|
|
||||||
|
// Listener para selecionar o tamanho do resumo
|
||||||
|
document.querySelectorAll('#videoMindModal .music-option-grid .btn-videomind-lines').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('#videoMindModal .music-option-grid .btn-videomind-lines').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
selectedVideoMindLines = parseInt(btn.dataset.lines) || 20;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Abrir modal do VideoMind
|
||||||
|
if (barBtnVideoMind) {
|
||||||
|
barBtnVideoMind.addEventListener('click', () => {
|
||||||
|
videoMindModal.style.display = 'flex';
|
||||||
|
videoMindUrlInput.value = '';
|
||||||
|
videoMindResultArea.style.display = 'none';
|
||||||
|
videoMindReportArea.innerHTML = '';
|
||||||
|
if (videoMindLoader) videoMindLoader.style.display = 'none';
|
||||||
|
btnGenerateVideoMind.disabled = false;
|
||||||
|
activeVideoMindReport = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fechar modal
|
||||||
|
if (btnCloseVideoMindModal) {
|
||||||
|
btnCloseVideoMindModal.addEventListener('click', () => {
|
||||||
|
videoMindModal.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('click', (e) => {
|
||||||
|
if (e.target === videoMindModal) {
|
||||||
|
videoMindModal.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ação de analisar vídeo
|
||||||
|
if (btnGenerateVideoMind) {
|
||||||
|
btnGenerateVideoMind.addEventListener('click', async () => {
|
||||||
|
const url = videoMindUrlInput.value.trim();
|
||||||
|
if (!url) {
|
||||||
|
alert('Por favor, insira o link de um vídeo do YouTube.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btnGenerateVideoMind.disabled = true;
|
||||||
|
videoMindLoader.style.display = 'flex';
|
||||||
|
videoMindResultArea.style.display = 'none';
|
||||||
|
videoMindLoaderText.textContent = 'Buscando metadados do vídeo...';
|
||||||
|
|
||||||
|
let statusMsgIdx = 0;
|
||||||
|
const statusMessages = [
|
||||||
|
'Buscando legenda automática do vídeo...',
|
||||||
|
'Carregando a transcrição completa...',
|
||||||
|
'Enviando transcrição para a IA...',
|
||||||
|
'Pedagoga IA analisando o conteúdo...',
|
||||||
|
'Alinhando temas do vídeo com a BNCC...',
|
||||||
|
'Gerando resumo de ' + selectedVideoMindLines + ' linhas...',
|
||||||
|
'Criando propostas de atividades pedagógicas...',
|
||||||
|
'Finalizando o parecer pedagógico...'
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusInterval = setInterval(() => {
|
||||||
|
if (statusMsgIdx < statusMessages.length) {
|
||||||
|
videoMindLoaderText.textContent = statusMessages[statusMsgIdx];
|
||||||
|
statusMsgIdx++;
|
||||||
|
} else {
|
||||||
|
videoMindLoaderText.textContent = 'Ajustando formatação da análise...';
|
||||||
|
}
|
||||||
|
}, 4000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/videomind/analyze', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
url,
|
||||||
|
lines: selectedVideoMindLines
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
clearInterval(statusInterval);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errData = await response.json();
|
||||||
|
throw new Error(errData.error || 'Erro desconhecido');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
activeVideoMindReport = data;
|
||||||
|
|
||||||
|
// Preencher informações do vídeo
|
||||||
|
videoMindTitle.textContent = data.metadata.title;
|
||||||
|
videoMindAuthor.textContent = 'Canal: ' + data.metadata.author;
|
||||||
|
videoMindThumbnail.src = data.metadata.thumbnail;
|
||||||
|
|
||||||
|
// Renderizar Markdown
|
||||||
|
if (window.marked) {
|
||||||
|
videoMindReportArea.innerHTML = marked.parse(data.report);
|
||||||
|
} else {
|
||||||
|
videoMindReportArea.textContent = data.report;
|
||||||
|
}
|
||||||
|
|
||||||
|
videoMindLoader.style.display = 'none';
|
||||||
|
videoMindResultArea.style.display = 'flex';
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
clearInterval(statusInterval);
|
||||||
|
console.error('Erro no VideoMind:', err);
|
||||||
|
alert('Erro ao analisar vídeo: ' + err.message);
|
||||||
|
videoMindLoader.style.display = 'none';
|
||||||
|
btnGenerateVideoMind.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copiar Parecer
|
||||||
|
if (btnCopyVideoMindReport) {
|
||||||
|
btnCopyVideoMindReport.addEventListener('click', () => {
|
||||||
|
if (activeVideoMindReport && activeVideoMindReport.report) {
|
||||||
|
navigator.clipboard.writeText(activeVideoMindReport.report)
|
||||||
|
.then(() => {
|
||||||
|
btnCopyVideoMindReport.textContent = '✅ Copiado!';
|
||||||
|
setTimeout(() => {
|
||||||
|
btnCopyVideoMindReport.textContent = '📋 Copiar Parecer';
|
||||||
|
}, 2000);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Falha ao copiar:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Baixar relatório TXT
|
||||||
|
if (btnDownloadVideoMindReport) {
|
||||||
|
btnDownloadVideoMindReport.addEventListener('click', () => {
|
||||||
|
if (activeVideoMindReport && activeVideoMindReport.report) {
|
||||||
|
const title = activeVideoMindReport.metadata.title || 'analise_video';
|
||||||
|
const blob = new Blob([activeVideoMindReport.report], { type: 'text/plain;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `VideoMind_${title.replace(/\s+/g, '_').toLowerCase()}.txt`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Baixar relatório PDF
|
||||||
|
if (btnPdfVideoMindReport) {
|
||||||
|
btnPdfVideoMindReport.addEventListener('click', () => {
|
||||||
|
if (!activeVideoMindReport) return;
|
||||||
|
|
||||||
|
const printWindow = window.open('', '_blank');
|
||||||
|
printWindow.document.write(`
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>VideoMind - Parecer Pedagógico</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Outfit', 'Inter', -apple-system, sans-serif; padding: 40px; color: #333; line-height: 1.6; }
|
||||||
|
h1, h2, h3 { color: #0891b2; margin-top: 24px; margin-bottom: 12px; }
|
||||||
|
h1 { border-bottom: 2px solid #e2e8f0; padding-bottom: 10px; font-size: 22px; }
|
||||||
|
h2 { font-size: 18px; border-bottom: 1px solid #f1f5f9; padding-bottom: 6px; }
|
||||||
|
.meta-box { background: #f8fafc; padding: 16px; border-radius: 8px; margin-bottom: 24px; font-size: 14px; border: 1px solid #e2e8f0; display: flex; gap: 16px; align-items: center; }
|
||||||
|
.meta-info { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.meta-label { font-weight: bold; color: #475569; }
|
||||||
|
p { margin-bottom: 14px; text-align: justify; }
|
||||||
|
ul, ol { margin-bottom: 14px; padding-left: 20px; }
|
||||||
|
li { margin-bottom: 6px; }
|
||||||
|
@media print {
|
||||||
|
body { padding: 0; }
|
||||||
|
button { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>🎬 VideoMind - Análise & Parecer Pedagógico</h1>
|
||||||
|
<div class="meta-box">
|
||||||
|
<img src="${activeVideoMindReport.metadata.thumbnail}" style="width: 100px; aspect-ratio: 16/9; object-fit: cover; border-radius: 6px;">
|
||||||
|
<div class="meta-info">
|
||||||
|
<div><span class="meta-label">Vídeo:</span> <span>${activeVideoMindReport.metadata.title}</span></div>
|
||||||
|
<div><span class="meta-label">Canal:</span> <span>${activeVideoMindReport.metadata.author}</span></div>
|
||||||
|
<div><span class="meta-label">Resumo Escolhido:</span> <span>${selectedVideoMindLines} Linhas</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>${window.marked ? marked.parse(activeVideoMindReport.report) : activeVideoMindReport.report}</div>
|
||||||
|
<script>
|
||||||
|
window.onload = function() {
|
||||||
|
window.print();
|
||||||
|
setTimeout(() => { window.close(); }, 500);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
printWindow.document.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Player de áudio personalizado global para as mídias geradas
|
// Player de áudio personalizado global para as mídias geradas
|
||||||
|
|||||||
@@ -237,6 +237,9 @@
|
|||||||
<button type="button" class="btn-prompt-mode mode-comics" id="barBtnComics" style="border-color: rgba(236, 72, 153, 0.4); color: #f472b6; background: rgba(236, 72, 153, 0.05);" title="Abrir Fábrica de Quadrinhos Pedagógicos">
|
<button type="button" class="btn-prompt-mode mode-comics" id="barBtnComics" style="border-color: rgba(236, 72, 153, 0.4); color: #f472b6; background: rgba(236, 72, 153, 0.05);" title="Abrir Fábrica de Quadrinhos Pedagógicos">
|
||||||
<span class="mode-icon">🎨</span> Fábrica de Quadrinhos
|
<span class="mode-icon">🎨</span> Fábrica de Quadrinhos
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn-prompt-mode mode-video" id="barBtnVideoMind" style="border-color: rgba(6, 182, 212, 0.4); color: #22d3ee; background: rgba(6, 182, 212, 0.05);" title="Análise pedagógica e resumo de vídeos do YouTube">
|
||||||
|
<span class="mode-icon">🎬</span> VideoMind
|
||||||
|
</button>
|
||||||
<button type="button" class="btn-prompt-mode mode-music" data-mode="AUDIO" title="Compor e gerar uma música pedagógica">
|
<button type="button" class="btn-prompt-mode mode-music" data-mode="AUDIO" title="Compor e gerar uma música pedagógica">
|
||||||
<span class="mode-icon">🎵</span> Criar Letra de Música
|
<span class="mode-icon">🎵</span> Criar Letra de Música
|
||||||
</button>
|
</button>
|
||||||
@@ -938,6 +941,72 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: VideoMind (Transcrição e Resumo Pedagógico de Vídeos) -->
|
||||||
|
<div id="videoMindModal" class="settings-modal" style="display: none;">
|
||||||
|
<div class="settings-modal-content" style="max-width: 700px; width: 95%; max-height: 90vh; display: flex; flex-direction: column;">
|
||||||
|
<div class="settings-modal-header" style="background: var(--bg-secondary); border-bottom: 1px solid var(--border-light);">
|
||||||
|
<h3 style="display: flex; align-items: center; gap: 8px; font-family: 'Outfit', sans-serif;">🎬 VideoMind - Transcrição & Análise de Vídeo</h3>
|
||||||
|
<button id="btnCloseVideoMindModal" class="btn-close-modal" style="background: none; border: none; color: var(--text-secondary); cursor: pointer; font-size: 1.5rem;">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-modal-body" style="padding: 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 18px; flex: 1;">
|
||||||
|
|
||||||
|
<!-- INPUT URL YOUTUBE -->
|
||||||
|
<div class="settings-group">
|
||||||
|
<label style="color: var(--text-secondary); font-size: 0.82rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">1. Link do Vídeo no YouTube</label>
|
||||||
|
<input type="text" id="videoMindUrlInput" placeholder="Ex: https://www.youtube.com/watch?v=..." class="obs-input" style="width: 100%; margin-top: 4px; padding: 10px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TAMANHO DO RESUMO -->
|
||||||
|
<div class="settings-group">
|
||||||
|
<label style="color: var(--text-secondary); font-size: 0.82rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">2. Tamanho do Resumo Desejado</label>
|
||||||
|
<div class="music-option-grid" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 4px;">
|
||||||
|
<button type="button" class="btn-music-option btn-videomind-lines active" data-lines="20">📝 Curto (~20 linhas)</button>
|
||||||
|
<button type="button" class="btn-music-option btn-videomind-lines" data-lines="50">📄 Médio (~50 linhas)</button>
|
||||||
|
<button type="button" class="btn-music-option btn-videomind-lines" data-lines="100">📚 Longo (~100 linhas)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- BOTÃO ANALISAR E STATUS -->
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 10px; margin-top: 8px;">
|
||||||
|
<button id="btnGenerateVideoMind" style="background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%); color: white; border: none; padding: 12px; border-radius: 8px; font-weight: 600; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px; transition: opacity 0.2s; box-shadow: 0 4px 12px rgba(6, 182, 212, 0.25);">
|
||||||
|
<span>🎬 Analisar e Transcrever Vídeo</span>
|
||||||
|
</button>
|
||||||
|
<div id="videoMindLoader" style="display: none; align-items: center; justify-content: center; gap: 10px; color: var(--text-secondary); font-size: 0.9rem; padding: 10px; background: rgba(255, 255, 255, 0.02); border-radius: 8px;">
|
||||||
|
<div class="record-spinner" style="width: 20px; height: 20px; border-width: 2px;"></div>
|
||||||
|
<span id="videoMindLoaderText">Buscando informações do vídeo...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ÁREA DE RESULTADO (INICIALMENTE OCULTA) -->
|
||||||
|
<div id="videoMindResultArea" style="display: none; border-top: 1px solid var(--border-light); padding-top: 16px; flex-direction: column; gap: 18px;">
|
||||||
|
|
||||||
|
<!-- Metadados do Vídeo -->
|
||||||
|
<div style="background: var(--bg-secondary); padding: 14px; border-radius: 12px; display: flex; gap: 14px; border: 1px solid var(--border-light); align-items: center;">
|
||||||
|
<img id="videoMindThumbnail" src="" alt="Thumbnail" style="width: 120px; aspect-ratio: 16/9; object-fit: cover; border-radius: 8px; border: 1px solid var(--border-light);">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
<h4 id="videoMindTitle" style="margin: 0; color: var(--text-primary); font-size: 1rem; font-weight: 600; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;">Título do Vídeo</h4>
|
||||||
|
<span id="videoMindAuthor" style="font-size: 0.85rem; color: var(--text-secondary);">Canal</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ações da Análise -->
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<label style="color: var(--text-secondary); font-size: 0.78rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">Resumo & Parecer Pedagógico</label>
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<button id="btnCopyVideoMindReport" style="background: none; border: 1px solid var(--border-light); color: var(--text-secondary); padding: 5px 10px; border-radius: 6px; font-size: 0.75rem; cursor: pointer; transition: all 0.2s;">📋 Copiar Parecer</button>
|
||||||
|
<button id="btnDownloadVideoMindReport" style="background: none; border: 1px solid var(--border-light); color: var(--text-secondary); padding: 5px 10px; border-radius: 6px; font-size: 0.75rem; cursor: pointer; transition: all 0.2s;">💾 Baixar TXT</button>
|
||||||
|
<button id="btnPdfVideoMindReport" style="background: none; border: 1px solid var(--border-light); color: var(--text-secondary); padding: 5px 10px; border-radius: 6px; font-size: 0.75rem; cursor: pointer; transition: all 0.2s;">📄 Baixar PDF</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Corpo da Análise em Markdown -->
|
||||||
|
<div id="videoMindReportArea" class="report-content-body" style="background: var(--bg-secondary); padding: 16px; border-radius: 10px; max-height: 350px; overflow-y: auto; font-family: inherit; font-size: 0.95rem; line-height: 1.6; border: 1px solid var(--border-light); color: var(--text-primary); margin: 0; box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Bibliotecas externas para renderizar markdown e blocos de código -->
|
<!-- Bibliotecas externas para renderizar markdown e blocos de código -->
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const cookieParser = require('cookie-parser');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const { MsEdgeTTS, OUTPUT_FORMAT } = require('msedge-tts');
|
const { MsEdgeTTS, OUTPUT_FORMAT } = require('msedge-tts');
|
||||||
|
const { YoutubeTranscript } = require('youtube-transcript');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
@@ -691,6 +692,103 @@ app.delete('/api/comics/projects/:id', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ROTAS DO VIDEOMIND (TRANSCRIÇÃO E ANÁLISE DE VÍDEO)
|
||||||
|
// ============================================================
|
||||||
|
function getYouTubeId(url) {
|
||||||
|
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
|
||||||
|
const match = url.match(regExp);
|
||||||
|
return (match && match[2].length === 11) ? match[2] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post('/api/videomind/analyze', requireAuth, async (req, res) => {
|
||||||
|
const { url, lines } = req.body;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return res.status(400).json({ error: 'A URL do vídeo é obrigatória.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoId = getYouTubeId(url);
|
||||||
|
if (!videoId) {
|
||||||
|
return res.status(400).json({ error: 'A URL inserida não é uma URL válida do YouTube.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const numLines = parseInt(lines) || 20;
|
||||||
|
let transcriptText = '';
|
||||||
|
let metadata = {
|
||||||
|
title: 'Vídeo do YouTube',
|
||||||
|
author: 'Desconhecido',
|
||||||
|
thumbnail: `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Tenta obter metadados via oEmbed oficial do YouTube
|
||||||
|
const oEmbedUrl = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`;
|
||||||
|
const oEmbedResp = await fetch(oEmbedUrl);
|
||||||
|
if (oEmbedResp.ok) {
|
||||||
|
const oEmbedData = await oEmbedResp.json();
|
||||||
|
metadata.title = oEmbedData.title || metadata.title;
|
||||||
|
metadata.author = oEmbedData.author_name || metadata.author;
|
||||||
|
metadata.thumbnail = oEmbedData.thumbnail_url || metadata.thumbnail;
|
||||||
|
}
|
||||||
|
} catch (metaErr) {
|
||||||
|
console.error('Erro ao buscar metadados do oEmbed:', metaErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Tenta obter legenda
|
||||||
|
const transcriptObj = await YoutubeTranscript.fetchTranscript(videoId);
|
||||||
|
transcriptText = transcriptObj.map(t => t.text).join(' ');
|
||||||
|
} catch (transcErr) {
|
||||||
|
console.error('Erro ao obter transcrição:', transcErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transcriptText) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Não foi possível extrair as legendas/transcrição automática deste vídeo. Certifique-se de que o vídeo possui legendas ou transcrição automática disponíveis no YouTube e tente novamente.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const prompt = `Analise a transcrição deste vídeo intitulado "${metadata.title}" (Canal: ${metadata.author}):
|
||||||
|
Transcrição do vídeo:
|
||||||
|
"""
|
||||||
|
${transcriptText}
|
||||||
|
"""
|
||||||
|
|
||||||
|
Instruções para o conteúdo de saída:
|
||||||
|
1. Escreva um RESUMO detalhado do vídeo. Esse resumo precisa ter EXATAMENTE ou APROXIMADAMENTE ${numLines} linhas de texto. Seja extremamente fiel aos fatos relatados.
|
||||||
|
2. Faça uma ANÁLISE PEDAGÓGICA COMPLEMENTAR detalhando:
|
||||||
|
- Relevância do conteúdo para fins educacionais.
|
||||||
|
- Indicação de faixa etária/segmento dos alunos recomendado.
|
||||||
|
- Conexão direta com a BNCC (mencione os códigos de competências, habilidades ou campos de experiência correspondentes).
|
||||||
|
- Sugestões práticas de aplicação em sala de aula (atividades pedagógicas, dinâmicas de grupo ou perguntas para debate com os alunos).
|
||||||
|
|
||||||
|
Retorne a resposta estruturada estritamente em Markdown. Separe as seções usando títulos claros:
|
||||||
|
# Resumo Pedagógico (${numLines} Linhas)
|
||||||
|
[Insira o resumo aqui, ocupando aproximadamente ${numLines} linhas de texto]
|
||||||
|
|
||||||
|
# Análise Pedagógica e Sugestões Práticas
|
||||||
|
[Insira a análise pedagógica, os pontos da BNCC e as sugestões de atividades práticas aqui]`;
|
||||||
|
|
||||||
|
const aiResponse = await callMinimax({
|
||||||
|
system: "Você é um pedagogo experiente e especialista em planejamento de aulas no Brasil, alinhado à Base Nacional Comum Curricular (BNCC).",
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
temperature: 0.5,
|
||||||
|
max_tokens: 3000
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
metadata,
|
||||||
|
report: aiResponse.text || ''
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao processar análise do VideoMind:', err);
|
||||||
|
res.status(500).json({ error: 'Erro ao gerar análise com a IA: ' + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Rota GET para obter configurações do agente
|
// Rota GET para obter configurações do agente
|
||||||
app.get('/api/agent-config', requireAuth, (req, res) => {
|
app.get('/api/agent-config', requireAuth, (req, res) => {
|
||||||
const config = readAgentConfig();
|
const config = readAgentConfig();
|
||||||
|
|||||||
Reference in New Issue
Block a user