Descarga tus posts y tweets, extrae un perfil de voz con Gemini y deja de pedirle a la IA que escriba como un brochure corporativo.

Los modelos de lenguaje son excelentes para sonar como nadie. Les pides "escribe en mi voz" y te sueltan un brochure con "¡vamos a ello!" y cero fricción real. Yo ya tenía el material —años de posts y una timeline de X— pero no un artefacto que el pipeline pudiera inyectar. Así que armé dos scripts: uno baja el corpus a disco, el otro destila un voice-profile.md. Hoy te muestro cómo generar el tuyo.

1. Un YAML por autor, no un prompt mágico

El punto de entrada no es un system prompt de cuarenta líneas. Es un archivo en scripts/voice-corpus/authors/{id}.yaml. El id tiene que coincidir con el nombre del archivo y pasar este patrón: letras, números, guiones y underscores. Zod exige al menos una fuente de blog (rss_url, sitemap_url o urls) o un twitter.username. Sin eso, el schema ni arranca.

id: luisalejandro
name: Luis Alejandro Martínez Faneyth

blog:
  rss_url: https://luisalejandro.org/blog/posts/feed.xml
  sitemap_url: https://luisalejandro.org/sitemap.xml
  urls: []
  max_posts: 20
  fetch_full_content: true
  url_filter: /blog/posts/

twitter:
  username: LuisAlejandro
  max_tweets: 500
  exclude_replies: false
  exclude_retweets: true

El downloader lista los YAML de authors/ y se salta example.yaml. Si el id interno no coincide con el filename, explota a propósito: no quieres un corpus etiquetado con el autor equivocado.

2. Cómo se descubre qué bajar

Descarga tus posts y tweets, extrae un perfil de voz con Gemini y deja de pedirle a la IA que escriba como un brochure corporativo.

listCandidates no mezcla fuentes. El orden es estricto: lista cerrada, RSS, sitemap.

if (blog.urls && blog.urls.length > 0) {
  return blog.urls.slice(0, blog.max_posts).map((url) => ({
    url,
    title: url,
  }));
}

if (blog.rss_url) {
  try {
    const posts = await listPostsFromRss(blog.rss_url, blog.max_posts);
    if (posts.length > 0) {
      return posts;
    }
    console.warn(`RSS feed returned no entries: ${blog.rss_url}`);
  } catch (error) {
    console.warn(`RSS fetch failed for ${blog.rss_url}:`, error);
  }
}

if (blog.sitemap_url) {
  return listPostsFromSitemap(
    blog.sitemap_url,
    blog.max_posts,
    blog.url_filter
  );
}

Incorrecto: Meter tres URLs en urls "por si acaso" y dejar el RSS. Seamos sinceros: si urls.length > 0, el RSS y el sitemap no existen para el script.

Correcto: Deja urls: [] cuando quieres RSS. Usa urls solo cuando quieres una lista cerrada.

Si el RSS viene vacío o falla, cae al sitemap. El url_filter es un RegExp contra la URL (/blog/posts/ en mi caso). El sitemap no trae fechas: los archivos salen undated-….md a menos que el RSS haya puesto publishedAt.

El HTML se convierte a markdown con Mozilla Readability y Turndown. El User-Agent es frontdesk-voice-corpus/1.0. Si el RSS ya trae HTML inline y el markdown pasa de 400 caracteres, no vuelve a pegarle a la página.

Cada post queda con frontmatter propio:

---
source_url: "https://luisalejandro.org/blog/posts/limpia-tu-buzon-crea-un-script-en-python-para-vaciar-la-papelera-con-imap"
title: "Limpia tu buzón: Crea un script en Python para vaciar la papelera con IMAP"
published_at: "2025-07-09T14:35:47.000Z"
fetched_at: "2026-06-09T03:53:29.510Z"
content_type: blog
author_id: "luisalejandro"
---

Twitter es otro canal: twitter-api-v2 en modo read-only, bearer en X_API_BEARER_TOKEN. Pagina de 100 en 100 hasta max_tweets (el schema topea en 3200). Si X te tira 429, espera 2^attempts * 1000 ms, máximo cinco intentos. El timeline entero es una sola entrada de manifest con source_url: "twitter:timeline". Si Twitter falla, el blog ya bajado no se tira: el CLI atrapa el error, suma failed y sigue.

3. Corre el download

