Como eu publiquei 200+ posts em algumas horas - E o que aprendi com isso

Apenas dando um pequeno contexto sobre o projeto, me deparei com uma demanda, totalmente pessoal, onde precisava transformar todos os vídeos de um determinado canal do YouTube em postagens de um blog, cada vídeo se tornaria um post independente, o problema em questão é que o canal contava - até o momento que finalizei todo o projeto - com mais de 220 vídeos publicados. Logo pensei, isso é uma ótima oportunidade de colocar as IAs para trabalhar e fazer o serviço pesado.

Foi aí que comecei a bolar umas estratégias de como poderia fazer, até que cheguei na seguinte solução. Entendo que há alguns pontos de melhorias, mas como é um script que ficará rodando sempre local e apenas 1x ao dia para verificar se há vídeos novos, é uma solução que achei até ok.

Obs: Ficou faltando no diagrama, mas ao final de cada etapa sempre salvo novas informações no banco.

Dando um disclaimer do pequeno clickbait do título, no sentido de publicar os mais de 200 posts realmente foram em poucas horas, mas teve todo o tempo de construção do script, pesquisas e testes.

Tecnologias

A escolha das tecnologias foi puramente as que estou mais confortável no meu dia a dia devido ao fato de serem as que já trabalho no serviço, até pensei em fazer com python para ter um pouco mais de contato, mas por conta de uma certa urgência que tinha para finalizar logo, optei por ir pelo caminho mais "rápido". Detalhe que alguns vídeos eu realmente vou precisar de um script em python para executar o Whisper localmente, pois o áudio de alguns vídeos excederam o limite que a OpenAI aceita, que são 25MB.

No entanto as tecnologias usadas foram:

  • JavaScript
  • MongoDB
  • Mongoose

E algumas libs de apoio como o próprio pacote da OpenAI e o youtube-dl-exec que serve para interagir com canais/vídeos do youtube.

Como tudo foi feito

Antes de começar de fato a codificar o projeto final eu fiz vários testes e algumas pesquisas de bibliotecas que eu conseguisse interagir com o YouTube, e a melhor que consegui foi a youtube-dl-exec que é um wrapper para a ferramenta de linha de comando yt-dlp.

Com testes feitos, tecnologias definidas, e tudo esboçado - até então só na minha cabeça - pensei no seguinte fluxo.

  1. Obter todos os vídeos do canal.
async function getAllVideosByChannelUrlAsync(channelUrl) {
  const channelInfosRawResult = await exec(channelUrl, {
    dumpSingleJson: true,
    flatPlaylist: true
  });
  const channelInfos = JSON.parse(channelInfosRawResult.stdout);
  const channelVideos = channelInfos.entries;
  const informations = channelVideos.map(video => {
    return {
      id: video.id,
      url: video.url
    }
  });
  return informations;
}
  1. Dessa lista eu retorno somente o id e a url do vídeo. Foi aqui que começaram alguns problemas que eu não havia mapeado, por isso passei a retornar somente essas duas informações - O problema foi que nos meus testes, obtendo os vídeos como playlist já seria o suficiente, pois tinha todas as informações que eu precisava, mas quando comecei a rodar pra valer diversos vídeos apareceram com título errado e sem descrição. Esse problema eu não consegui identificar a causa que resultou nisso, tenho uma leve impressão que é por causa dos parâmetros que passei junto com a URL do canal.

  2. Para cada vídeo que eu ainda não tenho salvo no banco, eu busco a informação no YouTube do vídeo em si, assim consegui todas as informações que queria de forma consistente, salvando no banco a estrutura abaixo.

