#!/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> 底下所有文章,回傳 [(網址, 標題, 日期), ...] 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): """把 [(網址, 標題, 日期)] 變成 <ul>。""" lines = ["<ul>"] for url, title, date in items: prefix = f'<span class="d">{date}</span> ' if date else "" lines.append( f'<li>{prefix}<a href="{html.escape(url)}">{html.escape(title)}</a></li>' ) lines.append("</ul>") return "\n".join(lines) def render_intro(text): """把 INTRO 的空行切成 <p>。""" blocks = [b.strip() for b in text.strip().split("\n\n")] return "\n".join( "<p>" + html.escape(b).replace("\n", "<br>") + "</p>" 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"""<!DOCTYPE html> <html lang="zh-Hant"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>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()