Watch at EaseHistorias. Respuestas. Perspectiva.

Tecnología · GUÍA PRÁCTICA

Exporta las anotaciones de Zotero a CSV con una fila por anotación

Crea una hoja de análisis con tus anotaciones de Zotero y conserva juntas las citas, los comentarios, las etiquetas, los identificadores de página y los metadatos de la fuente, sin confundir los datos de publicación con las fechas de las anotaciones.

Diagrama original que relaciona una anotación con su adjunto PDF y, cuando existe, con un elemento bibliográfico padre. Una rama separada muestra un adjunto independiente con sourceStatus igual a standalone-attachment y los metadatos de la fuente vacíos.
Ilustración editorial original. El exportador sigue parentItem desde la anotación hasta el adjunto y, después, hasta un elemento bibliográfico cuando existe. Un adjunto independiente sigue produciendo un registro de anotación; los campos bibliográficos de la fuente quedan vacíos. Las etiquetas seleccionadas representan campos del CSV de salida, no una interfaz de Zotero. Watch at Ease

Has resaltado pasajes en Zotero y quieres una hoja de cálculo para analizarlos: una anotación por fila, con su comentario, sus etiquetas y suficiente información de la fuente para identificar la cita. Un documento con todas las anotaciones resulta útil para leerlas, pero ofrece un resultado distinto de una tabla cuyos registros puedes filtrar y comparar.

El exportador local que aparece a continuación lee los registros nativos de anotaciones ya existentes en My Library y crea un CSV nuevo. Sigue la relación de cada anotación con su archivo adjunto y, cuando existe, con el elemento bibliográfico padre del adjunto. Las notas, Pyzotero y herramientas como Highlight Collector ofrecen funciones relacionadas; este procedimiento proporciona una exportación concreta y completa, junto con los pasos para comprobarla. No convierte el HTML de las antiguas notas Extracted Annotations.

1. Confirma que estos son los datos que necesitas

Empieza en la aplicación de escritorio de Zotero que contiene tu investigación. Este programa lee los elementos nativos de anotación almacenados por Zotero, incluidas las anotaciones asociadas a PDF, EPUB y capturas de páginas web. No examina los archivos adjuntos en busca de marcas creadas en otro editor de PDF que no se hayan importado como anotaciones nativas de Zotero. Tampoco divide en registros las notas normales ni el HTML antiguo de Extracted Annotations.

El alcance comprende todas las anotaciones que coincidan con la consulta y no estén en la papelera de My Library, mediante el acceso local a users/0. Seleccionar una colección o varios PDF en Zotero no reduce el alcance de esta exportación. Las anotaciones de bibliotecas de grupos quedan fuera de este programa. Utiliza los campos de fuente y adjunto del resultado para identificar el material que quieres analizar, y conserva un CSV maestro sin cambios antes de crear una copia de trabajo filtrada.

Elige unas cuantas anotaciones para comprobarlas después: un resaltado con comentario, una anotación con etiquetas y otra cuyo identificador de página reconozcas. Si tienes un adjunto independiente sin elemento bibliográfico padre, inclúyelo también. Estos ejemplos te ayudarán a distinguir entre un registro que realmente falta y un campo que debe quedar vacío.

2. Activa la lectura local y evita cambios en la biblioteca

En Zotero, abre Settings, después Advanced, y activa “Allow other applications on this computer to communicate with Zotero”. Mantén Zotero abierto. Su Local API permite solicitudes de lectura sin una clave de API; este programa no necesita una clave de la nube de zotero.org ni autorización para modificar tu biblioteca.

Ejecuta el programa en el mismo ordenador que Zotero. Envía solicitudes de solo lectura a 127.0.0.1 a través del puerto local habitual, 23119. No configures el reenvío de ese puerto, no lo expongas al exterior ni sustituyas la dirección por una remota. El exportador no lee directamente tu base de datos SQLite, no abre PDF, no sube citas ni escribe cambios en Zotero.

Deja que termine cualquier sincronización en curso y, después, mantén pausadas la edición y la sincronización durante toda la ejecución. Puedes desconectar el ordenador de la red para impedir otra sincronización; la Local API funciona sin conexión. No añadas anotaciones, cambies metadatos de las fuentes ni reinicies Zotero hasta que termine. El programa comprueba recuentos, claves e información de versión, pero esas comprobaciones no convierten la exportación en una instantánea transaccional: las versiones antiguas de la Local API de Zotero comunican versiones sincronizadas que pueden permanecer iguales después de cambios locales.

