diff --git a/txt_index.py b/txt_index.py
new file mode 100644
index 0000000..b3c39b3
--- /dev/null
+++ b/txt_index.py
@@ -0,0 +1,342 @@
+#!/usr/bin/env python3
+# ==============================================================
+# txt_index.py
+#
+# 產生 build/txt/index.html —— 網站所有 .txt 純文字檔的索引頁
+#
+# 執行順序:先跑 txt.sh,再跑這支。
+# 因為這支會檢查 .txt 是否真的存在,不存在的就不列出來。
+#
+# 運作方式:
+# 1. 掃 build/blog 和 build/docs 底下的 index.html
+# 2. 從 抓標題、article:published_time 抓日期
+# 3. 算出對應的 .txt 路徑,確認檔案存在才收進列表
+# 4. 吐出一個沒有任何框架、只有幾行 CSS 的 HTML
+#
+# 前置需求:python3、beautifulsoup4
+# ==============================================================
+
+import html
+import os
+import sys
+import time
+from concurrent.futures import ThreadPoolExecutor
+from datetime import datetime
+
+from bs4 import BeautifulSoup
+
+# ---------- 以下是你可以改的地方 ----------
+
+BUILD = "path/to/build"
+
+# 首頁文字,想寫幾行就寫幾行,空行會自動變成分段
+INTRO = """
+這裡是 Wiwi.Blog 的純文字版本。
+
+站台本體在 https://wiwi.blog
+
+聯絡 Wiwi:b@wiwikuan.com
+"""
+
+# 除了 /blog 和 /docs 之外,還要列出哪些單獨的頁面
+# 想加新的就加一行,寫網址路徑就好(不用寫 .txt)
+EXTRA_PAGES = [
+ "/now",
+ "/use",
+]
+
+# 標題後面的站名後綴,會從連結文字裡拿掉
+# 例如「近況 | Wiwi.Blog」→「近況」
+# 只比對結尾,所以標題裡自己有 | 不會被誤砍
+SITE_TITLE = "Wiwi.Blog"
+
+# 站名前面可能出現的分隔符號,通常不用改
+TITLE_SEPARATORS = ["|", "|", "-", "—", "–", "·", "•"]
+
+# 連結顏色
+LINK_COLOR = "#0f766e" # 未讀,深青綠
+LINK_VISITED_COLOR = "#6b5b95" # 讀過,霧紫
+
+# 同時處理幾個檔案
+# 設 1 就是單執行緒。實測在 SSD 上差別不大(真正的瓶頸是解析,
+# 而下面的 HEAD_BYTES 已經解決了),但在慢一點的硬碟上多開會有幫助
+WORKERS = 8
+
+# 每個 HTML 只讀開頭這麼多位元組
+# 我們只要
裡的 meta,沒必要把整份 100KB 的 HTML 讀進來解析
+HEAD_BYTES = 16384
+
+# 要不要印出詳細過程(也可以在命令列加 -q 暫時關掉)
+VERBOSE = True
+
+# ---------- 以下通常不用改 ----------
+
+# blog 底下這些路徑不是文章,要跳過
+SKIP_PARTS = {"tags", "archive", "page", "authors"}
+
+
+def log(msg):
+ """VERBOSE 開著才印。"""
+ if VERBOSE:
+ print(msg)
+
+
+def strip_suffix(title):
+ """
+ 把「近況 | Wiwi.Blog」變成「近況」。
+ 只認結尾的「分隔符號 + 站名」,所以標題本身含有 | 不受影響。
+ """
+ title = title.strip()
+ if not SITE_TITLE:
+ return title
+
+ for sep in TITLE_SEPARATORS:
+ tail = f"{sep} {SITE_TITLE}"
+ if title.endswith(tail):
+ stripped = title[: -len(tail)].strip()
+ return stripped or title # 整個被砍光就還是用原本的
+ # 也處理沒空格的寫法:「近況|Wiwi.Blog」
+ tail = f"{sep}{SITE_TITLE}"
+ if title.endswith(tail):
+ stripped = title[: -len(tail)].strip()
+ return stripped or title
+
+ return title
+
+
+def read_page(html_path):
+ """讀一個 index.html,回傳 (標題, 日期字串)。抓不到就回 None。"""
+ if not os.path.isfile(html_path):
+ return None
+
+ # 只讀開頭一小段,而且在 就切斷
+ # 這是整支程式最重要的效能關鍵:Docusaurus 的 HTML 可能有 100KB 以上,
+ # 但我們要的 meta 全都在最前面的幾 KB 裡
+ with open(html_path, encoding="utf-8", errors="replace") as f:
+ chunk = f.read(HEAD_BYTES)
+
+ cut = chunk.find("")
+ if cut != -1:
+ chunk = chunk[:cut]
+
+ soup = BeautifulSoup(chunk, "html.parser")
+
+ # 標題優先用 og:title,抓不到再退回
+ # 兩者都可能帶著「| Wiwi.Blog」,統一用 strip_suffix() 清掉
+ title = None
+ og = soup.find("meta", property="og:title")
+ if og and og.get("content"):
+ title = strip_suffix(og["content"])
+ elif soup.title and soup.title.string:
+ title = strip_suffix(soup.title.string)
+
+ if not title:
+ return None
+
+ # 日期只有 blog 文章會有
+ date = ""
+ meta_date = soup.find("meta", property="article:published_time")
+ if meta_date and meta_date.get("content"):
+ try:
+ date = datetime.fromisoformat(
+ meta_date["content"].replace("Z", "+00:00")
+ ).strftime("%Y-%m-%d")
+ except ValueError:
+ pass
+
+ return title, date
+
+
+def collect(section):
+ """
+ 掃 build/ 底下所有文章,回傳 [(網址, 標題, 日期), ...]
+ section 例如 "blog" 或 "docs"
+
+ 分兩階段:
+ 1. 走訪資料夾,篩掉非文章頁與沒有 .txt 的(很快,單執行緒就好)
+ 2. 剩下的用執行緒池平行讀取 HTML 抓標題
+ 這樣訊息才不會因為平行處理而變成亂序。
+ """
+ root = os.path.join(BUILD, section)
+ stats = {"跳過(非文章頁)": 0, "跳過(沒有 .txt)": 0, "跳過(抓不到標題)": 0}
+
+ log(f"\n[{section}] 掃描 {root}")
+
+ if not os.path.isdir(root):
+ log(f" 找不到這個資料夾,跳過整個 {section}")
+ return []
+
+ # ---- 階段一:挑出候選 ----
+ candidates = [] # [(rel, html_path, txt_path), ...]
+
+ for dirpath, dirnames, filenames in os.walk(root):
+ if "index.html" not in filenames:
+ continue
+
+ # build/blog/foo/index.html → blog/foo
+ rel = os.path.relpath(dirpath, BUILD)
+
+ # 跳過 section 首頁本身(例如 build/blog/index.html)
+ if rel == section:
+ log(f" - {rel}/ ← section 首頁")
+ stats["跳過(非文章頁)"] += 1
+ continue
+
+ # 跳過 tags、archive、分頁那些非文章頁面
+ hit = SKIP_PARTS & set(rel.split(os.sep))
+ if hit:
+ log(f" - {rel}/ ← 含有 {'、'.join(sorted(hit))}")
+ stats["跳過(非文章頁)"] += 1
+ continue
+
+ # 對應的純文字檔:build/blog/foo/index.html → build/blog/foo.txt
+ txt_path = os.path.join(BUILD, rel + ".txt")
+ if not os.path.isfile(txt_path):
+ log(f" ! {rel}/ ← 找不到 {rel}.txt,txt.sh 跑過了嗎?")
+ stats["跳過(沒有 .txt)"] += 1
+ continue
+
+ candidates.append((rel, os.path.join(dirpath, "index.html"), txt_path))
+
+ candidates.sort() # 固定順序,訊息才不會每次跑都不一樣
+
+ # ---- 階段二:平行讀取 HTML ----
+ if WORKERS > 1 and len(candidates) > 1:
+ with ThreadPoolExecutor(max_workers=WORKERS) as pool:
+ pages = list(pool.map(lambda c: read_page(c[1]), candidates))
+ else:
+ pages = [read_page(c[1]) for c in candidates]
+
+ items = []
+ for (rel, _, txt_path), page in zip(candidates, pages):
+ if not page:
+ log(f" ! {rel}/ ← HTML 裡抓不到標題")
+ stats["跳過(抓不到標題)"] += 1
+ continue
+
+ title, date = page
+ size = os.path.getsize(txt_path)
+ log(f" + {date:<10} {title} ({size:,} bytes)")
+ items.append(("/" + rel.replace(os.sep, "/") + ".txt", title, date))
+
+ log(f" 收錄 {len(items)} 篇" + "".join(
+ f"、{k} {v}" for k, v in stats.items() if v
+ ))
+ return items
+
+
+def collect_extra():
+ """處理 EXTRA_PAGES 裡指定的單獨頁面。"""
+ items = []
+ log(f"\n[頁面] EXTRA_PAGES 共 {len(EXTRA_PAGES)} 項")
+
+ for url in EXTRA_PAGES:
+ rel = url.strip("/")
+ txt_path = os.path.join(BUILD, rel + ".txt")
+ if not os.path.isfile(txt_path):
+ log(f" ! /{rel} ← 找不到 {rel}.txt")
+ continue
+
+ page = read_page(os.path.join(BUILD, rel, "index.html"))
+ if page:
+ title = page[0]
+ else:
+ title = rel
+ log(f" ? /{rel} ← 抓不到標題,先用路徑名稱當連結文字")
+
+ size = os.path.getsize(txt_path)
+ log(f" + {title} ({size:,} bytes)")
+ items.append((f"/{rel}.txt", title, ""))
+
+ log(f" 收錄 {len(items)} 頁")
+ return items
+
+
+def render_list(items):
+ """把 [(網址, 標題, 日期)] 變成 。"""
+ lines = [""]
+ for url, title, date in items:
+ prefix = f'{date} ' if date else ""
+ lines.append(
+ f'- {prefix}{html.escape(title)}
'
+ )
+ lines.append("
")
+ return "\n".join(lines)
+
+
+def render_intro(text):
+ """把 INTRO 的空行切成 。"""
+ blocks = [b.strip() for b in text.strip().split("\n\n")]
+ return "\n".join(
+ "
" + html.escape(b).replace("\n", "
") + "
" for b in blocks if b
+ )
+
+
+def main():
+ started = time.time()
+ log("=" * 60)
+ log(f"txt_index.py — 產生純文字索引")
+ log(f"build 目錄:{BUILD}")
+ log(f"執行緒:{WORKERS}")
+ log("=" * 60)
+
+ extra = collect_extra()
+
+ # blog 依日期新到舊;沒日期的排最後
+ blog = sorted(collect("blog"), key=lambda x: x[2], reverse=True)
+
+ # docs 依網址排,這樣同一個資料夾的會排在一起
+ docs = sorted(collect("docs"), key=lambda x: x[0])
+
+ parts = [
+ f"""
+
+
+
+
+Wiwi.Blog|純文字版
+
+
+
+{render_intro(INTRO)}
+""",
+ ]
+
+ if extra:
+ parts.append("頁面
\n" + render_list(extra))
+ if blog:
+ parts.append("貼文
\n" + render_list(blog))
+ if docs:
+ parts.append("筆記
\n" + render_list(docs))
+
+ parts.append("\n\n")
+
+ out_dir = os.path.join(BUILD, "txt")
+ os.makedirs(out_dir, exist_ok=True)
+ out_path = os.path.join(out_dir, "index.html")
+
+ with open(out_path, "w", encoding="utf-8") as f:
+ f.write("\n".join(parts))
+
+ total = len(extra) + len(blog) + len(docs)
+ log("\n" + "=" * 60)
+ print(f"完成:{out_path}")
+ log(f" 檔案大小:{os.path.getsize(out_path):,} bytes")
+ log(f" 頁面 {len(extra)} 個、Blog {len(blog)} 篇、Docs {len(docs)} 篇"
+ f",共 {total} 個連結")
+ if blog:
+ log(f" 最新一篇:{blog[0][2]} {blog[0][1]}")
+ log(f" 耗時 {time.time() - started:.2f} 秒")
+
+
+if __name__ == "__main__":
+ # 加 -q 就安靜執行,只印最後一行
+ if "-q" in sys.argv or "--quiet" in sys.argv:
+ VERBOSE = False
+ main()
\ No newline at end of file