Coolkid mascot CoolkidLab Build in Public. Level up together.
首頁新手教學SEO 歷程開源工具庫陪跑服務關於

SEO 菜鳥成長史 · #19

為什麼 41 篇文章值得做一個日期 registry:從 LASTMOD 共用到單一事實來源

閱讀
摘要

#18 把站台跟文章的日期拆成兩個常數,但文章 schema 的 dateModified 還是 19 篇共用一個 LASTMOD,跟每篇自己顯示的「最後更新」對不上。這次把 41 篇的真實日期拆進 data/article_dates.py,用單一注入點清掉寫死的舊日期,做到畫面、schema、meta、登錄檔四方一致。41 篇全對齊,而且以後改一處就好。

1. #18 結束時還剩一個尾巴

#18 拆完站台部署日跟文章內容日兩個常數之後,GEO 盤點的新鮮度訊號從 5/6 變成 6/6,Perplexity 引用力從 62 跳到 85。

但我打開 pagespeed-67-to-93.html 的原始碼一看,那行 meta 寫的「最後更新 2026-05-09」,跟 Article schema 裡的 2026-05-28 對不上。

兩個都是我的,兩個都寫著「最後更新」,但讀者看到的是 5 月 9 日,Google 抓到的是 5 月 28 日。

如果有任何爬蟲拿兩邊比對,很多 SEO 工具都會這樣做,這個站立刻被標記成「schema 跟畫面內容不一致」。這是對 E-E-A-T 一致性的直接傷害。

更尷尬的是,19 篇 SEO 跟 pitfall 文章共用同一個 LASTMOD。

我每次部署如果改 LASTMOD,所有文章的 schema 都跟著動。如果不改,schema 就永遠停在某一天。沒有中間地帶。

這就是「為什麼 41 篇文章值得做一份日期登錄檔」的起點。

2. 真實的選項只有兩個

把每篇文章的真實日期當成資料來管理,只有兩條路:

選項 A,在每個文章的建構函式裡寫死發布日跟修訂日,再傳進 schema 產生器。

20 個 SEO 函式、13 個 pitfall 規格、8 個 workflow 規格,總共 41 個改動點,全部分散。

哪天規格變了,要改 41 個地方。

選項 B,寫一份 data/article_dates.py 登錄檔,鍵是網址路徑,值是發布日跟修訂日兩個字串。

schema 產生器跟 page_shell 自己去查這份登錄檔。改一處,全站套用。

照單一事實來源的原則,選項 B 是正解。

但選項 B 真正吸引人的地方不在乾淨,是它讓未來新增文章只要加一筆,而不是去修改 N 個位置。

3. 真實日期從哪來:git first-add + 人工策劃日 + clamp

寫登錄檔之前要先回答一個問題,每篇的發布日跟修訂日到底從哪裡知道?

發布日用 git 的首次加入紀錄,也就是每個 HTML 檔在版本庫裡第一次被 commit 的那天。

指令是 git log --diff-filter=A --follow --format=%as,這是檔案進入版本庫最權威的時間。

修訂日靠人工判斷。文章內容真的有改的時候,由我自己決定那一天是修訂日。

已經存在的 19 篇,我從 build.py 的字串裡抓出「最後更新」那個日期當作修訂日。

但要設下限。如果人工填的修訂日早於 git 首建日,那就不合理了。

例如某篇文章內文寫「最後更新 2026-05-06」,但 git 首建是 2026-05-08,修訂日不可能早於發布日。這幾個例外,我讓修訂日等於發布日。

41 篇全部跑完,整理成像下面這樣的一份對照表:

ARTICLE_DATES: dict[str, tuple[str, str]] = {
    "/seo-journey/pagespeed-67-to-93.html": ("2026-05-09", "2026-05-09"),
    "/seo-journey/gsc-basics.html": ("2026-05-08", "2026-05-08"),  # clamped
    "/workflows/threads-auto-poster.html": ("2026-05-05", "2026-05-28"),  # 內文重寫過
    ...
}

def article_dates(path: str) -> tuple[str, str]:
    return ARTICLE_DATES.get(path, (LASTMOD, LASTMOD))

查不到的時候會退回站台的 LASTMOD。

所以未來新文章還沒進登錄檔的時候,schema 仍然會出現一個合理日期,不會直接爆錯。

4. 三處接 registry:schema / page_shell / 可見日期