En este repo el runtime va en Docker. El Makefile ya le pasa --author y --force:

make voice-corpus-download author=luisalejandro

Sin --force, compara el SHA-256 del contenido con corpus/{id}/manifest.json y se salta lo que no cambió.

export function shouldSkipDownload(
  manifest: CorpusManifest,
  sourceUrl: string,
  contentHash: string,
  force: boolean
): boolean {
  if (force) {
    return false;
  }

  const existing = findManifestEntry(manifest, sourceUrl);
  return existing?.content_hash === contentHash;
}

Para reescribir todo: make voice-corpus-download author=luisalejandro force=1. Para todos los autores: make voice-corpus-download-all.

El árbol en disco queda así:

corpus/luisalejandro/
  blog/*.md
  twitter/tweets.jsonl
  twitter/summary.json
  manifest.json

En mi corrida: 19 posts y 500 tweets. El extractor, más adelante, no usa todo: se queda con los 30 posts más recientes por mtime y las primeras 250 líneas del JSONL. Cada post entra recortado a 4000 caracteres. No es un archivo de entrenamiento infinito; es una muestra densa.

El token de X vive en .env. Docker Compose lo monta con env_file. No lo pegues en el YAML.

4. Extrae el perfil: estilística, no biografía

make voice-extract author=luisalejandro

Necesitas GEMINI_API_KEY. Modelo por defecto: gemini-3.6-flash, fallback gemini-3.1-pro-preview. El system prompt le ordena extraer cómo escribes, no qué te pasó en la vida. Las secciones son fijas: Identity, Lexicon (solo blog), Social lexicon (solo tweets), Syntax, Rhetoric, Structure, Anti-patterns, Exemplars. El Lexicon y el Social lexicon tienen que salir de canales distintos; si dejas que el blog se coma a los tweets, el perfil sirve para artículos y para nada más.

Si hay menos de cinco posts, igual extrae, pero marca low_confidence: true y pega un warning en el markdown. El skip es por hash del corpus, no por fecha:

const corpusHash = hashCorpus(samples);
const profilePath = getVoiceProfilePath(authorId);

if (!force && fs.existsSync(profilePath)) {
  const existing = fs.readFileSync(profilePath, "utf8");
  const meta = parseProfileFrontmatter(existing);

  if (meta?.corpus_hash === corpusHash) {
    console.log(`Skipping unchanged voice profile for ${authorId}`);
    return "skipped";
  }
}

Salida: corpus/{author-id}/voice-profile.md. Si cambias el prompt de extracción y no el corpus, ese hash sigue igual — tienes que pasar force=1 o te quedas con el perfil viejo.

5. El corpus no sirve si no lo inyectas

Bajar texto no cambia nada. El Writer (y el Social) envuelven el prompt de tarea con composeVoiceSystemInstruction. La voz va primero, con prioridad explícita:

return `# Voice Profile (HIGHEST PRIORITY)

You must write exactly in this voice. This overrides any persona, tone, or style described in the task instructions below.

${voiceProfile.trim()}
${adapterBlock}
# Task Instructions

The following rules define structure, SEO, formatting, and platform constraints. Where they conflict with the Voice Profile on style, tone, or word choice, the Voice Profile wins.

${taskPrompt.trim()}
`;

Hay dos adapters: blogAdapter para prosa larga y socialAdapter para copy corto (prioriza Social lexicon, comprime cadencia). Si el perfil está flaco, warnIfLowConfidence te avisa en consola. El generador de posts exige que el archivo exista: Run voice-extract first.

Es la misma idea de enseñarle a Cursor el idioma de tu proyecto: no le pidas a la máquina que adivine quién eres. Dale el artefacto.

6. Tres errores que te van a morder

El primero ya lo viste: urls no vacío mata RSS y sitemap. El segundo: extraer sin descargar. extractVoiceProfile lanza No corpus found… Run voice-corpus-download first si no hay posts ni tweets. El tercero: tratar --force como cosmética. Sirve cuando cambiaste el prompt, cuando el hash del timeline de X no se movió pero quieres reescribir tweets.jsonl, o cuando el perfil quedó marcado low_confidence y ya metiste más posts.

No hay magia. Hay un YAML, un download idempotente, un extract que destila estilo, y un compose que pone esa voz por encima del prompt de SEO. El resto es humo.


Otros proyectos en mi perfil de GitHub.