neuron-explorer
Steering-first neuron explorer for Qwen3.5-4B: push every MLP neuron, keep every rollout, browse the results
翻译准备中
尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证臺北科技大學課程資料爬蟲及API
翻译准备中
取自仓库 README
正在加载完整 README。 以下为已保存的摘录。
國立臺北科技大學課程資料的非官方靜態 API。 每 4 小時自動爬取學校課程查詢系統,轉成結構化 JSON 後發布到 GitHub Pages。 沒有伺服器、沒有資料庫、不需要 API key —— 直接 fetch() 就能用。 資料依系所 / 教師 / 班級 / 學程 / 教室 / 時段分別建索引, 查一位老師的課不必先下載全校兩千多門課。 涵蓋 90-1 至 115-1 共 51 個學期、141,018 門課(民國 90 年起,約 25 年)。 課程數是 2026-09-05 的實測值,選課期間會小幅變動;即時數字請讀 meta.json 的 semesters[].course_count。 下面所有路徑都是相對於這個 base URL。{semester} 例如 115-1。 所有回應都帶 Access-Control-Allow-Origin: *,瀏覽器可以直接跨網域 fetch(), 不需要 proxy。Content-Type 是 application/json; charset=utf-8, Cache-Control 是 max-age=600(GitHub Pages 的設定,本專案改不了)。查看完整 README ↗
國立臺北科技大學課程資料的非官方靜態 API。
每 4 小時自動爬取學校課程查詢系統,轉成結構化 JSON 後發布到 GitHub Pages。
沒有伺服器、沒有資料庫、不需要 API key —— 直接 fetch() 就能用。
資料依系所 / 教師 / 班級 / 學程 / 教室 / 時段分別建索引, 查一位老師的課不必先下載全校兩千多門課。
涵蓋 90-1 至 115-1 共 51 個學期、141,018 門課(民國 90 年起,約 25 年)。
課程數是 2026-09-05 的實測值,選課期間會小幅變動;即時數字請讀 meta.json
的 semesters[].course_count。
https://tntrock.github.io/ntut-course-crawler/
下面所有路徑都是相對於這個 base URL。
{semester}例如115-1。
所有回應都帶 Access-Control-Allow-Origin: *,瀏覽器可以直接跨網域 fetch(),
不需要 proxy。Content-Type 是 application/json; charset=utf-8,
Cache-Control 是 max-age=600(GitHub Pages 的設定,本專案改不了)。
| 我想查 | 打這支 | 例 |
|---|---|---|
| 現在有哪幾個學期、有什麼端點 | meta.json | |
| 關鍵字搜尋全部課程 | {semester}/index.json | 115-1/index.json |
| 某個系所的課 | {semester}/courses/{系所代碼}.json | 115-1/courses/59.json |
| 某個老師的課 | {semester}/teachers/{教師代碼}.json | 115-1/teachers/12095.json |
| 某個班級的課表 | {semester}/classes/{班級代碼}.json | 115-1/classes/2915.json |
| 有哪些老師 / 班級 / 系所 | {semester}/teachers.json、classes.json、departments.json | |
| 某個學程有哪些課 | {semester}/programs.json | |
| 某間教室排了什麼課 | {semester}/classrooms.json | |
| 星期幾第幾節有什麼課(找空堂、擋修衝堂) | {semester}/schedule.json | |
| 最近有哪些課 / 老師被加開、停開、調課、換人 | changes.json | |
| 退選率、修課人數 | 課程物件的 enrolled / withdrawn(見下) | |
| 退選率隨時間的走勢 | enrollment.json | |
| 某門課的教學大綱(進度、評量、教材、SDGs) | {semester}/syllabus/{課號}.json | 115-1/syllabus/364893.json |
不知道代碼?先拉 {semester}/departments.json、teachers.json、classes.json,
每一筆都附了 path 欄位,直接接在 base URL 後面就是明細檔的網址。
| 路徑 | 內容 | 115-1 實際大小 |
|---|---|---|
meta.json | 學年期清單、端點清單、節次與必選修對照 | 13 KB |
index.json | 最新兩個學期的課程輕量索引 | 1.5 MB(gzip 149 KB) |
errors.json | 目前的抓取失敗與警告(學期、單位、班級、大綱、教室容量) | 通常 < 10 KB |
runs.json | 最近 120 次抓取的執行紀錄,含失敗與逾時(見下) | 約 50 KB |
changes.json | 最近的課程與教師異動事件(見下) | 隨異動量變動 |
enrollment.json | 修課 / 撤選人數逐日快照的索引(見下) | 隨天數變動 |
{semester}/enrollment/{date}.json | 某一天的逐課修課 / 撤選人數 | 約 120 KB / 天 |
capacity.json | 教室容量(座位數) | 約 52 KB |
syllabus.json | 教學大綱的抓取進度與逐課狀態(見下) | 隨課數變動 |
{semester}/syllabus/{course_id}.json | 單一課程的教學大綱與進度 | 約 3–8 KB / 門 |
{semester}/index.json | 單一學期的課程輕量索引 | 711 KB |
{semester}/departments.json | 學院 / 系所 / 班級三層對照 | 48 KB |
{semester}/courses/{department_id}.json | 系所課表(完整課程物件) | 60 檔,共 1.5 MB |
{semester}/teachers.json | 教師清單 | 89 KB |
{semester}/teachers/{teacher_id}.json | 教師課表(完整課程物件) | 803 檔,共 1.7 MB |
{semester}/classes.json | 班級清單 | 70 KB |
{semester}/classes/{class_id}.json | 班級課表(完整課程物件) | 286 檔,共 1.7 MB |
{semester}/programs.json | 學程 → 課號 | 16 KB |
{semester}/classrooms.json | 教室 → 課號 | 51 KB |
{semester}/schedule.json | 星期 × 節次 → 課號 | 55 KB |
根目錄
/是一頁靜態說明頁。要拿最新兩個學期的索引請明確打/index.json。
同一組資料另外有三頁給人看的:/status.html(爬蟲跑得如何、各學期資料多新、
大綱補到哪、人數快照)、/errors.html(目前抓不到的單位與課程)、
/changes.html(最近的加開、停開、調課與教師異動)。
它們只讀上表這些 JSON,沒有後端 —— 你自己也可以照樣讀。
明細檔(courses/ teachers/ classes/)放完整課程物件,拿到就能直接顯示,
不必再載別的檔對照。清單檔(teachers.json classes.json …)只放
「有哪些、各幾門課」,做下拉選單時不會被整包資料拖慢。
programs.json / classrooms.json / schedule.json 只放課號 —— 拿課號去
{semester}/index.json 或系所明細檔查即可,避免同一份課程資料被複製太多份。
changes.json:最近的異動排程每 4 小時重跑一次,但大部分時候資料一個字都沒變。光看檔案時間戳 分不出「只是重跑」和「學校真的動了課」,要確認就得自己下載前後兩版來 diff。
changes.json 是一條事件流:一筆異動一個事件、各自帶偵測到的時間,
最新的在最前面。想知道最近發生什麼,讀開頭幾筆就好。
{
"checked_at": "2026-09-04T12:03:11Z",
"event_count": 4,
"events": [
{
"at": "2026-09-04T12:03:11Z", "semester": "115-1",
"type": "course_removed",
"id": "361339", "name": "體育",
"teachers": [], "department_ids": ["59"], "class_ids": ["2915"]
},
{
"at": "2026-09-04T12:03:11Z", "semester": "115-1",
"type": "course_changed",
"id": "364893", "name": "數位影像處理",
"teachers": ["陳香君"], "department_ids": ["59"], "class_ids": ["2915"],
"changes": { "teachers": { "from": ["白敦文"], "to": ["陳香君"] } }
},
{
"at": "2026-09-04T08:03:07Z", "semester": "115-1",
"type": "teacher_removed",
"id": "12095", "name": "白敦文",
"course_count": 1, "department_ids": ["59"]
}
]
}
type | 意思 | 特有欄位 |
|---|---|---|
course_added | 加開一門課 | |
course_removed | 停開一門課 | |
course_changed | 課的內容變了 | changes(逐欄位的 from / to) |
teacher_added | 這學期多了一位老師 | course_count(他開幾門課) |
teacher_removed | 這學期少了一位老師 | course_count(消失前開幾門課) |
baseline | 第一次抓到這個學期,沒有基準可比 | course_count |
bulk_change | 一次異動量太大,折成摘要 | counts、by_department、by_class、samples |
at 是偵測到的時間,不是學校異動的時間 —— 實際異動發生在上一次抓取
與這次之間,最多相差 4 小時。
course_removed 是跟學校查證過的。 課從資料集消失有兩種可能:真的停開,
或者我們那一輪讀錯了。只有「消失」還不夠 —— 每一筆都會再去問學校的教學大綱頁,
它回「查無課號」才記成停開。問不出結論的那一輪整個不發布,寧可晚 4 小時,
也不要在這裡放一筆假的停開。
加欄位不算異動。 比對只看兩邊都有的欄位 —— 索引的欄位會隨程式演進而
增加,新欄位第一次出現時舊索引裡根本沒有那個 key,把它當成「從 null 變成
某值」會讓全校幾百門課一次全部變成 course_changed。那不是學校動了資料。
事件是 append-only 的,寫下去就不會再改。 所以舊事件可能缺少後來才加的
欄位(例如 2026-09-04 10:24 之前寫入的 bulk_change 沒有 by_department /
by_class / samples)。使用端請把 type 以外的欄位一律當成選填,
缺了就降級顯示,不要假設一定存在。
checked_at 是最後一次比對的時間,沒有異動時也會更新。所以
「checked_at 是今天但沒有新事件」=學校真的沒動;
「checked_at 停在三天前」=爬蟲沒在跑。
course_changed 比對的欄位是課名、教師、時段、學分、必選修、開課系所、班級。
教師事件是用教師代碼比對的,不是姓名 —— 115-1 實測 803 個代碼只對到 801 個姓名,確實有同名老師。換人授課時課還在,只有教師端看得出少了誰。
最多保留 500 筆事件。
只有最新兩個學期會產生事件 —— 比對基準是頂層 index.json,它只涵蓋
那兩個。第一次抓到某個學期時記成 baseline,不會變成「新增兩千多門課」。
單次超過 200 筆異動會折成一筆 bulk_change,避免把先前真正的異動整個
推出保留範圍。摘要會帶依系所與班級的分組統計加上前 10 筆完整樣本,
所以不必再自己去 diff。2026-09-04 實際遇到的那次長這樣:
{
"type": "bulk_change", "event_count": 267,
"counts": { "course_added": 265, "course_removed": 2 },
"by_department": { "01": 185, "14": 80, "36": 1, "37": 1 },
"by_class": { "589": 80, "2519": 42, "2520": 38, "2522": 35,
"2524": 32, "2523": 28, "2521": 10, "…": 1 },
"samples": [ { "type": "course_added", "id": "367177",
"name": "全球化與多元文化", "class_ids": ["589"] } ]
}
兩個單位、7 個班級 —— 一眼就看得出是學校開了一批跨校選課(589 博雅選修
跨校、2519–2524 與北大 / 北醫 / 海大的跨校選課),而不是解析器壞了。
真的抓歪時分組會散落在幾十個系所,一樣一眼分得出來。
by_department 全列(系所頂多 60 個),by_class 只留量最大的 20 個。
一門課可能同時掛多個系所或班級,所以分組數量的總和會大於 event_count。
學校的課程表本身就有「人」和「撤」兩欄,所以不必去抓教學大綱:
| 欄位 | 意思 |
|---|---|
enrolled | 修課人數(原始頁面的「人」欄) |
withdrawn | 撤選人數(原始頁面的「撤」欄) |
quota | enrolled 的舊名,已棄用,值完全相同 |
quota這個名字會讓人以為是名額上限,其實一直都是修課人數 —— 114-2 全校有 111 種不同的值、只有 21% 是 5 的倍數,名額上限不會長這樣。 舊欄位保留不刪(見相容性承諾),新的程式請用enrolled。
兩個欄位在課程物件({semester}/courses/、teachers/、classes/ 的明細檔)
和輕量索引(index.json、{semester}/index.json)裡都有,所以算全校退選率
不必先下載 60 個系所明細檔。
人 到底是撤選後的現存人數,還是含撤選的原始人數,學校沒有說明,
從資料本身也判斷不出來。兩種算法在全校層級差很小,但對單一課程差很多:
撤 / 人 ← 若「人」不含撤選的人
撤 / (人 + 撤) ← 若「人」是撤選後的現存人數
114-2 全校實測:修課 73,403 人次、撤選 1,596 人次 → 兩種算法分別是 2.17% 與 2.13%。但那門撤選 17 人、現存 30 人的 「物件導向程式設計」,兩種算法是 56.7% 和 36.2%,差很多。
要判定的話:115-1 的撤選期還沒到(「撤」目前全是 0),
等撤選開始後看某門課的 enrolled 會不會隨 withdrawn 上升而下降就知道了。
enrollment.json 的逐日快照正是為了留下這段資料。
enrollment.json:人數的時間軸明細檔裡的人數是當下的值,每次抓取直接覆蓋。學期結束後那是定案的數字, 拿來算退選率沒問題;但「哪門課在第幾週被大量退掉」這種問題,快照沒留下來 就永遠答不了,而且錯過了要再等一個學期。
所以每天存一份(依台灣時區切日,同一天內重複抓取就覆蓋當天那份):
{
"snapshot_count": 12,
"snapshots": [
{ "semester": "115-1", "date": "2026-09-05",
"at": "2026-09-05T12:03:11Z",
"course_count": 2717,
"enrolled_total": 73403, "withdrawn_total": 1596,
"path": "115-1/enrollment/2026-09-05.json" }
]
}
enrollment.json 只放每天的總計,全校退選率的走勢靠它就畫得出來。{semester}/enrollment/{date}.json,
裡面是逐課的 id / enrolled / withdrawn。人數的變動刻意不進
changes.json。 加退選期間每 4 小時就有上千門課的 人數在動,塞進事件流會每次都觸發bulk_change,把真正的結構性異動 (加開、停開、調課、換老師)整個淹掉 —— 兩種資料的變動頻率差一個數量級。
runs.json「爬蟲現在怎麼樣了」這件事,GitHub Actions 的 log 回答不了——它要登入點進去
或帶 token 打 API,而且只留 90 天。一個靜態的狀態頁需要的是一份跟資料放在
一起、直接 fetch() 就拿得到的紀錄。這個檔就是。
{
"schema_version": 3,
"generated_at": "2026-09-05T12:31:07Z",
"run_count": 47,
"runs": [
{
"at": "2026-09-05T12:31:07Z",
"workflow": "backfill",
"status": "success",
"event": "workflow_dispatch",
"run_id": "33959063005",
"attempt": 1,
"url": "https://github.com/tntrock/ntut-course-crawler/actions/runs/33959063005",
"attempts": 1,
"detail": true,
"attempt_started_at": "2026-09-05T09:51:02Z",
"requests_ok": 7100,
"failed_urls": 0,
"cache_hits": 0,
"exit_code": 0,
"semesters": [
{"semester": "114-2", "courses": 2809, "departments_ok": 61,
"departments_failed": 0, "errors": 0, "seconds": 524.8,
"syllabus_fetched": 1968, "syllabus_written": 1968}
],
"failed_semesters": []
}
]
}
status 是 job 的狀態,不是爬蟲的回傳值:success / failure / cancelled
(job 逾時被砍算 cancelled)detail: false 代表爬蟲沒能寫出側寫檔——被砍在半路,或更早的步驟就失敗了。
這時 semesters 等欄位整組不存在。刻意不填 0,那會被讀成
「跑了但什麼都沒抓到」,跟「根本沒跑到」是兩回事run_id + attempt 只會有一筆;重試是新的 attempt,各自一筆attempt 是 GitHub 的 re-run 按鈕,
attempts 是 workflow 內部那個「等 30 分鐘整批重來」的迴圈跑了幾次。
「一次就過」跟「重試三次才過」對狀態頁是兩回事requests_ok 只算成功的請求,要跟 failed_urls(重試到底仍然放棄的網址數)
一起看。2026-09-05 那次回補失敗就是 requests_ok: 0 配 failed_urls: 3 ——
連學校的門都沒摸到attempt_started_at 是最後那一次嘗試的開始時間。整批重試每次都重跑一個
新的行程、重寫側寫檔,所以它不等於整個 run 的開始時間(那個看 url 進去查)要做狀態頁的話,這幾個檔合起來就夠了:
runs.json(最近跑得如何)、meta.json的semesters[].generated_at(每個學期的資料多新)、errors.json(現在有哪些東西抓不到)、syllabus.json(大綱補到哪了)。 全部都是 CORS 開放的靜態 JSON,前端直接fetch()。
課程物件的 syllabus_url 是學校原始頁面的網址;
{semester}/syllabus/{課號}.json 是解析過的內容:
{
"course_id": "364893", "course_name": "數位影像處理",
"teachers": ["白敦文"], "department_ids": ["59"],
"url": "https://aps.ntut.edu.tw/course/tw/ShowSyllabus.jsp?snum=364893&code=12095",
"updated_at": "2026-08-11T01:00:23Z",
"has_content": true,
"content_hash": "3f8a1c04b27de591",
"teacher_name": "白敦文", "teacher_email": "[email protected]",
"outline": "This course will be lectured in Chinese/English…",
"schedule": "1. Introduce to Image Processing\nAI applications\n…",
"flexible_learning": {
"category": ["學生分組實作及討論", "AI 輔助學習與工具應用", "遠距教學"],
"content": "…", "hours": 3, "outcome": "…", "assessment_ratio": "…"
},
"assessment": "Quiz:15%, Homework:15%, Midterm 35%, Final 35%.",
"materials": "…", "contact": ["研究室分機(Extension): 4222", "…"],
"extended_resources": ["混成學習 (Blended Learning)", "…"],
"sdgs": ["SDG4:優質教育(Quality Education)", "…"],
"ai_usage": ["使用生成式AI作為教學過程的輔助工具", "…"],
"notes": "…"
}
updated_at 是老師最後修改大綱的時間(學校顯示台灣時間,這裡轉成 UTC,
跟全站其他時間戳一致)。content_hash 是這份內容的指紋(sha256 前 16 個 hex 字)。內容沒變就不會重寫這個檔,
所以拿它當快取鍵是安全的。我們抓下來的時間不在這個檔裡,請查 syllabus.json。has_content 為 false 代表老師還沒填。檔案仍然會產生 —— 沒有它的話,
每次執行都會再去問一次同一頁。sdgs、ai_usage、contact、extended_resources)輸出成陣列,
項目符號已去掉。長文欄位(outline、schedule、materials)保留原樣的換行,
不自作聰明切開。extra。學校加新欄位時
(SDGs 和「是否導入 AI」顯然就是近年才加的)才不會靜靜漏抓半年。syllabus.json 的 fetched[學期][課號].at。syllabus.json 的三個區塊{
"schema_version": 3,
"semesters": [
{"semester": "115-1", "fetched": 1909, "with_url": 1909, "course_count": 2717,
"oldest_fetch": "2026-09-05T06:01:09Z", "newest_fetch": "2026-09-05T06:41:05Z"},
{"semester": "114-2", "fetched": 1968, "with_url": 1968,
"frozen": true, "frozen_at": "2026-09-06T02:11:40Z"}
],
"fetched": {
"115-1": {"360744": {"at": "2026-09-05T06:01:09Z", "hash": "3f8a1c04b27de591"}}
},
"frozen": {"114-2": {"fetched": 1968, "with_url": 1968, "at": "2026-09-06T02:11:40Z"}}
}
semesters 是給人看的進度,一個學期一列fetched 是給下一次執行看的逐課狀態(抓取時間 + 內容雜湊)frozen 是已經補完、逐課狀態收掉了的歷史學期,只留門數
semesters[].fetched是進度的分子:現在還有大綱連結、而且已經抓過的 門數。它跟頂層fetched的筆數不一定相等——課換了老師,學校的大綱連結 會跟著消失,但抓過的那筆逐課狀態刻意留著(連結回來時不必重抓)。 分母with_url是當下有連結的門數,所以進度不會超過 100%。
中文檔名在 GitHub Pages 上會被 percent-encoding,使用者得自己處理 URL 編碼; 而且系所會改名,代碼相對穩定。中文名稱在各清單檔裡提供對照。
{semester}/courses/{代碼}.json 的代碼。代碼是字串不是數字
("01" 有前導零,"C5" 有英文字母),JSON 解析時不要當整數處理。
以下為 115-1 實際抓到的 60 個單位;學校新增單位時 departments.json 會即時反映,
這張表只是方便閱讀的快照。
| 代碼 | 系所 | 代碼 | 系所 |
|---|---|---|---|
31 | 電機系 | 36 | 電子系 |
59 | 資工系 | 65 | 光電系 |
82 | 電資學士班 | 99 | 電資外國學生專班 |
AY | 太空所 | C5 | 電資學院 |
| 代碼 | 系所 | 代碼 | 系所 |
|---|---|---|---|
2B | 智動科 | 30 | 機械系 |
40 | 機電所 | 44 | 車輛系 |
45 | 能源冷凍空調系 | 56 | 製科所 |
61 | 自動化所 | 66 | 機電科所 |
81 | 機電學士班 | A8 | 機電科技博士外生專班 |
AG | 機械自動化外生專班 | B2 | 半導體學士學位學程 |
B3 | 半導體外生專班 | C0 | 機電學院 |
| 代碼 | 系所 | 代碼 | 系所 |
|---|---|---|---|
32 | 化工系 | 33 | 材資系 |
34 | 土木系 | 35 | 分子系 |
42 | 防災所 | 51 | 高分所 |
60 | 環境所 | 68 | 生化所 |
73 | 化工所 | 78 | 材料所 |
79 | 資源所 | 83 | 工程科技學士班 |
A0 | 能源光電外國學生專班 |
| 代碼 | 系所 | 代碼 | 系所 |
|---|---|---|---|
37 | 工管系 | 57 | 經管系 |
74 | 管理所 | 98 | 管理外國學生專班 |
AB | 資財系 | C2 | 管理學院 |
| 代碼 | 系所 | 代碼 | 系所 |
|---|---|---|---|
38 | 工設系 | 39 | 建築系 |
52 | 建都所 | 58 | 創新所 |
84 | 創意設計學士班 | 85 | 設計所 |
AC | 互動系 | AT | 互動與創新外生專班 |
| 代碼 | 系所 | 代碼 | 系所 |
|---|---|---|---|
49 | 技職所 | 54 | 英文系 |
91 | 科技法律學程 | A4 | 智財所 |
A5 | 文發系 |
| 代碼 | 系所 |
|---|---|
C7 | 創新學院 |
college 為 null)| 代碼 | 系所 | 代碼 | 系所 |
|---|---|---|---|
01 | 教務處 | 10 | 體育室 |
14 | 通識中心 | 62 | 師資培育中心 |
AA | 校院級課程 |
C0C2C5C7這種「學院」單位掛的是院級共同課程, 不是把該學院所有系的課集合起來。要一個學院的全部課程, 請照departments.json的colleges分組,逐系所取。
meta.json{
"schema_version": 3,
"generated_at": "2026-09-04T02:03:11Z",
"source": { "name": "國立臺北科技大學 課程查詢系統", "url": "https://aps.ntut.edu.tw/course/tw/" },
"disclaimer": "本資料由非官方爬蟲自動蒐集,僅供參考…",
"latest": "115-1",
"semesters": [
{
"year": 115, "sem": 1, "path": "115-1",
"generated_at": "2026-09-04T02:03:11Z",
"partial": false,
"department_count": 60,
"class_group_count": 286,
"course_count": 2455,
"merged_course_count": 310,
"failed_department_count": 0
}
],
"endpoints": [
{ "path": "{semester}/teachers/{teacher_id}.json", "description": "教師課表" }
],
"periods": [{ "code": "1", "start": "08:10", "end": "09:00" }],
"requirement_symbols": [{ "symbol": "★", "required": false, "requirement_type": "專業選修" }]
}
latest 是目前最新的學年期。不要把學年期寫死在前端,讀這個欄位。
115-1/teachers.json → 115-1/teachers/12095.json{
"teacher_count": 803,
"teachers": [
{
"id": "12095",
"name": "白敦文",
"course_count": 3,
"department_ids": ["59", "C5"],
"path": "115-1/teachers/12095.json"
}
]
}
{
"schema_version": 3,
"year": 115, "sem": 1,
"teacher": {
"id": "12095",
"name": "白敦文",
"department_ids": ["59", "C5"],
"url": "https://aps.ntut.edu.tw/course/tw/Teach.jsp?format=-3&year=115&sem=1&code=12095"
},
"course_count": 3,
"courses": [ { "id": "364893", "name_zh": "數位影像處理", "...": "完整課程物件" } ]
}
教師以代碼為準,不是姓名。 115-1 有 803 個教師代碼但只有 801 個不同姓名 ——
確實有同名老師,用姓名分組會把兩個人的課混在一起。
程式對「有姓名但沒有 Teach.jsp 連結」的老師會輸出 id 與 path 為 null,
但實際資料裡沒有這種情形(90-1 至 115-1 抽查皆為 0 筆)——
沒有指定教師的課(115-1 有 504 門,多為班週會)根本不會產生教師條目。
115-1/classes.json → 115-1/classes/2915.json{
"class_count": 286,
"classes": [
{
"id": "2915",
"name": "資工四",
"department_id": "59",
"department_name": "資工系",
"college": "電資學院",
"course_count": 53,
"url": "https://aps.ntut.edu.tw/course/tw/Subj.jsp?format=-4&year=115&sem=1&code=2915",
"path": "115-1/classes/2915.json"
}
]
}
明細檔結構與教師檔相同:class_group + course_count + courses。
班級代碼(
2915)和系所代碼(59)是兩組不同的 ID,互相推不出來。
115-1/departments.json{
"schema_version": 3,
"year": 115, "sem": 1,
"departments": [
{
"id": "59",
"name": "資工系",
"college": "電資學院",
"url": "https://aps.ntut.edu.tw/course/tw/Subj.jsp?format=-3&year=115&sem=1&code=59",
"class_groups": [
{ "id": "2915", "name": "資工四", "url": "…format=-4…code=2915" }
],
"course_count": 53,
"path": "115-1/courses/59.json"
}
],
"colleges": [
{ "name": "電資學院", "department_ids": ["31", "36", "59", "65", "82", "99", "AY", "C5"] },
{ "name": null, "department_ids": ["01", "10", "14", "62", "AA"] }
]
}
115-1/courses/59.json{
"schema_version": 3,
"year": 115, "sem": 1,
"department": { "id": "59", "name": "資工系", "college": "電資學院", "url": "…" },
"course_count": 53,
"courses": [
{
"id": "364893",
"name_zh": "數位影像處理",
"name_en": null,
"stage": "1",
"credits": 3.0,
"hours": 3,
"required": false,
"requirement_type": "專業選修",
"teachers": ["白敦文"],
"teacher_codes": ["12095"],
"classes": ["資工四"],
"class_ids": ["2915"],
"department_ids": ["59"],
"time_slots": [{ "day": 5, "day_name": "五", "periods": ["2", "3", "4"] }],
"classrooms": ["六教727(e)"],
"classroom_codes": ["452"],
"enrolled": 22,
"quota": 22,
"withdrawn": 0,
"language": null,
"syllabus_url": "https://aps.ntut.edu.tw/course/tw/ShowSyllabus.jsp?snum=364893&code=12095",
"notes": "資工四和資工所合開",
"audit": null,
"lab": null,
"programs": ["人工智慧科技學程", "光電智慧製造學程"]
}
]
}
115-1/index.json / index.json只放搜尋與篩選需要的欄位,細節到明細檔取。
teacher_codes / class_ids / department_ids 讓你能從搜尋結果直接跳到對應的明細檔。
{
"schema_version": 3,
"covers": ["115-1", "114-2"],
"course_count": 5264,
"courses": [
{
"id": "364893",
"name_zh": "數位影像處理",
"teachers": ["白敦文"],
"teacher_codes": ["12095"],
"time_slots": [{ "day": 5, "day_name": "五", "periods": ["2", "3", "4"] }],
"department_ids": ["59"],
"class_ids": ["2915"],
"credits": 3.0,
"required": false,
"requirement_type": "專業選修",
"language": null,
"enrolled": 22,
"withdrawn": 0,
"year": 115,
"sem": 1
}
]
}
索引裡的 language 讓「只看全英語授課」不必下載 60 個系所明細檔
(115-1 全校 2,717 門裡有 499 門非中文:英語 488、中英雙語 11)。
enrolled / withdrawn 同理,是為了讓算退選率只需要一個檔。
頂層 index.json 只含 covers 列出的那幾個學期(目前是最新兩個)。
更早的學期不在裡面,請改讀 {semester}/index.json —— 那些檔案抓過一次
就不會再變,很適合長期快取。
115-1/programs.json / classrooms.json{
"program_count": 86,
"programs": [
{ "name": "人工智慧科技學程", "course_count": 33, "course_ids": ["364892", "364893"] }
]
}
{
"classroom_count": 234,
"classrooms": [
{
"id": "452",
"name": "六教727(e)",
"capacity": 50,
"course_count": 12,
"course_ids": ["364893"],
"url": "https://aps.ntut.edu.tw/course/tw/Croom.jsp?format=-3&year=115&sem=1&code=452"
}
]
}
學程只有中文名稱、沒有代碼,所以不另外開明細檔。
capacity.json 是教室代碼 → 容量的對照表,各學期的 classrooms.json 每筆也
直接帶 capacity 欄位,不必再查一次。
容量與學期無關。 實測
Croom.jsp的year/sem參數只影響使用率與 週課表,容量欄位不受影響 —— 打一個不存在的學年度(year=199)仍然回傳 正確容量。所以學校不保留歷史容量:教室改建後,所有學期都會顯示新值。因此各學期
classrooms.json的capacity反映的是最近一次觀測值, 不是那個學期當時的容量。抓不到時是null,不是0。每月重抓一輪,
capacity.json的checked_at是該筆最後一次確認的時間。
約半數教室沒有座位數。 全量實測 445 間裡有 216 間查得到(48.5%),
其餘 229 間學校根本沒登記 —— 那格連同旁邊的使用率欄位一起是空白的,
打四個不同學年期(115-1 / 110-1 / 105-1 / 199-1)都一樣。這種 null 是
永久且正確的,不是抓取失敗。
分佈不是隨機的,一般教學大樓幾乎都有、研究與系館幾乎都沒有:
| 館別 | 有容量 | 館別 | 有容量 |
|---|---|---|---|
| 先鋒 | 18/18 | 綜科 | 15/82 |
| 六教 | 17/17 | 土木 | 2/11 |
| 四教 | 10/12 | 五教 | 1/10 |
| 二教 | 16/20 | 紡織 | 0/9 |
| 三教 | 34/44 | 化工 | 2/8 |
已知容量的 216 間範圍是 10~420,中位數 50。
「原本有值、這次讀不到」會保留舊值。 座位數不會無聲消失 —— 改建 會改變數字,不會把數字變成空白。所以那種情況一律保留舊值、更新
checked_at、並在errors.json記一筆;比例超過門檻時整批不發布, 讓 workflow 重試。這是為了擋掉「學校改版讓 216 間同時歸零」這種 靠事後看資料分辨不出來的災難。
115-1/schedule.json{
"periods": [{ "code": "1", "start": "08:10", "end": "09:00" }],
"days": [
{
"day": 3,
"day_name": "三",
"periods": [
{ "code": "2", "course_count": 118, "course_ids": ["361345", "361351"] }
]
}
]
}
periods 已依 1–9 → N → A–D 排好,不是字典序(字典序會把 A 排到 9 前面)。
沒有上課時間的課(體育、班週會)不會出現在這裡。
time_slots 每個元素代表一天。day 是 0=日、1=一 … 6=六,
periods 是節次代碼,對應時間查 meta.json 的 periods。
沒有上課時間的課 time_slots 是空陣列。
required 是布林值,requirement_type 是完整類別:
| 符號 | required | requirement_type |
|---|---|---|
| ○ | true | 部訂共同必修 |
| △ | true | 校訂共同必修 |
| ☆ | false | 共同選修 |
| ● | true | 部訂專業必修 |
| ▲ | true | 校訂專業必修 |
| ★ | false | 專業選修 |
注意 ★ 和 ☆ 都是「選修」,兩者差別在共同 / 專業,不是必 / 選。
原始資料該欄空白時 required 是 null,不是 false。
language 空白(null)代表中文授課,不是「未知」——
學校只在非中文的課填這一欄。115-1 全校的實際值只有 "英語"(488 門)
與 "中英雙語"(11 門)兩種,其餘 2,218 門都是 null。
所以「找全英語授課」的條件是 language === "英語",
「找中文授課」是 language === null。
原始頁面用全形空白 (U+3000)表示「沒有這個欄位」,
輸出時一律正規化成 null(陣列欄位則是 [])。
schema_version 與相容性承諾每個 JSON 檔的頂層都有 "schema_version"。目前是 3。
schema_version 變了就先檢查再上線。只影響教學大綱,把 v2 沒做完的那半件事做完。
| 改動 | 影響 |
|---|---|
{semester}/syllabus/{課號}.json 不再有 fetched_at | 要知道何時抓的,讀 syllabus.json 的 fetched[學期][課號].at |
同一個檔新增 content_hash | 加欄位,不影響既有使用端 |
syllabus.json 的 fetched[學期][課號] 從字串變成物件 | 原本是抓取時間字串,現在是 {"at": ..., "hash": ...} |
syllabus.json 新增 frozen 區塊 | 已補完的歷史學期只留門數,逐課狀態不再保留 |
為什麼要動: 跟 v2 拿掉 generated_at 完全同一個理由,只是漏了大綱這一塊。
fetched_at 每次抓取都變,一天兩班、每班 1,909 份大綱,即使內容一個字都沒改,
git 也會收下 1,909 個新 blob。改成比對內容雜湊之後,沒變就不重寫那個檔,
配合 keep_files: true,遠端那份原封不動留著。
為了容納 90 學年度起的歷史資料而做的兩個改動。兩個都會影響既有使用端:
| 改動 | 影響 |
|---|---|
頂層 index.json 只涵蓋最新兩個學期(原本是全部) | 要更舊的學期改讀 {semester}/index.json;新增 covers 欄位明講涵蓋範圍 |
generated_at 只留在 meta.json 與 errors.json | 其他檔案不再有這個欄位。要知道某學期何時產生,讀 meta.json 的 semesters[].generated_at |
為什麼要動: 50 個學期全塞進一個 index.json 會膨脹到數十 MB;
而每個檔都帶時間戳的話,每次抓取後所有檔案內容都會變,發布時等於整包重推 ——
以歷史資料的量級來說那是每天好幾 GB 的無謂流量。
時間戳集中在 meta.json 粒度也更正確:一個學期的所有檔案本來就是同一次產生的。
| workflow | 頻率 | 台灣時間 |
|---|---|---|
crawl | 每 4 小時 | 00、04、08、12、16、20 時 |
syllabus | 一天兩次 | 09、21 時 |
capacity | 每月一次 | 1 號 10 時(排在同一天的整包重建之前) |
crawl(整包重建) | 每月一次 | 1 號 14 時 |
backfill | 只手動 | 補歷史學期的課表與大綱 |
GitHub Actions 的排程常延遲 2~4 小時,上表只是排程時間。
meta.json 的 generated_at 是該次產生的實際時間,請以它為準。
爬蟲每次啟動會先讀學校首頁,看目前掛了哪幾個「上課時間表」入口, 再決定要抓哪些學期 —— 學年期沒有寫死在任何地方。 115-1 過完換 115-2、再換 116-1,程式與 workflow 都不用改。
所以 meta.json 的 semesters 會越積越多。要最新的那個就讀 latest。
首頁只掛最近兩個學期,但 Subj.jsp?format=-2&year=&sem= 不理會首頁 ——
90 學年度起的資料都還在,而且版面 25 年沒變(一樣是 23 欄,教師與教室
一樣是帶代碼的連結),所以同一套解析器直接吃得下。
這些歷史學期是一次性回補下來的,之後不會再重抓(過去的學期資料不會變)。
每月 1 號會整包重新發布一次。班級或教師從學校系統消失後, 它們的舊明細檔會在那時從線上移除 —— 在那之前可能還查得到。
Subj.jsp?format=-2 那棵樹)。
該系統每個學期只提供這一個上課時間表入口,因此這棵樹爬完即無遺漏。
暑期課程(Summer.jsp)、學程查詢、教室使用情形不在本專案範圍。syllabus.json 的 fetched[學期][課號].at;
老師最後修改的時間是該檔的 updated_at。
越早的學期有大綱的課越少(96 學年度以後約七成,90~92 學年度約 1%)。aps.ntut.edu.tw/course/tw/ 底下。name_en 永遠是 null。 課程列表頁與教學大綱頁都沒有英文課名,
目前無資料來源。欄位保留是為了維持 schema 穩定。classes / class_ids / department_ids 列出全部。
115-1 有 110 門課跨多個系所。
但「合開」不是這種情況 —— 例如「數位影像處理」在資工四是 364893、
在資工所是 364899,notes 都寫「資工四和資工所合開」,
它們是兩筆獨立資料。不要假設「同一門課 = 同一個課號」,
要判斷是否同一門課請一併看 notes。enrolled / withdrawn 是原始頁面的「人」「撤」欄位,會隨選課進度變動,
只反映抓取當下的狀態。歷史學期的值是該學期結束後很久才抓的,
等於是最終定案的數字,不代表當年選課期間的即時人數。
想看變動過程請用 enrollment.json(只有開始記錄之後的學期才有)。
quota 是 enrolled 的舊名,已棄用但保留。meta.json。departments.json 為準,
不要拿 115-1 的代碼表去套 95-1。updated_at 是老師在學校系統上最後
修改的時間(已轉成 UTC)。看不懂的日期格式會原樣保留,不保證是 ISO 8601。
沒有 syllabus_url 的課(跨校選課那類)不會有 {semester}/syllabus/ 檔案。本專案為非官方個人專案,與國立臺北科技大學無隸屬關係。
資料以自動化方式蒐集自北科大課程查詢系統, 可能因學校改版、網路異常或解析錯誤而不完整或過時。 選課、抵免、畢業學分等一切事項請以學校公告與課程系統當下顯示的內容為準, 因使用本資料造成的任何損失,本專案不負責任。
若校方認為本專案造成困擾,請開 issue 告知,我會立即停止。
程式碼採 MIT License。
資料本身著作權屬國立臺北科技大學,本專案僅為格式轉換與再發布。 使用資料時請標註來源為「國立臺北科技大學課程查詢系統」。
python -m venv .venv
.venv/Scripts/activate # Windows
pip install -r requirements-dev.txt
python -m crawler.main --out data/ # 自動偵測學年期
python -m crawler.main --year 115 --sem 1 --out data/ # 指定學年期
python -m crawler.main --out data/ --dept 59 --pretty # 開發用,只抓一個系所
python -m crawler.main --out data/ --no-cache --delay 1.5
# 回補歷史學期(已經抓過的會自動跳過,可以分批續跑)
python -m crawler.main --out data/ --years 90-114 --max-semesters 12
| 參數 | 說明 |
|---|---|
--year / --sem | 指定學年期。要嘛都給,要嘛都不給;都不給就自動偵測 |
--refresh-after HOURS | 非最新學期隔多久才重抓(預設 24 小時) |
--all-semesters | 首頁列出的每個學年期都重抓,忽略 --refresh-after |
--years FROM-TO | 回補模式:抓這個學年度範圍內尚未抓過的學期,例 --years 90-114 |
--max-semesters N | 這次最多抓幾個學期(回補分批用) |
--out DIR | 輸出目錄,預設 data/ |
--dept CODE | 只抓指定系所(可重複)。輸出會是不完整的資料集 |
--delay | 每次請求後的延遲秒數,下限 0.5 |
--no-cache | 略過 .cache/,強制重新抓取 |
--pretty | 輸出縮排過的 JSON(檔案會變大) |
--with-syllabus | 順便抓教學大綱(一門課一頁,很慢,預設關閉) |
--max-syllabus N | 這次最多抓幾頁大綱,預設 0 = 不限(全校一輪約 38 分鐘) |
--syllabus-refresh-after H | 同一門課的大綱隔多久重抓,預設 6 小時(配合一天兩班各全跑一輪)。只對最新學期有效,歷史學期一律只補沒抓過的 |
--with-capacity | 順便抓教室容量(座位數),每月重抓一輪,預設關閉 |
--max-capacity N | 這次最多抓幾間教室,預設 0 = 不限(全部約 445 間、9 分鐘) |
--capacity-refresh-after H | 同一間教室的容量隔多久重抓,預設 480 小時(20 天) |
--run-summary PATH | 把這次跑了什麼寫成側寫檔,給 crawler.runlog 記進 runs.json |
--log-level | 記錄層級,預設 INFO。抓不到東西時開 DEBUG 看實際打了哪些 URL |
pytest -q
測試完全離線,全部對 tests/fixtures/ 裡的真實 HTML 樣本斷言。
tests/conftest.py 有一道安全網,任何測試只要發出真實 HTTP 請求就會直接失敗。
解析器測試刻意寫得很細 —— 學校改版時它們會先紅, CI 就不會把解錯的資料發布出去。
crawler/http.py 硬性限制)concurrency 群組,不會有兩個 job 同時抓課程網站抓太快很容易被封鎖。這些限制不是效能問題,是能不能長久運作的問題。
重試、逾時與容錯分層的設計理由見 reference.md。
crawler/
config.py # 常數(base URL / schema 版本 / 延遲 / User-Agent)
http.py # 全專案唯一對外出口:限速、快取、重試、UTF-8 解碼
models.py # 輸出 schema 的 dataclass 定義
periods.py # 節次代碼對照與解析
parse_util.py # 解析器共用工具
parse_semester.py # 解析 course.jsp — 目前有哪些學年期(純函式)
parse_dept.py # 解析 format=-2 / -3(純函式,不連網)
parse_course.py # 解析 format=-4(純函式,不連網)
parse_syllabus.py # 解析教學大綱頁,並判斷課號還在不在(純函式)
parse_croom.py # 解析教室頁的容量(純函式)
output.py # 把 CrawlResult 寫成各維度的 JSON
main.py # CLI:選學期、抓取流程、去重
runlog.py # CLI:把一次 workflow 執行的結果記進 runs.json
tests/ # 全離線,對 tests/fixtures/ 的真實 HTML 樣本斷言
.github/workflows/ # crawl(每 4 小時)、syllabus(一天兩班)、
# capacity(每月一次,教室容量)、backfill(手動回補)、
# test(每次 push)
web/ # 發布到 gh-pages 根目錄的頁面,整個目錄由 workflow 複製過去
# index.html(說明)、status.html(爬蟲狀態)、
# errors.html(抓取失敗)、changes.html(課程異動)、
# 加上共用的 style.css 與 app.js
解析器一律是純函式:吃 HTML 字串 → 吐 dataclass,不發網路請求。
所有網路存取都集中在 http.py,限速與快取的規則只要在那裡守住就守住了。
要改這個 repo 的話,硬性規定、實測過的站台特性與決策紀錄見 reference.md。