登錄檔是資料層,要接到三個輸出層:

schema 產生器自己查。它收到網址路徑,內部就去查登錄檔,拿到發布日跟修訂日寫進 JSON-LD。

呼叫它的地方一行都不用改。

page_shell 對文章頁查。當它判斷這是一篇文章,就去查登錄檔,寫進兩個 og meta。首頁跟 hub 不受影響。

畫面上那行「發布 X · 最後更新 Y」,這是最不直覺的部分。

原本每篇的 meta 那一行是寫死的字串,跟登錄檔各自為政。我不可能去 19 個建構函式裡手改 19 個字串。所以做了一個 page_shell 等級的注入:

_META_P_RE = re.compile(r'(<p class='meta'>)(.*?)(</p>)', re.S)
_EMBED_DATE_RE = re.compile(r'\s*·?\s*最後更新[::]\s*\d{4}-\d{2}-\d{2}')

def _inject_article_dates(body, path):
    pub, mod = article_dates(path)
    dl = date_line(pub, mod)  # 渲染「發布:X · 最後更新:Y」span
    def repl(m):
        label = _EMBED_DATE_RE.sub('', m.group(2)).rstrip(' ·')
        return f'{m.group(1)}{label}{m.group(3)}\n{dl}'
    return _META_P_RE.sub(repl, body, count=1)

在 page_shell 裡,文章頁一進來就跑一遍。

清掉舊的「最後更新」字串,再把登錄檔算出來的雙日期接在後面。

這一段是整個重構的關鍵。

單一注入點,加上清掉寫死的舊日期,等於零呼叫點改動。20 個 article_open 呼叫沒動,19 個 SEO 函式沒動,13 個 pitfall 規格沒動,8 個 workflow 規格也沒動。

改動全部收在 page_shell 跟一條正規表示式裡。

5. 4-way 一致性驗證

重構完最重要的事情是驗證

schema 改了但 og meta 沒改怎麼辦?meta 改了但畫面沒改怎麼辦?

我寫了一支四方驗證腳本。

對 41 篇文章,抓四個地方的日期,畫面那行、schema 的 dateModified、og meta、登錄檔的值,檢查它們全部相等。跑完印一行,四方一致,41 比 41。

41 篇全過,沒有半個漏網。

包括被下限修正過的 gsc-basics,畫面原本 05-06,登錄檔是 05-08,注入後畫面也變成 05-08。

還有原本沒有日期的週報,注入後補上 05-19。以及早上線、晚改寫的 threads-auto-poster,05-05 發布、05-28 修訂,兩個日期同時顯示。

這個驗證的價值不在當下這次全過,而是未來每次部署它都會再跑一遍。

新文章加錯了會立刻被抓出來。這比盤點工具還可靠,因為它檢查的是我自己定義的一致性,不是盤點工具理解的一致性。

6. 新 SOP:新文章上線多一個步驟

整套登錄檔制度落地之後,新增 SEO 文章的流程從 6 個註冊點變成 7 個。

一定要在 data/article_dates.py 加一筆日期,不然那篇會退回站台的 LASTMOD,schema 跟 og meta 都會錯。

註解寫在 build.py 跟 article_dates.py 裡,下次寫新文章看到就會想起來。

這也是單一事實來源的另一個價值,記憶量降了。我不用記「日期在 build.py 哪一行」,記得「日期在 article_dates.py」就好。

▸ 常見問題

為什麼不用 git mtime 自動算 modified?

repo 提交「已 build 過的 HTML」,每次部署都會動到所有 HTML 檔。git log 的 last commit 永遠是「上次 build」而不是「上次內容變更」。除非把 source 跟 output 拆開、用 source 端 mtime — 但重構 build.py 也會動到所有 builder 函式 mtime,一樣失準。最乾淨的還是手動 registry。

41 筆手動維護不會出錯嗎?

第一次填當然會錯。所以寫了一個 gen_article_dates.py 一次性腳本:讀 git first-add + built HTML 可見「最後更新」、clamp 不可能日期、輸出 registry 檔案。第一次生成跑完直接得到 41 筆,省去手抄。之後新增/修訂文章自己加一筆即可。

fallback 到 LASTMOD 會不會讓我忽略漏加的篇?

會。所以 4-way 驗證腳本是必備的。每次 build 後跑一次,缺 registry entry 的篇會被 visible 跟 schema 對不上抓出來。沒有驗證腳本的話這個系統很危險 — fallback 是 silent 的。

