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> 底下所有文章,回傳 [(網址, 標題, 日期), ...] + 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() \ No newline at end of file