async function saveOnlyNewVideos(videosInfos) {
  console.log(`[${getFormattedDateTimeNow()}] Saving only new videos...`);
  const videosMongo = await VideoInfo.find();
  const videosIds = videosMongo.map(video => video.youtubeId);
  videosInfos = videosInfos.filter(video => !videosIds.includes(video.id));
  for (const video of videosInfos) {
    try {
      const videoMongo = videosMongo.find(videoMongo => videoMongo.youtubeId === video.id);
      if (videoMongo) {
        console.log(`[${getFormattedDateTimeNow()}] Video already saved: `, videoMongo.title);
        continue;
      }
      if (!videoMongo) {
        const videoInfo = await getVideoInformationsByUrl(video.url);
        const videoInfoModel = new VideoInfo({
          youtubeId: videoInfo.id,
          title: videoInfo.title,
          duration: videoInfo.duration,
          url: videoInfo.original_url,
          thumbnail: videoInfo.thumbnail,
          tags: videoInfo.tags,
          description: videoInfo.description,
          audioExceeds25MB: false,
          transcribed: false,
          transcription: '',
          hasBeenProcessed: false,
          processedTranscription: '',
          createdBlogPost: false
        })
        console.log(`[${getFormattedDateTimeNow()}] Saving video: `, videoInfoModel.title);
        await videoInfoModel.save();
      }
    } catch (error) {
      console.log(error);
    }
  }
  console.log(`[${getFormattedDateTimeNow()}] All videos saved!`);
}
async function getVideoInformationsByUrl(videoUrl) {
  const videoInformationsRawResult = await exec(videoUrl, {
    dumpSingleJson: true
  });
  const videoInformations = JSON.parse(videoInformationsRawResult.stdout);
  return videoInformations;
}
  1. Obter da base todos os vídeos que ainda não foram transcritos e, para cada vídeo, fazer o download somente do áudio no formato .mp3, salvar a transcrição no banco, atualizar a flag transcribed e apagar o arquivo do disco. Aqui foi outro problema enfrentado, de início não queria salvar o arquivo diretamente em disco e sim trabalhar com tudo no buffer da memória, mas tive inúmeros erros da OpenAI reclamando do limite de 25MB excedido, mas o que me intrigava era que eram de vídeos curtos demais para ser tão pesado assim, até que baixei um e ele deu apenas 4MB, foi então que conversando com um amigo muito mais sênior, ele me lembrou do encode, o que estava acontecendo era que enquanto estava trabalhando com o arquivo na memória ele ainda não havia sido encodado (existe essa palavra no português), e quando salva no disco ele ainda baixava os arquivos absurdamente grande de forma temporária, porém após finalizar totalmente o encode, ele era limpo e apagado do disco deixando somente o .mp3 mesmo.
async function transcribeVideos() {
  console.log(`[${getFormattedDateTimeNow()}] Transcribing videos...`);
  try {
    const videos = await VideoInfo
      .find({
        transcribed: false,
        audioExceeds25MB: false
      });
    for (const video of videos) {
      console.log(`[${getFormattedDateTimeNow()}] Getting audio stream from video: ${video.title}`);
      const audioPath = await getAudioFromYoutubeUrl(video.url);
      console.log(`[${getFormattedDateTimeNow()}] Transcribing video: ${video.title}`);
      const transcription = await transcribeAudioToTextAsync(audioPath);
      if (transcription) {
        fs.unlinkSync(audioPath);
      }
      if (transcription === "THIS_AUDIO_FILE_EXCEEDS_25MB") {
        video.audioExceeds25MB = true;
        await video.save();
        continue;
      }
      console.log(`[${getFormattedDateTimeNow()}] Saving transcription for video: ${video.title}`);
      video.transcription = transcription;
      video.transcribed = true;
      await video.save();
    }
    console.log(`[${getFormattedDateTimeNow()}] All videos transcribed!`);
  } catch (error) {
    console.error(`[transcribeVideos] Error: ${error}`);
  }
}
async function getAudioFromYoutubeUrl(youtubeUrl) {
  try {
    const pathToDownload = path.resolve('../temp/audio.mp3');
    await exec(youtubeUrl, {
      output: pathToDownload,
      extractAudio: true,
      audioFormat: 'mp3',
      audioQuality: 5,
    });
    return pathToDownload;
  } catch (error) {
    console.error(`[getAudioFromYoutubeUrl] Error: ${error}`);
    throw error;
  }
}
async function transcribeAudioToTextAsync(audioPath) {
  try {
    const audioSize = fs.statSync(audioPath).size;
    if (audioSize > 26214400) {
      console.log(`[${getFormattedDateTimeNow()}] [transcribeAudioToTextAsync] Audio file exceeds 25MB: ${audioSize} bytes`);
      return 'THIS_AUDIO_FILE_EXCEEDS_25MB';
    }
    const transcription = await openai.audio.transcriptions.create({
      file: fs.createReadStream(audioPath),
      model: 'whisper-1',
      language: 'pt'
    });
    return transcription.text;
  } catch (error) {
    console.error(`[transcribeAudioToTextAsync] Error: ${error}`);
    throw error;
  }
}
  1. Esse era para ser o passo da publicação no blog já, mas percebi que nem todas transcrições eram feitas de forma aceitável, o jeito foi pensar em uma maneira de corrigir isso, e na própria documentação da OpenAI tem uma recomendação de fazer um pós processamento utilizando o GPT, muita coisa que ficava estranha eram algumas palavras em inglês que o whisper não entendia corretamente por especificar a língua que era português, então montei um prompt usando algumas referências que já tinham a escrita correta e ficou assim:
