DEV Community

RF Online Next
RF Online Next

Posted on

Como transcrever e resumir vídeos do YouTube automaticamente com Google Apps Script e Supadata

No iadots.com, eu reúno vídeos sobre IA, agentes, robótica e humanoides em quatro idiomas. Assistir a cada vídeo e escrever um resumo à mão não escala, então montei uma automação simples: um script no Google Apps Script pega a transcrição do vídeo pela API da Supadata, manda o texto para um modelo de linguagem e salva o resumo. Neste tutorial mostro como montar a mesma estrutura do zero, com o código completo e os cuidados que aprendi rodando isso em produção.

O exemplo usa uma planilha do Google como fila de vídeos. No meu caso a fonte são as postagens do Blogger, mas a lógica é a mesma.

Pré-requisitos

  • Uma conta Google, para usar o Apps Script e o Google Sheets.
  • Uma chave de API da Supadata (o plano gratuito serve para testar).
  • Uma chave da API do Gemini, gerada no Google AI Studio. Pode ser outro modelo, desde que você adapte a função de resumo.
  • Uma planilha com uma aba chamada Videos e três colunas: youtubeId (o código depois de v= na URL), lang (idioma original do vídeo, como en ou pt) e resumo (vazia no começo).

Crie o projeto em Extensões > Apps Script, dentro da própria planilha, ou em script.google.com.

Passo 1: guardar as chaves em Script Properties

Nunca deixe chave de API escrita no código. No editor do Apps Script, vá em Configurações do projeto > Propriedades do script e crie duas propriedades: SUPADATA_API_KEY e GEMINI_API_KEY. O script lê esses valores em tempo de execução:

const SUPADATA_BASE = 'https://api.supadata.ai/v1';
const GEMINI_MODEL = 'gemini-3.8-flash'; // troque pelo modelo que preferir
const PLANILHA_ID = 'COLE_AQUI_O_ID_DA_PLANILHA';
const LIMITE_BYTES = 180000;

function chave(nome) {
  const valor = PropertiesService.getScriptProperties().getProperty(nome);
  if (!valor) throw new Error('Propriedade ausente: ' + nome);
  return valor;
}
Enter fullscreen mode Exit fullscreen mode

Passo 2: a função de transcrição

O endpoint é GET /v1/transcript, autenticado pelo cabeçalho x-api-key. Uso três parâmetros importantes: mode=native, que busca apenas a legenda que já existe no vídeo, sem gerar transcrição por IA; text=true, que devolve o texto corrido em vez de trechos com tempo; e lang, com o idioma original.

Um detalhe da documentação muda tudo: se o idioma pedido não existir, a API devolve o primeiro idioma disponível. Por isso o script confere o campo lang da resposta e descarta o resultado quando ele não bate com o pedido. Assim um vídeo em japonês nunca recebe, por engano, um resumo feito a partir de uma legenda traduzida.

Vídeos muito longos podem voltar com status 202 e um jobId. Nesse caso é só consultar GET /v1/transcript/{jobId} até o status virar completed ou failed.

function buscarTranscricao(youtubeId, lang) {
  const key = chave('SUPADATA_API_KEY');
  const videoUrl = 'https://www.youtube.com/watch?v=' + youtubeId;
  const url = SUPADATA_BASE + '/transcript?url=' + encodeURIComponent(videoUrl) +
    '&mode=native&text=true&lang=' + encodeURIComponent(lang);
  const r = UrlFetchApp.fetch(url, { muteHttpExceptions: true, headers: { 'x-api-key': key } });
  const code = r.getResponseCode();

  let data = null;
  if (code === 200) data = JSON.parse(r.getContentText());
  if (code === 202) data = aguardarJob(JSON.parse(r.getContentText()).jobId, key);
  if (!data) return { ok: false, motivo: 'sem_transcricao_http_' + code };

  const idioma = String(data.lang || '').toLowerCase().split('-')[0];
  const texto = typeof data.content === 'string' ? data.content.trim() : '';
  if (idioma !== lang || !texto) return { ok: false, motivo: 'idioma_ou_texto_indisponivel' };
  if (Utilities.newBlob(texto).getBytes().length > LIMITE_BYTES) {
    return { ok: false, motivo: 'texto_muito_extenso' };
  }
  return { ok: true, texto: texto };
}

function aguardarJob(jobId, key) {
  for (let i = 0; i < 12; i++) {
    Utilities.sleep(5000);
    const r = UrlFetchApp.fetch(SUPADATA_BASE + '/transcript/' + encodeURIComponent(jobId),
      { muteHttpExceptions: true, headers: { 'x-api-key': key } });
    if (r.getResponseCode() !== 200) continue;
    const job = JSON.parse(r.getContentText());
    if (job.status === 'completed') return job;
    if (job.status === 'failed') return null;
  }
  return null;
}
Enter fullscreen mode Exit fullscreen mode

O limite de 180.000 bytes existe porque transcrições enormes deixam o resumo lento e caro, e podem estourar o tempo de execução do Apps Script.

Passo 3: a função de resumo

Para o resumo uso o método generateContent da API do Gemini, chamado direto pelo UrlFetchApp. A chave vai no cabeçalho x-goog-api-key, não na URL, para não aparecer em logs.

