# Genera un corpus de voz: extrae tu estilo desde el blog y Twitter

Canonical URL: https://luisalejandro.org/blog/posts/genera-un-corpus-de-voz-extrae-tu-estilo-desde-el-blog-y-twitter

Published: 2026-08-16

Categories: Desarrollo de Software

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.

```yaml
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



<span class="figure figure-right-40" data-figure-src="https://cdn.cosmicjs.com/14291c70-9909-11f1-840f-f3d6107fb5e0-voice-corpus-pipeline.jpg" data-figure-href="https://imgix.cosmicjs.com/14291c70-9909-11f1-840f-f3d6107fb5e0-voice-corpus-pipeline.jpg" data-figure-alt="Pipeline de cuatro etapas: YAML del autor, download.ts, carpeta corpus, extract.ts escribiendo voice-profile.md"></span>

<!-- Uploaded to Cosmic CDN -->

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

```typescript
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](https://github.com/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:

```yaml
---
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`:

```bash
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ó.

```typescript
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í:

```text
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

```bash
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:

```typescript
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:

```typescript
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](https://luisalejandro.org/blog/posts/tutorial-cursor-ide-configura-las-rules-y-deja-de-usar-la-ia-como-un-juguete): 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](https://github.com/LuisAlejandro).