documents 表只有 13 個欄位,但其中幾個是踩過雷才長出來的,還有一個是為了補洞而存在。
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
exchange TEXT NOT NULL, -- 交易所: sse/szse/bj/ksc/koe/knx/jpx/twse/tpex
stock_code TEXT NOT NULL,
stock_name TEXT, -- 反正規化
announcement_id TEXT UNIQUE NOT NULL, -- 冪等的衝突鍵, 不是 id
doc_type TEXT NOT NULL,
report_period TEXT, -- 講哪一期, 不是什麼時候發的
title TEXT NOT NULL,
adjunct_url TEXT, -- 原始 PDF
content_path TEXT NOT NULL, -- 指向 R2, 正文不進資料庫
word_count INTEGER DEFAULT 0, -- >0 正常 / 0 掃描件 / -1 失敗
announcement_time INTEGER, -- 整數 timestamp
parser_version INTEGER DEFAULT 0, -- 決定這篇要不要重轉
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
content_path 存的是路徑,不是內容,markdown 全文另外寫成檔案(雲端那份在 R2)。正文是「一次寫、多次讀、幾乎不改」的大物件——塞進關聯式資料庫,會讓備份、同步、查詢全部變重。資料庫只留索引:查「有哪幾篇」很快,要正文再去拿。
id 是 AUTOINCREMENT 主鍵,但冪等的關鍵是 announcement_id 上的 UNIQUE 約束。寫入用 INSERT ... ON CONFLICT(announcement_id) DO UPDATE,所以同一篇公告重跑一百次,資料庫裡還是一列;中斷後重跑也只要問「這篇存在嗎」。
為什麼不用複合自然鍵(交易所+代碼+期間+文件類型)?因為同一家公司同一期可能發不只一次——更正、重編、補充——複合鍵會讓更正版跟原版撞在一起。來源給的公告 ID,才是那把唯一的鑰匙。
整張表最不直覺的地方:> 0 正常有內文、0 是掃描件(空的 markdown)、-1 是轉換失敗。用一個數值欄位編碼三種狀態,而不是多加一個 status 欄位——好處是少一個欄位、判斷邏輯集中在一支函式;壞處是可讀性很差,所以我把它寫進 docstring。
為什麼 0 不當成「失敗」?因為掃描件是已知且合理的結果:那篇就是沒有文字層,不是轉換壞掉。把它當失敗,它每一輪都會被重試,永遠試不完。
解析器每次改版就遞增版本號,判斷「這篇要不要重轉」的規則是三條:沒有記錄、版本落後、或是有記錄但檔案不見了。但有個例外寫在 mark_parsed() 裡:如果只是換了演算法、內容其實沒變,就只把版本號提上去,不動內文也不動字數。 另外,word_count < 0 的記錄會被跳過,即使檔案不存在也不再重拉——因為那篇本來就沒有檔案。
to_symbol() 把「交易所+代碼」轉成外面世界在用的寫法:上海 600519.SS、深圳 000001.SZ;韓國 KOSPI 用 .KS、KOSDAQ 用 .KQ、KONEX 用 .KN;日本用 .T;台灣上市 .TW、上櫃 .TWO。
我自己發明一套更整齊的寫法(例如 sse:600519)對使用者沒有好處。 他們手上已經有一堆用這種後綴寫好的程式碼,我的 API 不該逼他們改。一致性要對齊「外面已經存在的東西」,不是對齊「我覺得漂亮的東西」。
回到 Day 4 那個「雲端那份不會自己出現」的問題。它在刪除上最痛:同步程式原本只做 upsert。 我在本地清掉一份壞掉的正文,雲端那份永遠留著,網站繼續把它發給客戶,清理等於沒做。
補法是加一張墓碑表 deleted_docs:刪記錄時順手把 announcement_id 和 content_path 寫進墓碑;同步程式每次推送前先「回放」墓碑,D1 按 ID 批次刪、R2 按精確路徑刪,刪完才清墓碑。墓碑是刪除當下就記下來的,精確而且零成本——不用把幾十萬筆 ID 從 D1 拉回來做全量比對。
刪除不會自己傳播,你得自己造一個傳播機制。 這個機制後來還有第二個用途,留到 Day 25 講合規下架時再說。
表定完了,但這張表上有兩個欄位不像技術決定:api_keys 裡的 quota 和 rate_limit。明天(Day 6)把 $0 和 $31 的推導過程攤開來。