neuron-explorer
Qwen3.5‑4B 전용 조향형 뉴런 탐색기, 모든 MLP 뉴런을 밀어 결과를 보관·탐색
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기臺北科技大學課程資料爬蟲及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。