async function postProcessingTranscription() {
  console.log(`[${getFormattedDateTimeNow()}] Post processing transcriptions...`);
  try {
    const videos = await VideoInfo
      .find({
        transcribed: true,
        hasBeenProcessed: false
      });
    for (const video of videos) {
      console.log(`[${getFormattedDateTimeNow()}] Pos processing transcription for video: ${video.title}`);
      const correctedTranscription = await correctTranscription({
        title: video.title,
        description: video.description,
        tags: video.tags,
        transcription: video.transcription
      });
      video.processedTranscription = correctedTranscription;
      video.hasBeenProcessed = true;
      await video.save();
      console.log(`[${getFormattedDateTimeNow()}] Video post processed: ${video.title}`);
    }
    console.log(`[${getFormattedDateTimeNow()}] All transcriptions post processed!`);
  } catch (error) {
    console.error(`[${getFormattedDateTimeNow()}] [postProcessingTranscription] Error: ${error}`);
  }
}
async function correctTranscription(videoInfos) {
  const { title, description, tags, transcription } = videoInfos;
  const systemPrompt = `Você é um assistente útil da empresa. Sua tarefa é corrigir quaisquer discrepâncias ortográficas e gramaticais no texto transcrito, com base no contexto fornecido (título, descrição e tags). Certifique-se de que os nomes de produtos e outros termos mencionados no contexto estejam escritos corretamente. Adicione apenas a pontuação necessária (pontos, vírgulas, letras maiúsculas apropriadas), sem inserir qualquer conteúdo adicional.

Instruções importantes e obrigatórias:
- NÃO inclua o título, a descrição, as tags ou a transcrição original na resposta.
- NÃO adicione marcadores, códigos, HTML, formatação extra, comentários ou explicações.
- NÃO adicione rótulos como "Transcrição:" ou "Corrigido:".
- A resposta final deve conter apenas a versão corrigida do texto transcrito, nada mais.

Qualquer tentativa de incluir elementos além do texto transcrito corrigido deve ser evitada. Somente retorne o texto corrigido final.`;
  const userPrompt = `Título: ${title}\nDescrição: ${description}\nTags: ${tags.join(', ')}\nTranscrição: ${transcription}`;

  const completion = await openai.chat.completions.create({
    model: 'gpt-4o',
    messages: [
      {
        role: 'system',
        content: systemPrompt
      },
      {
        role: 'user',
        content: userPrompt
      }
    ]
  });
  return completion.choices[0].message.content;
}
  1. Publicar cada transcrição como uma forma de post no blog que foi feito com wordpress. Dentro do wordpress eu usei o plugin ACF (Advanced Custom Fields) e criei alguns campos personalizados para ter algumas informações dentro do próprio wordpress para montar a página do post depois, como por exemplo, a thumbnail, a url do vídeo para incorporar na publicação, descrição e etc... Isso foi uma parte interessante, pois já havia feito alguns projetos antes com o wordpress e com o ACF em conjunto, porém nunca havia consumido a API do wordpress para integrar com outras soluções, foi a oportunidade de ouro pra explorar essa funcionalidade, enfim, após algumas pesquisas, até que relativamente tranquilas, encontrei o endpoint de OPTIONS que o wordpress já retornar toda a estrutura que ele espera receber na API e a partir disso foi apenas montar o objeto:
