我原本以為,替 ResearchForge 多接一個學術資料來源,只是多寫一次 API 呼叫。直到我把 2026 年 6 月 26 日的三種回應放在一起讀,才發現真正麻煩的不是網址不同,而是它們對「一篇文獻」的描述方式根本不同。
Crossref 的題名可能放在陣列,作者可能是完整姓名,也可能拆成名字與姓氏;OpenAlex 的摘要不是現成段落,而是詞語與位置的索引;Semantic Scholar 沒有摘要時,還可能提供一段 TLDR。若我只是把這些值都塞進 abstract、authors、year,型別會變整齊,資料來歷卻可能一起消失。[1][2]
Day 06 解決的是「查詢要怎麼送出去而不改題」。到了 Day 07,我面對的是另一個邊界:不同 provider 回來的資料,怎麼翻成 ResearchForge 能共同處理的語言,又不讓下游誤以為它們原本就一樣?
我先踩到的閱讀陷阱,是從檔名推測架構。當時確實已有 Crossref、OpenAlex 與 Semantic Scholar 的 provider 模組,但三個檔案主要保存服務名稱、啟用旗標與預設連線位置。真正解析回應的程式不在那裡,而是在來源搜尋服務裡。[1]
這個差別很重要。設定模組回答「要連到哪個服務」;adapter 才回答「外部欄位如何變成內部紀錄」。只看到 provider 目錄就宣布串接完成,等於看到插座便假設電器已經能運作。
ResearchForge 當時把三種回應轉成共同的 SourceRecord。三個轉換函式都先找題名;沒有可用題名就回傳 null,呼叫端也不把那筆資料放進來源清單。這是一道可辨識性門檻,不是品質審查:有題名只代表紀錄可以被處理,不代表它相關、可信或足以引用。[2]
把三個 adapter 並排後,我看到的不只是欄位名稱差異,而是三種不同的資訊壓縮方式:
| Provider | 外部資料的形狀 | 轉成共同欄位時的處理 | 被壓縮的差異 |
|---|---|---|---|
| Crossref | 題名陣列、作者姓名片段、多組出版日期、可能含標記的摘要 | 選第一個可用題名、組合作者、依固定次序取年份、清除摘要標記 | 多組日期最後只留一個年份 |
| OpenAlex | 題名或顯示名稱、作者關係、摘要倒排索引 | 展開詞語位置、排序後重組摘要,再整理作者與期刊資訊 | 下游只看到重組後文字 |
| Semantic Scholar | 題名、作者名稱、摘要,以及可選的 TLDR | 優先取摘要,缺少時改用 TLDR 文字 | 兩種內容最後進入同一個 abstract 欄位 |
這些處理都能在 6 月 26 日的歷史實作中找到。[2] 它們讓畫面、去重與後續報告不必理解三套 JSON,代價則是共同欄位可能遮住原始差別。
例如 OpenAlex 沒有摘要索引時,程式保留 null,而不是自行補寫文字;這是好的缺值行為。Semantic Scholar 的 TLDR fallback 則比較微妙:它讓紀錄仍有可讀內容,但當時沒有另外記錄 abstract 究竟來自主摘要還是 TLDR。下游若把「非空」直接翻譯成「取得原始摘要」,就會超出資料實際保存的資訊。
這也改變了我寫 adapter 測試的方式。我不只檢查輸出欄位有沒有值,還會為每一種取值分支準備案例:原始摘要存在時取了什麼、替代文字何時啟用、缺值是否仍維持缺值。真正需要保護的不是畫面一定有字,而是轉換後的語意沒有被悄悄改寫。
轉換完每一筆資料後,搜尋服務還要描述整次 provider 呼叫發生了什麼。當時的共同結果契約如下,這是歷史原碼而非示意:
export interface SourceProviderSearchResult {
ok: boolean;
provider: SourceProvider;
query: string;
results: SourceRecord[];
error_type: SourceSearchErrorCode | null;
error_message: string | null;
}
這個型別把資料與狀態放在一起:哪個 provider、用了哪個 query、得到哪些 records,以及失敗類型與訊息。results 是共同資料;其他欄位則保留這批資料的執行脈絡。[2]
它也揭露一個容易誤讀的地方:ok: true 可以搭配空陣列。這只表示該呼叫沒有在這層被判成錯誤,不表示找到文獻,更不表示找到可用證據。HTTP、provider outcome、候選來源與研究材料,是四個不能互相代替的判定層次。
多 provider 搜尋的價值之一,是單一服務失敗時仍可能保留其他結果。歷史程式會先收集各 provider 的共同結果;只要至少一個成功,就合併成功部分、執行去重並限制輸出。全部失敗時,才拋出整體錯誤。[2]
我重新執行同一版本的固定資料測試後,確認了三種正規化、OpenAlex 摘要重組、Semantic Scholar TLDR fallback、去重,以及「一個 provider 成功、另一個失敗仍保留部分結果」等契約。這些都是隔離 fixture,不是真實線上成功率,也不能證明三個服務對任何題目都穩定。
Partial success 若只留下來源陣列,下游會看到資料,卻不知道哪個服務沒有回來。日後想判斷是否重試、是否提示材料不完整,便失去依據。因此失敗資訊不是除錯雜訊,而是研究流程的一部分;但前台也不必直接傾倒原始錯誤,只要把它轉成使用者能採取行動的狀態。
同日的研究正規化模組還會把 SourceRecord 轉成 NormalizedSource:整理 DOI、題名、作者與文字,建立供去重使用的鍵值,並保留 provider、query 與取得時間。[3] 這一步讓後續模組使用較穩定的資料語言。
不過,模型雖然提供 rawProviderPayload,轉換時若沒有額外傳入,欄位預設仍是 null。[3] 「資料結構允許保存原始回應」與「這次真的保存了原始回應」不能寫成同一件事。若未來要追查摘要來源、日期衝突或欄位覆寫,只有共同值可能還不夠。
另一個需要警覺的地方是狀態轉換。三個搜尋 adapter 建立的 SourceRecord 都先標為 pending;同版本另一條從 NormalizedSource 轉回 SourceRecord 的路徑,卻直接指定為 accepted。[2][3] 這不能證明所有搜尋結果會自動通過,但已足以要求我追查實際資料走哪條路,而不是相信型別名稱會替狀態守門。
第一,辨識問題:缺少哪些欄位時,這筆紀錄根本不該進入共同流程?第二,來歷問題:共同欄位是原始值、清理值、重組值,還是 fallback,接收端需要知道嗎?第三,失敗問題:空結果、請求失敗與部分成功,是否會保留成不同狀態?
這三問比先畫一個很大的萬用 schema 更有用。共同模型的目的,是隔離 provider 差異;它不該消滅差異,也不能順手決定資料是否足以進入報告。Adapter 負責翻譯,來源審查仍要判斷能否採用,後續證據層再判斷能主張到哪裡。
三種回應終於能說同一種資料語言後,下一個錯誤會更隱蔽:它們可能其實在描述同一篇文獻。若把三條發現路徑算成三份研究,來源數、證據廣度與 Writer 的語氣都會被放大。Day 08,我會從 DOI、作者與年份開始,處理去重其實是在辨認研究身分這件事。
[1] ResearchForge 專案歷史,2026 年 6 月 26 日:Crossref、OpenAlex、Semantic Scholar provider 設定模組。
[2] ResearchForge 同日來源搜尋服務與固定資料測試:三種 response adapter、共同 provider outcome、部分成功與去重流程。
[3] ResearchForge 同日研究資料正規化模組:SourceRecord/NormalizedSource 轉換、dedupe key 與 raw payload 保存邊界。