Watch at Ease看見世界,讀懂生活。

科技 · 實用指南

將 Zotero 批註匯出成 CSV,每條批註各佔一列

用既有的 Zotero 批註建立研究資料表,將引文、評論、標籤、頁碼標籤與來源書目資料放在一起,同時避免混淆出版資訊與批註日期。

原創示意圖將一條批註連到所屬 PDF 附件,再於存在上層書目項目時連到該項目。另一條分支顯示獨立附件,其 sourceStatus 為 standalone-attachment,來源書目資料留白。
原創編輯示意圖。匯出程式沿著 parentItem,從批註找到附件,再於存在書目項目時繼續找到該項目。獨立附件仍會產生批註記錄,書目來源欄位則留白。圖中選用的標籤是 CSV 輸出欄位,不是 Zotero 介面。 Watch at Ease

你已在 Zotero 中標示了一些段落,現在想用試算表進行分析:每列放一條批註,並附上評論、標籤,以及足以辨識引文出處的來源資訊。把所有批註放進同一份文件,確實方便閱讀;但這與能逐筆篩選、比較的資料表,是不同的成果。

下方的本機匯出程式會讀取 My Library 中既有的原生批註記錄,並寫入新的 CSV。它會從每條批註找到所屬附件,再於存在上層書目項目時,取得該項目的資料。筆記、Pyzotero 與 Highlight Collector 等工具也提供相關功能;這套流程則提供一套完整、具體的匯出與核對步驟。它不會轉換舊版 Extracted Annotations 筆記中的 HTML。

1. 確認這就是你需要的資料

先開啟存有研究資料的 Zotero 桌面應用程式。這支程式會讀取 Zotero 儲存的原生批註項目,包括與 PDF、EPUB 及網頁快照相關聯的批註。它不會掃描附件,尋找在其他 PDF 編輯器中建立、但尚未匯入為 Zotero 原生批註的標記,也不會將一般筆記或舊版 Extracted Annotations HTML 拆成個別記錄。

匯出範圍是 My Library 中所有符合條件、未放入垃圾桶的批註,透過本機的 users/0 存取。即使你在 Zotero 中選取某個文獻集或幾份 PDF,也不會縮小這次匯出的範圍。群組文獻庫的批註不在本程式的處理範圍內。你可以利用匯出結果中的來源與附件欄位,辨識想分析的資料;建立篩選後的工作副本前,請先保留一份未經修改的 CSV 原始檔。

先挑幾條批註,供匯出後核對:例如附有評論的螢光標記、帶有標籤的批註,以及頁碼標籤是你認得的批註。如果你有獨立儲存、沒有上層書目項目的附件,也請選一筆作為範例。這些範例能幫你分辨,究竟是記錄真的遺漏,還是某個欄位本來就應該留白。

2. 啟用本機讀取,並讓文獻庫維持不變

在 Zotero 中開啟 Settings(設定),接著進入 Advanced(進階),啟用「Allow other applications on this computer to communicate with Zotero」(允許此電腦上的其他應用程式與 Zotero 通訊)。讓 Zotero 保持執行。Local API 允許不帶 API 金鑰的讀取請求;這支程式不需要 zotero.org 雲端金鑰,也不需要變更文獻庫的授權。

請在執行 Zotero 的同一台電腦上執行程式。它會向 127.0.0.1 的一般本機通訊埠 23119 傳送唯讀請求。請勿轉送該通訊埠、將它暴露在外部網路,或改用遠端位址。匯出程式不會直接讀取 SQLite 資料庫、開啟 PDF、上傳引文,或將變更寫回 Zotero。

先讓目前正在進行的同步完成,然後在整個執行期間暫停編輯與同步。你可以中斷電腦的網路連線,避免再次同步;Local API 可在離線狀態下運作。程式完成前,請勿新增批註、變更來源書目資料或重新啟動 Zotero。程式會檢查記錄數、識別鍵與版本資訊,但這些檢查不代表匯出結果是交易快照:較舊的 Zotero Local API 回報的是已同步版本,本機編輯後,這個版本號仍可能維持不變。

3. 將完整匯出程式存成純文字檔

在同一台電腦上使用較新的 Node.js LTS 版本。開啟終端機,執行 node --version,確認 Node 可用。這支匯出程式不需要安裝任何 npm 或 pip 套件。請在支援硬連結的一般本機磁碟上建立工作資料夾,不要直接假設網路磁碟或特殊虛擬檔案系統也能使用。