async function publishPosts() {
  try {
    const videos = await VideoInfo
      .find({
        transcribed: true,
        hasBeenProcessed: true,
        createdBlogPost: false
      });
    const token = await getTokenJWT();
    for (const video of videos) {
      console.log(`[${getFormattedDateTimeNow()}] Publishing post for video: ${video.title}`);
      const post = {
        title: video.title,
        content: video.processedTranscription,
        status: 'draft',
        comment_status: 'closed',
        acf: {
          youtube_video: video.url,
          video_duration: Number(video.duration),
          video_thumbnail: video.thumbnail,
          youtube_tags: video.tags.join('|'),
          video_description: video.description
        }
      }
      const response = await createPostWP(post, token);
      if (response.success && response.status === 201) {
        video.createdBlogPost = true;
        await video.save();
      }
    }
    console.log(`[${getFormattedDateTimeNow()}] All ${videos.length} posts published!`);
  } catch (error) {
    console.error(`[publishPosts] Error: ${error}`);
  }
}
async function getTokenJWT() {
  const url = `${WORDPRESS_BASE_URL}/wp-json/jwt-auth/v1/token`;
  try {
    const response = await fetch(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        username: WORDPRESS_USERNAME,
        password: WORDPRESS_PASSWORD
      })
    });
    const data = await response.json();
    return data.token;
  } catch (error) {
    console.error(`[getTokenJWT] Error: ${error}`);
  }
}

