#!/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()