課程的檔案系統
實例 11 / 程式碼

build_notebooklm_sources.py|完整原始碼

把每週 manuscript、glossary、assessment 與 resources 轉成閱讀副本,排除 instructor-only 區塊並保留既有 ID 欄位。

完整內容・去識別版程式碼182 行 · 全文
來源位置(邏輯別名)Google Drive / CourseWorkspace / YZU / Management 管理學 / 1151 / Textbook / scripts / build_notebooklm_sources.py
來源版本/日期2026-09-08
資料取得方式Google Drive 實際檔案/文件匯出
使用與備份界線此程式只建置本機來源與登錄檔,不負責登入、Google Docs 寫入或 Notebook MCP 同步。需要原專案輸入目錄;本頁不執行程式。

以下說明為本次整理;正文區保留來源的內容與歷史語氣。

閱讀實際內容 ↓下載 .py回到地圖位置
網址與識別碼處理本檔未偵測到需要替換的私人網址或識別碼。假網址一律使用 example.invalid,不能用來開啟原資料;請換成自己有權使用的來源。查看處理規則。
1"""Build clean, refreshable text mirrors for the CM108G NotebookLM sources.
2
3The learner-facing HTML remains the web reading experience. This script builds
4one plain-text/Markdown-like source per week for stable native Google Docs files.
5It deliberately removes instructor-only blocks and internal governance comments.
6"""
7from __future__ import annotations
8
9import csv
10import hashlib
11import re
12from datetime import datetime, timezone
13from pathlib import Path
16ROOT = Path(__file__).resolve().parents[1]
17MODULE_ROOT = ROOT / "04_modules"
18OUTPUT_ROOT = ROOT / "01_sources" / "notebooklm" / "generated"
19MANIFEST = ROOT / "01_sources" / "notebooklm" / "notebooklm_drive_source_manifest.csv"
21INSTRUCTOR_ONLY_BLOCK = re.compile(
22 r"<!--\s*instructor-only:start\s*-->.*?"
23 r"<!--\s*instructor-only:end\s*-->",
24 flags=re.I | re.S,
26HTML_COMMENT = re.compile(r"<!--.*?-->", flags=re.S)
27LINK = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
30def between(text: str, start: str, end: str) -> str:
31 """Return the text between exact delimiters, failing loudly if absent."""
32 if start not in text or end not in text:
33 raise ValueError(f"Missing required delimiters: {start!r}, {end!r}")
34 return text.split(start, 1)[1].split(end, 1)[0].strip()
37def clean(markdown: str) -> str:
38 """Remove internal comments and make links readable to a text indexer."""
39 cleaned = INSTRUCTOR_ONLY_BLOCK.sub("", markdown)
40 cleaned = HTML_COMMENT.sub("", cleaned)
41 cleaned = LINK.sub(r"\1 (\2)", cleaned)
42 cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
43 return cleaned.strip()
46def read_title_and_question(manuscript: str) -> tuple[str, str]:
47 title_match = re.search(
48 r"^#\s+Week\s+\d+\s*(?::|\|)\s*(.+?)\s*$", manuscript, re.M
49 )
50 question_match = re.search(
51 r"^##\s+Core question\s*\n+\s*(.+?)\s*$", manuscript, re.M
52 )
53 if not title_match:
54 raise ValueError("Could not read unit title")
55 if question_match:
56 return title_match.group(1).strip(), question_match.group(1).strip()
57 # Weeks 2 and 3 predate the later explicit heading. Reuse the already
58 # published page metadata so the source mirror stays aligned with the web
59 # unit without inventing a new question.
60 week_number = re.search(r"^#\s+Week\s+(\d+)", manuscript, re.M)
61 if week_number:
62 page = ROOT / "06_web" / "pages" / f"week-{int(week_number.group(1)):02d}.html"
63 if page.is_file():
64 page_text = page.read_text(encoding="utf-8")
65 page_question = re.search(
66 r'<p class="dek">(.*?)</p>', page_text, flags=re.S
67 )
68 if page_question:
69 return title_match.group(1).strip(), re.sub(
70 r"<[^>]+>", "", page_question.group(1)
71 ).strip()
72 raise ValueError("Could not read unit core question")
75def build_week(week: int) -> tuple[Path, dict[str, str]]:
76 module = MODULE_ROOT / f"week-{week:02d}"
77 manuscript = (module / "manuscript.md").read_text(encoding="utf-8")
78 title, question = read_title_and_question(manuscript)
79 sections = {
80 "Reading": clean(
81 between(manuscript, "<!-- main-body:start -->", "<!-- main-body:end -->")
82 ),
83 "Glossary": clean((module / "glossary.md").read_text(encoding="utf-8")),
84 "Assessment and reflection": clean(
85 (module / "assessment.md").read_text(encoding="utf-8")
86 ),
87 "Resources and APA 7 references": clean(
88 (module / "resources.md").read_text(encoding="utf-8")
89 ),
90 }
91 document = [
92 f"# CM108G Become Judy — Week {week}: {title}",
93 "",
94 f"Core question: {question}",
95 "",
96 "This is the refreshable NotebookLM reading source for this week. It is "
97 "derived from the approved learner-facing course material. The web page "
98 "and this source are maintained as separate representations of the same "
99 "unit; internal project controls and instructor-only answer material are "
100 "not included.",
101 "",
102 ]
103 for heading, body in sections.items():
104 document.extend([f"## {heading}", "", body, ""])
105 output = OUTPUT_ROOT / f"week-{week:02d}.md"
106 output.parent.mkdir(parents=True, exist_ok=True)
107 output.write_text("\n".join(document).rstrip() + "\n", encoding="utf-8")
108 digest = hashlib.sha256(output.read_bytes()).hexdigest()
109 words = len(re.findall(r"\b[\w’'-]+\b", output.read_text(encoding="utf-8")))
110 return output, {
111 "week": f"week-{week:02d}",
112 "title": title,
113 "core_question": question,
114 "local_mirror": str(output.relative_to(ROOT)).replace("\\", "/"),
115 "content_sha256": digest,
116 "word_count": str(words),
117 }
120def preserve_drive_columns(rows: list[dict[str, str]]) -> list[dict[str, str]]:
121 """Keep Drive and NotebookLM IDs across rebuilds."""
122 existing: dict[str, dict[str, str]] = {}
123 if MANIFEST.is_file():
124 with MANIFEST.open(encoding="utf-8-sig", newline="") as handle:
125 for row in csv.DictReader(handle):
126 if row.get("week"):
127 existing[row["week"]] = row
128 now = datetime.now(timezone.utc).isoformat(timespec="seconds")
129 merged: list[dict[str, str]] = []
130 for row in rows:
131 old = existing.get(row["week"], {})
132 row.update(
133 {
134 "drive_folder_id": old.get("drive_folder_id", ""),
135 "drive_document_id": old.get("drive_document_id", ""),
136 "drive_url": old.get("drive_url", ""),
137 "notebook_id": old.get("notebook_id", ""),
138 "notebook_source_id": old.get("notebook_source_id", ""),
139 "last_drive_sync": old.get("last_drive_sync", ""),
140 "last_notebooklm_refresh": old.get("last_notebooklm_refresh", ""),
141 "status": old.get("status", "pending-drive-provisioning"),
142 "last_manifest_build": now,
143 }
144 )
145 merged.append(row)
146 return merged
149def write_manifest(rows: list[dict[str, str]]) -> None:
150 MANIFEST.parent.mkdir(parents=True, exist_ok=True)
151 fields = [
152 "week",
153 "title",
154 "core_question",
155 "local_mirror",
156 "content_sha256",
157 "word_count",
158 "drive_folder_id",
159 "drive_document_id",
160 "drive_url",
161 "notebook_id",
162 "notebook_source_id",
163 "last_drive_sync",
164 "last_notebooklm_refresh",
165 "status",
166 "last_manifest_build",
167 ]
168 with MANIFEST.open("w", encoding="utf-8", newline="") as handle:
169 writer = csv.DictWriter(handle, fieldnames=fields, lineterminator="\n")
170 writer.writeheader()
171 writer.writerows(rows)
174def main() -> None:
175 rows = [build_week(week)[1] for week in range(1, 19)]
176 write_manifest(preserve_drive_columns(rows))
177 print(f"Built {len(rows)} NotebookLM source mirrors")
178 print(f"Manifest: {MANIFEST}")
181if __name__ == "__main__":
182 main()
資料依據:build_notebooklm_sources.py,全文;來源版本 2026-09-08;擷取/收錄 2026-09-13。
未公開原來源分享位置;此處的來源路徑是展示別名。
本收錄文字 SHA-256:cb804a3468aac7dfbcec3e778b24c74aabf223358fc8a9016ba77520d6e04115
完整收錄清單與版本資訊