async function createPostWP(postData, token) {
  const url = `${WORDPRESS_BASE_URL}/wp-json/wp/v2/posts`;
  try {
    const response = await fetch(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${token}`
      },
      body: JSON.stringify(postData)
    });
    const data = await response.json();
    if (!response.ok) {
      return {
        success: false,
        status: response.status,
        message: data.message,
        errorCode: data.code
      }
    }

    return {
      success: true,
      status: response.status,
      data
    }
  } catch (error) {
    console.error(`[createPostWP] Error: ${error}`);
  }
}

Após implementar todas as etapas e ver os mais de 200 posts publicados no blog, fiquei bastante satisfeito com o resultado. O processo, embora desafiador, foi uma excelente oportunidade para aprender na prática e explorar novas ferramentas, além de automatizar uma tarefa que parecia quase impossível de ser feita manualmente.

O projeto também me mostrou como uma boa integração entre tecnologias e APIs pode simplificar processos complexos. Mesmo com os imprevistos, foi gratificante ver a solução final funcionando.

Aprendizados

Ao final de todo o processo, tive vários aprendizados que vão além do lado técnico.

Planejar é bom, mas a prática sempre traz surpresas.

Mesmo com todo o planejamento inicial, problemas inesperados surgiram no meio do caminho, como o caso do encode do áudio e o problema com as informações incompletas dos vídeos. Isso me mostrou que é fundamental testar em cenários reais antes de escalar o projeto.

Adapte-se à realidade do projeto

A escolha das tecnologias foi influenciada pela minha experiência, isso foi essencial para manter o progresso. Também precisei criar novas propriedades para lidar com situações que não havia previsto. Mesmo que é um projeto que está rodando localmente e não teria problemas em derrubar o banco, optei por não fazer simulando um sistema que estivesse em produção, onde não teria essa liberdade, então criei um script que populasse a nova propriedade em documentos já existentes no mongo de vídeos que já havia buscado as informações.

Iteração e melhorias contínuas

A solução inicial era simples e funcional, mas fui refinando com base nos problemas encontrados, como ajustar as transcrições com o GPT para melhorar a qualidade do conteúdo antes da publicação.

Custos

Somando a transcrição e o pós processamento + os testes que fiz antes de colocar pra rodar de fato, gastei cerca de uns 19 dólares.

Próximos passos

Esse projeto ainda não está 100% finalizado, tenho planos de conectar uma IA nessa base que guardei as transcrições para responder perguntas sobre os vídeos.

Pedidos e feedbacks

Finalmente é isso pessoal, gostaria de saber de vocês os mais diversos feedbacks, esse foi o primeiro artigo longo que escrevo, então o que peço é sugestão da organização do texto como um todo, a lista enumerada que detalha o fluxo fez sentido ou seria melhor separar em seções mais específicas? Os problemas encontrados poderiam ter sido descritos como subtópicos dentro de cada etapa ao invés de serem mencionados apenas como texto corrido? O contexto inicial deixa claro o motivo do projeto? Acham que deveria expandir a parte que explica o problema ou está suficientemente detalhado? Há algo que poderia de sido mais detalhado ou simplificado?

Feedbacks gerais são bem vindos para deixar o artigo mais interessante e informativo.

Por mais conteúdos como esse no TN. Cara que projeto fantastico, ficou muito top o resultado, eu não sei se é privado o resultado do projeto. Faltou o link do blog, ou ao menos o exemplo de uma postagem realizada com o script. Parabéns pela solução.

que massa!

Pensa em fazer algum saas e vender essa solução?

Não tinha pensado nessa possibilidade pra isso em específico não, será que tem mercado? Fiquei de fato com uma semente de dúvida agora em... Por acaso tem ideias de como daria para validar a chance de vender isso como um produto?
Faz uma landing page bonita e disponibiliza pros criadores de conteúdo por X valor, se ele quiser contéudo de X vídeos é um valor e assim por diante. Tem muuuito mercado com isso. Imagina se o criador de contéudo queira fazer um e-book sobre as lives de lançamento dele? Ou se quiser fazer um apanhado geral de tudo que ele já ensinou e catalogar isso... Se ele pegar um vídeo e quiser transformar em stories.. Faz uma API, chama num site e manda ver!
Existem toneladas de plataformas que fazem isso Free, e uma bem famosa, n8n, que faz quase free. Mas, sempre existe espasso para mais um dos milhões de micro saas milagrosos no mercado..

Cara conhece o n8n? Dá pra fazer isso de forma extremamente simples. Depois dá uma olhada.

Até conheço o n8n, mas nem cheguei a lembrar nele quando comecei a codar, pra ser bem sincero eu só conheço algumas possibilidades, mas nunca cheguei a explorar ele com algum projeto.

Belo trabalho, parabéns! Só faltou o link do blog para visitarmos.

Utilizou algum servidor para hospedar essa solução aonde? Foi algum host free? PErgunto porque fiquei na dúvida como fez para implantar essa solução de agendador, que rodasse uma vez por dia.

Tenho uma máquina um pouco mais antiga em casa mesmo que deixei rodando o script, geralmente utilizo ela com o ubuntu server para pequenos projetos pessoais, para executar 1x por dia tem várias abordagens, você pode agendar via cron ou então deixar o código todo em um loop e e adicionar um sleep de 24 horas. Que foi a abordagem que eu utilizei, inclusive com uma pequena lógica de retry em caso de falhas, não é uma das melhores mas para o meu caso está mais do que suficiente. Por exemplo: ```js async function main() { const allVideosFromChannel = await getAllVideosByChannelUrlAsync('https://www.youtube.com/@canal_escolhido_aqui') await saveOnlyNewVideos(allVideosFromChannel); await transcribeVideos(); await postProcessingTranscription(); await publishPosts(); } while(true) { // 1 minute -> 60 seconds // 1 hour -> 3600 seconds // 1 day -> 86400 seconds const sleepingTimeInSeconds = 86400; let tentative = 1; const initialSleepingTimeToRetry = 60; const retry = 3; try { await main(); console.log('Waiting to check for new videos...'); await new Promise(resolve => setTimeout(resolve, sleepingTimeInSeconds)); tentative = 1; } catch (error) { console.log(error); if (tentative === retry) { console.log('Max retries reached. Waiting to check for new videos...'); await new Promise(resolve => setTimeout(resolve, sleepingTimeInSeconds)); tentative = 1; } const backoffTime = initialSleepingTimeToRetry * (initialSleepingTimeToRetry * (2 ** (retry - 1))); console.log(`Retrying in ${backoffTime} seconds... (Attempt ${tentative} of ${retry})`); await new Promise(resolve => setTimeout(resolve, backoffTime * 1000)); tentative++; } } ```
Cron Jobs, qualquer servidor simples com Linux permite implementar, isso inclui qualquer hospedagem simples, seja compartilhada ou não. Embora existam soluções mais adequadas, é amplamente usada pelos iniciantes e até devs mais experientes que querem algo rapido.