3. Guarda el exportador completo como archivo de texto sin formato

Utiliza una versión LTS reciente de Node.js en el mismo ordenador. Abre un terminal y ejecuta node --version para confirmar que Node está disponible. Este exportador no requiere instalar paquetes de npm ni de pip. Crea una carpeta de trabajo en un disco local normal que admita enlaces duros, en lugar de dar por hecho que funcionará en una unidad de red o en un sistema de archivos virtual especial.

Copia el programa completo del siguiente párrafo en un editor de texto sin formato y guárdalo como export_zotero_annotations.mjs con codificación UTF-8. Conserva las comillas rectas, las barras invertidas y todos los signos de puntuación. Comprueba que el nombre del archivo termine realmente en .mjs, no en .mjs.txt. En esta página, el código puede aparecer como un único párrafo largo; se ha probado tanto esa versión con los espacios en blanco reducidos como la versión distribuida en varias líneas.

import {open,link,unlink} from 'node:fs/promises'; import {resolve} from 'node:path'; import {randomUUID} from 'node:crypto'; const fail=c=>{const e=new Error(c);e.code=c;throw e;}; const str=v=>{if(v==null)return '';if(typeof v!=='string')fail('INVALID_FIELD');return v;}; const key=v=>{if(typeof v!=='string'||!/^[A-Z0-9]{8}$/.test(v))fail('INVALID_KEY');return v;}; const item=o=>{if(!o||!o.data||key(o.key)!==key(o.data.key)||!str(o.data.itemType))fail('INVALID_ITEM');return o;}; const array=v=>{if(v==null)return [];if(!Array.isArray(v))fail('INVALID_ARRAY');return v;}; let temporary,handle,serverID,version,total,published=false; try { const port=process.env.ZOTERO_PORT||'23119'; if(!/^\d+$/.test(port)||1>Number(port)||Number(port)>65535)fail('INVALID_PORT'); if(process.argv.length>3)fail('USAGE'); const output=resolve(process.argv[2]||'zotero-annotations.csv'); const base='http://127.0.0.1:'+port+'/api/users/0/items'; async function get(suffix){ let r;try{r=await fetch(base+suffix,{method:'GET',redirect:'error',headers:{'Zotero-API-Version':'3',...(serverID?{'Zotero-Server-ID':serverID}:{})},signal:AbortSignal.timeout(30000)});}catch{fail('LOCAL_REQUEST_FAILED');} if(!r.ok)fail('HTTP_'+r.status); const id=r.headers.get('Zotero-Server-ID'); if(serverID!==undefined&&id!==serverID)fail('SERVER_CHANGED');serverID=id; const api=r.headers.get('Zotero-API-Version');if(api&&api!=='3')fail('API_VERSION'); let data;try{data=await r.json();}catch{fail('INVALID_JSON');} return {data,headers:r.headers}; } async function page(start,limit){ const r=await get('?format=json&itemType=annotation&sort=dateAdded&direction=asc&limit='+limit+'&start='+start); const n=r.headers.get('Total-Results');if(!n||!/^\d+$/.test(n)||!Number.isSafeInteger(Number(n)))fail('INVALID_TOTAL'); if(total!==undefined&&total!==Number(n))fail('TOTAL_CHANGED');total=Number(n); const v=r.headers.get('Last-Modified-Version');if(!v||!/^\d+$/.test(v)||!Number.isSafeInteger(Number(v)))fail('INVALID_VERSION');if(version!==undefined&&version!==v)fail('LIBRARY_CHANGED');version=v; if(!Array.isArray(r.data)||r.data.length!==Math.min(limit,Math.max(0,total-start)))fail('PAGE_COUNT_MISMATCH'); return r.data; } const annotations=[],seen=new Set(),cache=new Map(); do {const batch=await page(annotations.length,100);for(const o of batch){item(o);if(o.data.itemType!=='annotation'||!str(o.data.annotationType))fail('INVALID_ANNOTATION');if(seen.has(o.key))fail('DUPLICATE_KEY');seen.add(o.key);annotations.push(o);}} while(total>annotations.length); if(seen.size!==total)fail('COUNT_MISMATCH'); async function parent(k){key(k);if(!cache.has(k)){const o=item((await get('/'+k+'?format=json')).data);if(o.key!==k)fail('PARENT_KEY_MISMATCH');cache.set(k,o);}return cache.get(k);} const headers=['annotationKey','type','text','comment','tagsJSON','pageLabel','attachmentKey','attachmentTitle','sourceKey','sourceTitle','sourceCreatorsJSON','sourceAuthorsJSON','sourceDate','sourceParsedDate','sourceYear','sourceStatus','annotationAuthorName','annotationDateAdded','annotationDateModified']; const rows=[headers];let standalone=0,nontext=0; for(const o of annotations){ const a=o.data,p=await parent(a.parentItem),d=p.data;if(d.itemType!=='attachment')fail('PARENT_NOT_ATTACHMENT'); let source=null;if(d.parentItem)source=await parent(d.parentItem); if(source&&['attachment','annotation','note'].includes(source.data.itemType))fail('SOURCE_NOT_BIBLIOGRAPHIC'); const s=source?.data||{},parsed=str(source?.meta?.parsedDate),year=/^(?!0000)\d{4}(?:-\d{2}(?:-\d{2})?)?$/.test(parsed)?parsed.slice(0,4):''; const tags=array(a.tags),creators=array(s.creators);if(tags.some(t=>!t||typeof t.tag!=='string')||creators.some(c=>!c||typeof c.creatorType!=='string'))fail('INVALID_METADATA'); const textType=['highlight','underline'].includes(a.annotationType);if(!textType)nontext++;if(!source)standalone++; rows.push([o.key,a.annotationType,textType?str(a.annotationText):'',str(a.annotationComment),JSON.stringify(tags),str(a.annotationPageLabel),p.key,str(d.title),source?.key||'',str(s.title),source?JSON.stringify(creators):'',source?JSON.stringify(creators.filter(c=>c.creatorType==='author')):'',str(s.date),parsed,year,source?'bibliographic':'standalone-attachment',str(a.annotationAuthorName),str(a.dateAdded),str(a.dateModified)]); } const check=await page(0,1);if(check.length&&item(check[0]).key!==annotations[0].key)fail('LIBRARY_CHANGED'); const csv=rows.map(row=>row.map(v=>'"'+v.replaceAll('"','""')+'"').join(',')).join('\r\n')+'\r\n'; const pending=output+'.partial-'+randomUUID();handle=await open(pending,'wx',0o600);temporary=pending;await handle.writeFile(csv,'utf8');await handle.sync();await handle.close();handle=null;await link(temporary,output);published=true; console.log('Exported '+annotations.length+' annotation records to '+output);console.log('Standalone rows: '+standalone+'; non-text rows: '+nontext+'.'); } catch(e){console.error((published?'CSV published; reporting failed: ':'Export failed; no new completed CSV was published: ')+(e.code||'UNEXPECTED')+'.');process.exitCode=1;} finally {if(handle)await handle.close().catch(()=>{});if(temporary)await unlink(temporary).catch(e=>{if(e.code!=='ENOENT')console.error('Temporary-file cleanup failed: '+temporary);});}