將下一段的完整程式複製到純文字編輯器,以 UTF-8 編碼儲存為 export_zotero_annotations.mjs。請保留半形直引號、反斜線及所有標點符號,並確認檔名確實以 .mjs 結尾,而不是 .mjs.txt。這段程式在網頁上可能顯示為一個很長的段落;除了多行版本,空白摺疊後的版本也已經過測試。

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);});}

在存放該檔案的資料夾中開啟終端機,執行下方的語法檢查。檢查成功時,通常會直接回到命令提示字元,不顯示任何輸出。它檢查的是已儲存程式的語法,不會檢查是否能存取 Zotero,也不會確認文獻庫資料是否完整。

node --check export_zotero_annotations.mjs

4. 匯出成使用新檔名的 CSV

讓 Zotero 保持開啟且不做變更,然後在同一個工作資料夾中執行這個命令。引號中的檔名是要新建的檔案,不是指定要更新的既有匯出檔。

node export_zotero_annotations.mjs "zotero-annotations.csv"

等待終端機顯示「Exported」,後面接著批註記錄數與輸出路徑。它也會回報 standalone rows(獨立附件記錄數)與 non-text rows(非文字批註記錄數)。這些數字是補充說明,不是要再加到匯出總數上的額外記錄。請記下回報的數字,稍後核對試算表。

程式會分批要求批註記錄、驗證回應,並取得每筆記錄所參照的附件與來源。接著,它會在目的檔旁寫入暫存檔,待寫入完成後才建立最終檔名。它不會取代已存在的目的檔。再次執行時,請改用不同名稱,例如 zotero-annotations-2.csv。殘留檔名中若含有 .partial,該檔案就不是已完成的交付檔。

5. 了解批註欄位與來源欄位

總共有 19 欄。annotationKey 用來識別批註,type 記錄批註類型。highlight 與 underline 批註的段落文字放在 text 中,而 comment 會另行保留批註評論。tagsJSON 將批註的標籤物件保留為 JSON 文字,維持原有結構,不會壓平成容易混淆的逗號分隔名稱。

pageLabel 會原樣保留 Zotero 的批註頁碼標籤。不要改用附件的實際頁面索引,也不要假設標籤一定是數字:前置頁面與其他文件結構可能使用不同標籤。attachmentKey 與 attachmentTitle 則用來識別包含該批註的附件。

如果存在上層書目項目,sourceKey 與 sourceTitle 會識別該項目。sourceCreatorsJSON 保留來源文獻的結構化創作者清單及角色;sourceAuthorsJSON 只包含角色為 author 的創作者。編者不會被默默改標為作者,作者清單若是空的,也不會拿建立批註的人來填補。

sourceDate 保留來源項目原始的日期字串。若 Zotero 有提供解析後的來源日期,則會存於 sourceParsedDate。sourceYear 只會從符合 YYYY、YYYY-MM 或 YYYY-MM-DD 格式,且四位數年份不是全零的解析值中擷取。這是有限定條件的擷取規則,不是曆法有效性驗證;缺少日期或格式不符時會留白,不會猜測。

若有上層書目項目,sourceStatus 就是 bibliographic;沒有則是 standalone-attachment。獨立附件的記錄仍保留附件資訊,但來源欄位留白。最後,annotationAuthorName、annotationDateAdded 與 annotationDateModified 描述的是批註本身,沒有任何一項會用來代替出版物的作者或日期。

6. 保留非文字記錄,並讓缺漏資料保持可辨識

image(影像)、ink(手寫筆跡)、note(註記)及其他非 highlight 或 underline 的類型,仍會各有一列,保留可取得的評論、標籤與識別欄位;它們的 text 欄會留白。程式不會辨識手寫內容、將影像區域轉成引文,也不會匯出影像或筆跡本身。非文字記錄數會告訴你,分析時有多少筆記錄需要作這項區分。

如果來源作者、出版日期或頁碼標籤缺漏,請先在 Zotero 中查看對應記錄,再解讀空白的意思。必要時,先在 Zotero 修正來源書目資料;等編輯與同步都停止後,再重新匯出。不要只是為了讓每個儲存格看起來都完整,就用批註日期填補缺少的出版日期。

成功匯出零筆記錄時,檔案只會包含欄位標題。這表示在此範圍內沒有傳回符合條件的原生批註記錄,不代表你的 PDF 從未含有螢光標記。請重新確認資料是否位於群組文獻庫、只存在於舊筆記中,或仍嵌在由其他軟體加上批註的檔案裡。這支程式不會轉換這些輸入資料。

7. 以文字匯入 CSV,並核對資料列

請使用試算表的 Text/CSV(文字/CSV)匯入流程,不要直接依賴按兩下檔案開啟。選擇 UTF-8、逗號分隔符號與雙引號文字限定符號,並在載入前將每一欄設為 Text(文字)。如果匯入對話框提供相關選項,請停用自動轉換或公式解讀。若預覽畫面已經改變頁碼標籤、日期或引文,請取消匯入,再用文字處理設定重新匯入。