這套對 SEO/GEO 真的有差嗎?

直接的 SEO 加分有限。可查證的只有兩件。第一件是 Google 的結構化資料規範白紙黑字寫著「不要標記讀者在頁面上看不到的內容」(Google 結構化資料一般準則),違反可能被人工判決處置,也就是真人審查過後給的處罰。第二件是我自己這邊,修好之後 brand_entity audit(AI 工具評「這個站是不是一個認得出來的品牌」的那項分數)從 6/10 變 7/10,這 +1 主要靠 Service schema(告訴機器我提供哪些服務的那段標記),schema 一致性只是前提。至於「不一致會不會讓排序變差」,官方沒有講過,我也沒有證據。以下是我的推測:一份自己跟自己對不上的標記,對機器來說就是不可信的輸入,能拿來用的資訊量會下降。這是推論不是機制,你不必信。反正對齊這件事不花什麼力氣,不用等誰證實才做。

41 篇文章值得做一份日期登錄檔。

單一事實來源,加上單一注入點,加上四方驗證,換來零呼叫點改動。意外收穫不是乾淨,是記憶量降了。下篇 #20 講盤點分數從 70 推到 81 的完整 5 步紀錄。

名詞解釋

SEO(搜尋引擎優化)
讓網站在 Google 搜尋結果排得更前面的一整套方法,涵蓋技術體質、內容品質、連結結構三層。
部署(deploy)
把做好的網站或程式「推上線」讓所有人用得到的動作。
結構化資料(Schema / JSON-LD)
用機器看得懂的格式跟搜尋引擎說明「這頁是文章、作者是誰、何時更新」,有機會換到更豐富的搜尋結果外觀。
提交(commit)
Git 的一個存檔點:把這次改動連同一句說明記錄下來,之後隨時可以回到這個版本。
JSON
程式之間交換資料的通用格式,長得像一層層的「名稱:內容」清單,人眼也讀得懂。
爬蟲(crawler)
自動瀏覽網頁、把內容抓回去的程式。Google 靠爬蟲收錄網頁,AI 公司靠爬蟲收集資料。
E-E-A-T
Google 評估內容可信度的四個面向:經驗(Experience)、專業(Expertise)、權威(Authoritativeness)、可信(Trustworthiness)。
Python
入門門檻最低的主流程式語言之一,資料處理、自動化、AI 領域的首選。

看完這篇之前先確認:

適合你
  • 靜態站台、多篇文章共用一個 build pipeline、文章內容偶爾修訂
  • 想做 dateModified 但不想被 git mtime 污染(每次 build 動到全部)
  • 想做 GEO 跟 freshness 但要守住誠實 — 沒改的內容不裝今天
不適合
  • CMS 站(WordPress / Ghost / Notion-as-CMS)每篇已內建 published/modified 欄位
  • 純 evergreen 內容站、文章從不修訂(registry 退化成單常數比較簡潔)
  • 文章內容跟 schema 由不同人/不同系統管理(同步成本太大)
最常踩
  • registry 加新文章前先檢查 path 對:少一個斜線、結尾 .html 漏寫,fallback 就靜默觸發
  • modified 比 published 早 → physics violation。clamp 規則要寫進 generator script
  • 改 LASTMOD 以為文章就更新了 — LASTMOD 現在只是 fallback,registry 才是真的

不想錯過 Lab 的新文章?
訂閱後有新文章上線,就寄信通知你,
點有興趣的進來看就好。

填 email 就完成訂閱(信由 Substack 代寄)、隨時取消、不轉賣 email。

如果內容對你有用就太好了
隨喜斗內

Buy Me a Coffee at ko-fi.com
NEXT CHAPTER ▸ #20 把 GEO audit 從 70 推到 81:5 步、3 commit、每一步的分數與證據

相關閱讀

這篇背後的真實開發過程記錄在 Build Log。 搜尋標籤:georegistrysingle-source-of-truthdateModifiedrefactorbuild-in-public

本篇為個人實驗紀錄。本文做法不保證在你的網站產生相同結果,請依自身狀況驗證。教育研究用途,不構成投資建議。

← 回 SEO 菜鳥成長史

⚠ 本站所有內容僅供教育與研究用途,不構成投資建議,不保證任何獲利。投資有風險,使用者須自行判斷並承擔結果。