Abre el terminal en la carpeta que contiene el archivo y ejecuta la comprobación de sintaxis que aparece a continuación. Cuando la comprobación es correcta, normalmente se vuelve al indicador del terminal sin mostrar ningún mensaje. Comprueba la sintaxis del programa guardado, no el acceso a Zotero ni que tu biblioteca esté completa.

node --check export_zotero_annotations.mjs

4. Exporta a un CSV con un nombre nuevo

Con Zotero abierto y sin cambios, ejecuta este comando desde la misma carpeta de trabajo. El nombre entre comillas corresponde al nuevo archivo que se va a crear; no designa una exportación existente que se deba actualizar.

node export_zotero_annotations.mjs "zotero-annotations.csv"

Espera a que el terminal indique “Exported”, seguido del número de registros de anotaciones y la ruta de salida. También indica cuántas filas corresponden a adjuntos independientes y cuántas a anotaciones no textuales. Son recuentos explicativos, no registros adicionales que debas sumar al total exportado. Guarda el número indicado para comprobarlo después en la hoja de cálculo.

El programa solicita los registros de anotaciones por lotes, valida las respuestas y recupera cada adjunto y cada fuente referenciados. A continuación, escribe un archivo temporal junto al destino y crea el nombre de archivo definitivo solo cuando termina de escribir. No sustituye un archivo de destino existente. Para otra ejecución, utiliza un nombre distinto, como zotero-annotations-2.csv. Un archivo restante cuyo nombre contenga .partial no es el resultado final.