CSV 的每個欄位都會加上雙引號,欄位內的雙引號會重複一次,記錄之間則以 CRLF 分隔。引文與評論原本的換行仍保留在各自的儲存格內,因此一筆記錄在文字編輯器中可能佔好幾行。請將試算表解析出的記錄數扣除標題列後,與終端機回報的批註數比較;不要計算原始檔的文字行數。

將先前挑選的範例與 Zotero 逐一比對,確認完整引文、評論、標籤、頁碼標籤、附件及書目來源。檢查多行評論是否仍位於同一個儲存格,以及逗號或引號是否造成多出一欄。若資料中有獨立附件或非文字記錄,也請各核對一筆。即使你在展示用副本中隱藏 JSON 欄位,原始匯出檔仍應完整保留這些欄位。

將分析成果另存為獨立的活頁簿或檔案。請保留原始 CSV 與回報的記錄數,讓後續篩選都能追溯至這次匯出。當記錄數與抽樣檢查的來源關聯,都符合你在 Zotero 中核對的結果時,這份檔案就可以用來分析或分享。

8. 排除失敗原因,不要略過檢查

EEXIST 表示輸出檔名已存在,請改用另一個新檔名。遇到 HTTP_403 時,請檢查 Zotero 的本機通訊設定。遇到 LOCAL_REQUEST_FAILED 時,請確認 Zotero 正在同一台電腦上執行,且可透過一般本機通訊埠連線;逾時或遭拒絕的重新導向也可能產生相同代碼。修正原因後再執行,不要把位址改成雲端端點。

記錄數變動、識別鍵重複、文獻庫版本改變或伺服器身分改變,都表示程式無法依其檢查條件確認讀取結果一致。請停止編輯與同步,確認開啟的是預期的 Zotero 文獻庫,再重新執行。缺少上層項目、HTTP_404 或非預期的上層項目類型,表示無法安全地關聯所參照的記錄;匯出程式會停止,而不是默默捨棄那條批註。

INVALID_TOTAL、INVALID_VERSION、API_VERSION,以及欄位或資料結構無效的錯誤,都表示回應不符合這支程式的預期。請讓文獻庫保持完整,並保留診斷代碼以便查明原因。不要刪除驗證程式碼,強行產生一份看似完整的 CSV。若發生檔案系統錯誤,請檢查資料夾權限與硬連結支援,再於合適的本機磁碟上使用新的目的檔。

9. 了解這支程式實際經過哪些測試

程式的原寫法在 Linux 上搭配 Node 24.19.0 與 Python 3.12.14,通過了 28 項合成測試。模擬的 Local API 使用隨機選取的本機回送通訊埠,測試情境包括分成兩頁的 101 筆記錄、恰好 100 筆記錄、空結果、缺少上層項目、回應變動,以及嘗試覆寫既有檔案。另用 Python 獨立的 CSV 讀取器核對輸出欄位,包括 Unicode、欄位內的引號與換行。

原始多行版本與其空白摺疊後的形式,對同一份 101 筆記錄的測試資料產生了位元組完全相同的 CSV。這些檢查沒有使用真正的 Zotero 程序、帳號、文獻庫或試算表圖形介面,也不代表已在 Windows 或 macOS 上測試。程式也會在記憶體中建立匯出資料,因此沒有宣稱可處理多大規模的文獻庫。使用 CSV 前,最後一步仍是由你親自核對記錄數與來源。 此處展示的程式將兩處數值比較的運算元對調,比較含義不變。在額外的 13 個合成測試情境中,使用記憶體中的 JavaScript 模擬物件執行原寫法與展示寫法,CSV 位元組、訊息和操作順序完全相同。這項對照沒有使用 Node,也沒有執行真實檔案系統或網路操作;未重新執行原來的 28 項 Node/Python 測試。

以合成資料呈現的 CSV 解析範例,在六個欄位中顯示兩筆批註記錄。highlight 的文字在同一欄位內包含換行;ink 記錄的 text 留白,但仍保留 comment、tagsJSON 與 pageLabel。
使用合成資料的原創編輯示意圖,顯示匯出程式 19 欄中的 6 欄。即使引文內含換行,一條批註仍只產生一筆資料記錄。ink 範例保留一筆 text 為空白的記錄,並保有 comment、tagsJSON 與 pageLabel;沒有進行 OCR,也沒有推測筆跡文字。「Empty field」是說明標記,不是實際匯出的內容。 Watch at Ease