function gerarResumo(transcricao, lang) {
  const url = 'https://generativelanguage.googleapis.com/v1beta/models/' +
    GEMINI_MODEL + ':generateContent';
  const payload = {
    systemInstruction: { parts: [{ text: 'Você resume transcrições de vídeos de tecnologia de forma fiel, sem inventar informações.' }] },
    contents: [{
      role: 'user',
      parts: [{ text: 'Escreva um resumo de 3 a 5 frases, no idioma "' + lang +
        '", desta transcrição:\n\n' + transcricao }]
    }],
    generationConfig: { temperature: 0.3 }
  };
  const r = UrlFetchApp.fetch(url, {
    method: 'post',
    contentType: 'application/json',
    headers: { 'x-goog-api-key': chave('GEMINI_API_KEY') },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  });
  if (r.getResponseCode() !== 200) {
    throw new Error('Gemini HTTP ' + r.getResponseCode() + ': ' + r.getContentText().slice(0, 300));
  }
  const data = JSON.parse(r.getContentText());
  const parts = (((data.candidates || [])[0] || {}).content || {}).parts || [];
  const resumo = parts.filter(p => !p.thought).map(p => p.text || '').join('').trim();
  if (!resumo) throw new Error('Resposta do Gemini sem texto');
  return resumo;
}
Enter fullscreen mode Exit fullscreen mode

Pedir o resumo no idioma original mantém cada versão do site coerente.

Passo 4: juntar tudo e evitar gasto duplicado

Este é o ponto que mais me preocupava. Se a chamada à Supadata expirar no meio do caminho, o script não sabe se o crédito foi gasto. Se ele simplesmente tentar de novo na próxima execução, pode pagar duas vezes pelo mesmo vídeo.

A solução é registrar a tentativa no PropertiesService antes de fazer a requisição. Se a marca já existe, o vídeo é pulado. O registro é atualizado no fim com o resultado, seja sucesso ou motivo da falha.

function listarPendentes() {
  const aba = SpreadsheetApp.openById(PLANILHA_ID).getSheetByName('Videos');
  return aba.getDataRange().getValues().slice(1)
    .map((l, i) => ({
      linha: i + 2,
      youtubeId: String(l[0]).trim(),
      lang: String(l[1]).trim().toLowerCase(),
      resumo: String(l[2]).trim()
    }))
    .filter(v => v.youtubeId && v.lang && !v.resumo);
}

function processarVideo(video) {
  const props = PropertiesService.getScriptProperties();
  const marca = 'TX_DONE_' + video.youtubeId + '_' + video.lang;
  if (props.getProperty(marca)) return; // já tentado: nunca gasta duas vezes

  const registro = { at: Date.now(), status: 'consultando' };
  props.setProperty(marca, JSON.stringify(registro)); // marca ANTES da requisição

  try {
    const t = buscarTranscricao(video.youtubeId, video.lang);
    if (!t.ok) { registro.status = t.motivo; return; }
    const resumo = gerarResumo(t.texto, video.lang);
    SpreadsheetApp.openById(PLANILHA_ID).getSheetByName('Videos')
      .getRange(video.linha, 3).setValue(resumo);
    registro.status = 'resumido';
  } catch (e) {
    registro.status = 'erro';
    registro.erro = String(e).slice(0, 200);
  } finally {
    props.setProperty(marca, JSON.stringify(registro));
  }
}
Enter fullscreen mode Exit fullscreen mode

Se o resumo falhar depois da transcrição, o vídeo fica marcado com erro. Você confere o motivo e apaga a propriedade manualmente quando quiser tentar de novo. É uma decisão consciente: prefiro revisar um caso à mão a pagar duas vezes sem perceber.

Passo 5: gatilho automático

Por fim, uma função principal processa poucos vídeos por vez e um gatilho de tempo a executa a cada hora. O LockService impede que duas execuções rodem ao mesmo tempo e peguem o mesmo vídeo.

function rodarAutomacao() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(1000)) return;
  try {
    listarPendentes().slice(0, 5).forEach(processarVideo);
  } finally {
    lock.releaseLock();
  }
}

function criarGatilho() {
  ScriptApp.getProjectTriggers()
    .filter(t => t.getHandlerFunction() === 'rodarAutomacao')
    .forEach(t => ScriptApp.deleteTrigger(t));
  ScriptApp.newTrigger('rodarAutomacao').timeBased().everyHours(1).create();
}
Enter fullscreen mode Exit fullscreen mode

Execute criarGatilho uma vez pelo editor e autorize as permissões. A partir daí, cada linha nova na planilha ganha um resumo em até uma hora.

Dicas e erros comuns

  • Status 206 ou "transcript-unavailable": o vídeo não tem legenda no idioma pedido. Com mode=native, isso é esperado. Se quiser cobrir esses casos, teste mode=auto, que gera a transcrição por IA quando não há legenda, mas confira antes o custo em créditos.
  • Código de idioma errado: use códigos ISO 639-1 (en, pt, es). O script já ignora sufixos como -BR na comparação.
  • Tempo de execução: o Apps Script tem limite de alguns minutos por execução. Por isso processo cinco vídeos por rodada.
  • Limite das propriedades: o PropertiesService tem cota de armazenamento. Guarde só o status nele e deixe o resumo na planilha ou no seu banco.

Conclusão

Com pouco mais de cem linhas de Apps Script, sem servidor e sem custo de infraestrutura, dá para transformar uma lista de vídeos em resumos prontos para publicar. É assim que o iadots.com mantém resumos de vídeos em quatro idiomas sem trabalho manual. Os pontos que fazem diferença são conferir o idioma devolvido, limitar o tamanho do texto e marcar cada tentativa antes de gastar um crédito.

Teste a Supadata: https://supadata.ai/r/HHD1MQYC

Aviso: este artigo contém um link de indicação da Supadata; posso receber créditos se você se cadastrar por ele.

Top comments (0)