5. Entiende las columnas de anotación y de fuente

Hay 19 columnas. annotationKey identifica la anotación; type indica su tipo. text contiene el pasaje de las anotaciones highlight y underline, mientras que comment conserva por separado el comentario de la anotación. tagsJSON mantiene los objetos de etiqueta de la anotación como texto JSON, preservando su estructura en vez de reducirlos a nombres separados por comas que puedan resultar ambiguos.

pageLabel conserva exactamente el identificador de página de la anotación en Zotero. No lo sustituyas por el índice físico de página del adjunto ni presupongas que debe ser un número: las páginas preliminares y otras partes del documento pueden usar identificadores distintos. attachmentKey y attachmentTitle identifican el adjunto que contiene la anotación.

sourceKey y sourceTitle identifican el elemento bibliográfico padre, cuando existe. sourceCreatorsJSON conserva la lista estructurada de creadores de la fuente y sus funciones; sourceAuthorsJSON incluye únicamente a los creadores cuya función es author. No se cambia silenciosamente la función de editor por la de autor, ni se completa una lista de autores vacía con la persona que hizo la anotación.

sourceDate conserva la cadena de fecha original del elemento fuente. sourceParsedDate contiene la fecha de la fuente interpretada por Zotero, cuando se proporciona. sourceYear se obtiene únicamente de un valor interpretado con el formato YYYY, YYYY-MM o YYYY-MM-DD, cuyo año tenga cuatro dígitos y sea distinto de cero. Es una regla de extracción limitada, no una validación del calendario; los valores ausentes o con otro formato quedan vacíos en lugar de deducirse.

sourceStatus toma el valor bibliographic cuando existe ese elemento padre, o standalone-attachment cuando no existe. Los registros independientes conservan la información de su adjunto, pero dejan vacíos los campos de la fuente. Por último, annotationAuthorName, annotationDateAdded y annotationDateModified describen la propia anotación. Ninguno se utiliza como sustituto del autor o de la fecha de la publicación.

6. Mantén visibles los registros no textuales y los metadatos ausentes

Los tipos image, ink, note y los demás distintos de highlight o underline también reciben una fila con los comentarios, las etiquetas y los campos de identificación disponibles. Su columna text queda vacía. El programa no reconoce escritura a mano, no convierte regiones de imagen en citas ni exporta la propia imagen o el trazo. El recuento de anotaciones no textuales indica cuántos registros requieren esa distinción durante el análisis.

Si falta un autor de la fuente, una fecha de publicación o un identificador de página, revisa el registro correspondiente en Zotero antes de interpretar el campo vacío. Corrige allí los metadatos de la fuente cuando corresponda y vuelve a exportar después de detener la edición y la sincronización. No sustituyas las fechas de publicación ausentes por fechas de anotación solo para que todas las celdas de la hoja parezcan completas.

Una exportación correcta con cero registros contiene únicamente la fila de encabezados. Significa que no se devolvieron registros nativos de anotaciones que coincidieran con este alcance; no demuestra que tus PDF nunca hayan contenido resaltados. Comprueba de nuevo si el material está en una biblioteca de grupo, si solo existe en notas antiguas o si sigue incrustado en archivos anotados externamente. Este programa no convierte esos datos de entrada.

7. Importa el CSV como texto y comprueba las filas

Utiliza el proceso de importación Text/CSV de tu hoja de cálculo en lugar de confiar en la apertura mediante doble clic. Elige UTF-8, la coma como delimitador y las comillas dobles como delimitador de texto, y configura todas las columnas como texto antes de cargarlas. Desactiva la conversión automática o la interpretación de fórmulas cuando el cuadro de importación ofrezca esas opciones. Si la vista previa ya ha cambiado un identificador de página, una fecha o una cita, cancela e importa de nuevo con tratamiento de texto.

El CSV encierra todos los campos entre comillas, duplica las comillas que aparecen dentro de ellos y utiliza CRLF para separar registros. Los saltos de línea originales de las citas y los comentarios permanecen dentro de sus celdas. Por eso, un registro puede ocupar varias líneas físicas en un editor de texto. Compara los registros que la hoja de cálculo ha interpretado, sin contar el encabezado, con el número de anotaciones indicado por el terminal; no cuentes las líneas del archivo en bruto.

Compara los ejemplos que elegiste con Zotero. Confirma el texto exacto de la cita, el comentario, las etiquetas, el identificador de página, el adjunto y la fuente bibliográfica. Comprueba que un comentario con varias líneas permanezca en una sola celda y que una coma o una comilla no haya creado una columna adicional. Verifica un registro independiente y otro no textual, si los hay. Conserva intactas las columnas JSON en la exportación maestra aunque las ocultes en una copia destinada a presentar los datos.

Guarda tu análisis como un libro de cálculo o archivo separado. Conserva el CSV original y el recuento indicado para poder relacionar los filtros posteriores con la exportación. El archivo estará listo para analizarlo o compartirlo cuando el número de registros y las asociaciones con las fuentes de los ejemplos comprobados coincidan con lo que verificaste en Zotero.

8. Resuelve los fallos sin omitir las comprobaciones

EEXIST significa que el nombre de archivo de salida ya existe: elige otro nombre nuevo. Para HTTP_403, comprueba la opción de comunicación local de Zotero. Para LOCAL_REQUEST_FAILED, comprueba que Zotero esté abierto en el mismo ordenador y sea accesible en su puerto local habitual; un tiempo de espera agotado o una redirección rechazada pueden producir el mismo código. Corrige la causa y vuelve a ejecutar el programa, en lugar de cambiar la dirección por un punto de acceso en la nube.

Un cambio en el recuento, una clave duplicada o un cambio de versión de la biblioteca o de identidad del servidor significa que el programa no pudo establecer una lectura coherente según sus comprobaciones. Detén la edición y la sincronización, confirma que esté abierta la biblioteca de Zotero que quieres usar y empieza una nueva ejecución. Un padre ausente, HTTP_404 o un tipo de padre inesperado significa que no se pudo asociar con seguridad un registro referenciado; el exportador se detiene en vez de descartar esa anotación sin avisar.

INVALID_TOTAL, INVALID_VERSION, API_VERSION y los errores de campo o esquema no válido significan que la respuesta no cumple lo que espera este programa. Mantén intacta la biblioteca y conserva el código de diagnóstico para investigarlo. No elimines líneas de validación para forzar un CSV que parezca completo. Si se producen errores del sistema de archivos, comprueba los permisos de la carpeta y la compatibilidad con enlaces duros; después, utiliza un destino nuevo en un disco local adecuado.

9. Conoce las pruebas realizadas con el programa incluido

La forma original del programa superó 28 pruebas con datos sintéticos en Linux con Node 24.19.0 y Python 3.12.14. Una Local API simulada en un puerto de bucle local elegido al azar permitió probar 101 registros repartidos en dos páginas, exactamente 100 registros, un resultado vacío, padres ausentes, respuestas cambiantes e intentos de sobrescribir un archivo existente. El lector CSV de Python comprobó de forma independiente los campos de salida, incluidos Unicode, comillas incrustadas y saltos de línea.

La forma original en varias líneas y su versión con los espacios en blanco reducidos produjeron exactamente los mismos bytes de CSV con el mismo conjunto sintético de 101 registros. Estas comprobaciones no utilizaron un proceso real de Zotero, una cuenta, una biblioteca ni una interfaz gráfica de hoja de cálculo, y no constituyen una prueba en Windows o macOS. El programa también construye la exportación en memoria; no se afirma que tenga capacidad para bibliotecas de gran tamaño. Tus propias comprobaciones del número de registros y de las fuentes siguen siendo el último paso antes de utilizar el CSV. El código mostrado invierte los operandos de dos comparaciones numéricas sin cambiar su significado. En 13 casos sintéticos adicionales, la forma original y la mostrada produjeron bytes CSV, mensajes y secuencias de operaciones idénticos mediante implementaciones simuladas de JavaScript en memoria. Esta comparación no utilizó Node ni operaciones reales del sistema de archivos o de red; no se volvió a ejecutar la batería original de 28 pruebas con Node/Python.

Ejemplo sintético de CSV interpretado que muestra dos registros de anotaciones en seis columnas. El texto del resaltado contiene un salto de línea dentro de un solo campo. El registro ink tiene el texto vacío, pero conserva comment, tagsJSON y pageLabel.
Ilustración editorial original con datos sintéticos que muestra seis de las diecinueve columnas del exportador. Una anotación produce un registro de datos aunque el texto entre comillas contenga un salto de línea. El ejemplo ink conserva un registro con text vacío y mantiene comment, tagsJSON y pageLabel; no se aplica OCR ni se deduce texto de los trazos. “Empty field” es una indicación explicativa, no contenido literal de la exportación. Watch at Ease