mcp-server-104 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 a7512cs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,229 @@
1
+ # mcp-server-104
2
+
3
+ 台灣 104 人力銀行的 MCP server。讓 Claude(或任何 MCP client)能直接搜尋 104 的即時職缺。
4
+
5
+ ## 這個工具適合你嗎?
6
+
7
+ | 你的情境 | 最佳工具 |
8
+ |---------|---------|
9
+ | 偶爾自己找工作 | 直接開 104 網站 |
10
+ | 想寫個一次性爬蟲抓資料 | Playwright / cycletls 腳本就好,不用 MCP |
11
+ | 不想自己寫腳本,要讓 Claude 直接幫你分析/比對/彙整/自動化職缺 | **這個 MCP** |
12
+
13
+ ## 安裝
14
+
15
+ 以下三種**擇一**,看你用什麼 client:
16
+
17
+ **A:有快捷指令的 client** —— 一行搞定,設定自動寫好:
18
+
19
+ ```bash
20
+ claude mcp add job104 -- npx -y mcp-server-104 # Claude Code
21
+ ```
22
+
23
+ ```bash
24
+ codex mcp add job104 -- npx -y mcp-server-104 # OpenAI Codex CLI(新版才有;舊版走 B 的 TOML)
25
+ ```
26
+
27
+ **B:手動貼設定的 client** —— 把設定貼進該 client 的 MCP 設定檔:
28
+
29
+ Claude Desktop / Cursor / Windsurf(JSON):
30
+
31
+ ```json
32
+ {
33
+ "mcpServers": {
34
+ "job104": { "command": "npx", "args": ["-y", "mcp-server-104"] }
35
+ }
36
+ }
37
+ ```
38
+
39
+ OpenAI Codex CLI 舊版(`~/.codex/config.toml`):
40
+
41
+ ```toml
42
+ [mcp_servers.job104]
43
+ command = "npx"
44
+ args = ["-y", "mcp-server-104"]
45
+ ```
46
+
47
+ > A 和 B 做的是同一件事:告訴 client「用 npx 啟動這個 server」。核心到哪都是 `npx -y mcp-server-104`,差別只在各家 client 怎麼登記它。
48
+ >
49
+ > ⚠️ ChatGPT 網頁/桌面版**接不上**這種本機型(stdio)server —— 它只支援遠端 URL 型 MCP,它的雲端上沒有你的電腦可以跑 `npx`。
50
+
51
+ **C:開發者,想改 code** —— clone 這個 repo 後:
52
+
53
+ ```bash
54
+ npm install && npm run build
55
+ claude mcp add job104 -- node /你的路徑/104-mcp-server/dist/index.js
56
+ ```
57
+
58
+ 日常指令與測試策略見下方「開發」。
59
+
60
+ ## 用什麼方法取得資料
61
+
62
+ 104 的搜尋 API 藏在 Cloudflare bot 防護後面。用 `curl` 或 Node `fetch`(即使帶 Referer / User-Agent)都會被擋 —— 回 403 或 Cloudflare 的 "Just a moment..." 挑戰頁。
63
+
64
+ **關鍵不是 header,是 TLS 指紋。** Cloudflare 會檢查 TLS 握手的指紋(JA3);一般程式的指紋一看就不是瀏覽器,直接被攔。
65
+
66
+ 本專案用 **[cycletls](https://github.com/Danny-Dasilva/CycleTLS)** 偽裝成 Chrome 的 TLS 指紋,讓 Cloudflare 以為請求來自真瀏覽器 → 放行。這樣**不用開瀏覽器**(比 Playwright / Selenium 輕一個量級、快、好部署),純 HTTP 就能拿到真實 JSON。
67
+
68
+ > cycletls 底層是一個 Go 寫的 TLS client 子程序,server 啟動時開一次、全程共用。
69
+
70
+ ## 現在有什麼
71
+
72
+ | Tool | 狀態 | 說明 |
73
+ |------|------|------|
74
+ | `search_jobs` | ✅ 真實資料 | 依關鍵字 + 多種篩選搜尋職缺,支援分頁 |
75
+ | `get_job_detail` | ✅ 真實資料 | 取得單筆職缺完整詳情:完整 JD、薪資、地點、學經歷要求、技能、語言能力、福利、產業別 |
76
+ | `get_company_jobs` | ✅ 真實資料 | 列出某公司所有在徵職缺(分頁) |
77
+
78
+ ### `search_jobs` 參數
79
+
80
+ | 參數 | 必填 | 說明 |
81
+ |------|:----:|------|
82
+ | `keyword` | ✅ | 職務關鍵字,例如 `Rust 工程師` |
83
+ | `area` | | 工作地區名稱,例如 `台北市`、`新竹`(自動解析成 104 官方地區代碼查詢)。同名多區(如「信義區」有台北/基隆兩個)不會直接搜,改回 `ambiguousArea` 候選清單讓模型跟你確認 |
84
+ | `salaryMin` | | 月薪下限(新台幣),例如 `60000`。薪資明確低於此值的濾掉;面議預設保留 |
85
+ | `excludeNegotiable` | | 設 `true` 排除「面議」職缺。預設 `false` |
86
+ | `excludeFeatured` | | 設 `true` 排除 104 付費廣告職缺(`featured=true` 那些)。預設 `false` |
87
+ | `jobCategory` | | 職務類別名稱,例如 `軟體工程師`(自動解析成 104 官方職類代碼查詢) |
88
+ | `remote` | | 遠端:`full` 完全遠端 / `partial` 部分遠端 / `any` 皆可 |
89
+ | `jobType` | | 工作性質:`fulltime` 全職 / `parttime` 兼職 |
90
+ | `experience` | | 需求年資:`under-1y` / `1-3y` / `3-5y` / `5-10y` / `over-10y` |
91
+ | `sort` | | 排序:`newest` 最新更新在前(掃新缺用,建議配 `excludeFeatured=true`)/ `salary` 待遇高→低。預設相關性 |
92
+ | `page` | | 第幾頁(每頁 20 筆),預設 1。想看更多就往後翻 |
93
+ | `limit` | | 本頁回傳筆數上限,最多 20,預設 5 |
94
+
95
+ > **篩選參數的實作眉角**(都是觀察 104 官網 UI 實際請求 + `metadata.total` 實測得來的):
96
+ > 1. `salaryMin` 要同時送 `scmin` + `sctp=M` + `scstrict=1`,缺了 `scstrict` 薪資篩選會被完全忽略。
97
+ > 2. 「面議」薪資值是 `0`,104 預設保留(面議可能開很高)。`excludeNegotiable` 會排除它們。
98
+ > 3. 薪資上限 `9,999,999` 是 104「不設上限」哨兵值,server 端已正規化成「N 元以上」。薪資前綴依原始 `s10` 類型標示(10=面議、30=時薪、40=日薪、50=月薪、60=年薪)—— 兼職多為時薪,別當月薪讀。
99
+ > 4. `remoteWork`=1完全/2部分、`ro`=1全職/2兼職、`jobexp`=1/3/5/10/99(互斥年資級距)。
100
+ > 5. 地區/職類用**樹狀代碼表 + 剪枝**:命中父節點(如「新竹縣市」)就用父代碼,不展開成一堆子代碼 —— 展開太多會讓 104 回 `400`。地區**同名多處**(如「信義區」)不聯集也不搜尋,回 `ambiguousArea` 請模型跟使用者確認(地理上不相干的地方聯集沒意義);職類多重命中則維持聯集(相關職類一起查通常是想要的)。
101
+ > 6. **廣告偵測**:104 會在結果最前面塞廣告(原始欄位 `jobType=1`),它會**無視關鍵字**(例如護理師搜尋跑出「COACH 精品銷售」)。每筆回傳 `featured` 旗標標記它,`excludeFeatured=true` 可整批濾掉。`jobType=2`(付費優先位)仍符合關鍵字,視為有效結果不標記。搜尋列表刻意不含完整 JD(精簡、避免模型整理清單時把某筆網址對錯到別筆),完整內容用 `get_job_detail`。
102
+ > 7. **掃新缺姿勢**:`sort=newest`(`order=16`,實測)最新更新在前,但廣告位連排序都無視、照樣卡最前面 —— 配 `excludeFeatured=true` 才是乾淨的最新清單。另外 `employeeCount=0` 代表「未公開」(約半數公司不提供),不是 0 人。
103
+
104
+ > **欄位命名跨三個工具一致**(都對照 104 原始欄位語意,避免同名不同物):
105
+ >
106
+ > | 概念 | search_jobs | get_job_detail | get_company_jobs |
107
+ > |------|:-----------:|:--------------:|:----------------:|
108
+ > | 職缺代碼(slug,可餵回 `get_job_detail`)| `jobId` | `jobId` | `jobId` |
109
+ > | 職缺網址 | `url` | `url` | `url` |
110
+ > | 地區(區級)| `area` | `area` | `area` |
111
+ > | 完整地址(區+街道)| — | `location` | — |
112
+ > | 需求年資 | — | `experience` | `experience` |
113
+ > | 擅長工具/語言(C++、Linux)| `skills` | `skills` | — |
114
+ > | 職務技能(職類層級,如「軟體工程系統開發」)| — | `jobSkills` | — |
115
+ > | 公司頁網址(餵給 `get_company_jobs`)| `companyUrl` | `companyUrl` | — |
116
+ > | 是否為廣告位(`jobType=1`)| `featured` | — | — |
117
+ > | 更新日期(頁面的「MM/DD更新」)| `appearDate` | `appearDate` | — |
118
+ > | 員工人數 | `employeeCount`(數字,0=未公開) | `employees`(字串) | — |
119
+ >
120
+ > `jobId` 一律是 **slug**(如 `7uqyj`),不是 104 內部數字 —— slug 才能餵回 `get_job_detail`。`skills` 到哪都是「具體技術」。`appearDate` 統一為 `YYYY/MM/DD`。公司職缺**刻意不回**日期:公司 API 原始只有 `8/20` 這種無年份格式,久未更新的殭屍職缺看起來永遠像最近更新(實測有 2025 年的缺混在裡面),跨年靜默誤導 —— 想要某筆的日期,把它的 `jobId` 餵給 `get_job_detail` 拿完整的。
121
+
122
+ ### `get_job_detail` 參數
123
+
124
+ | 參數 | 必填 | 說明 |
125
+ |------|:----:|------|
126
+ | `jobUrlOrId` | ✅ | 職缺網址或代碼,例如 `https://www.104.com.tw/job/7uqyj` 或 `7uqyj`(用 `search_jobs` 回傳的 `url`) |
127
+
128
+ ### `get_company_jobs` 參數
129
+
130
+ | 參數 | 必填 | 說明 |
131
+ |------|:----:|------|
132
+ | `companyUrlOrId` | ✅ | 公司網址或代碼,例如 `https://www.104.com.tw/company/1a2x6blghh` 或 `1a2x6blghh` |
133
+ | `page` | | 第幾頁(每頁 20 筆),預設 1 |
134
+ | `limit` | | 本頁回傳筆數上限,最多 20,預設 10 |
135
+
136
+ **三個工具怎麼串**:
137
+ - `search_jobs` / `get_job_detail` 每筆都回 `url`(職缺)和 `companyUrl`(公司)兩個網址。
138
+ - 想看某筆職缺完整內容 → 把它的 `url` 餵給 `get_job_detail`。
139
+ - 想看「這家公司還有哪些缺」→ 把 `companyUrl` 餵給 `get_company_jobs`(它是**指定公司**的職缺列表,不是關鍵字搜尋)。
140
+
141
+ ```
142
+ search_jobs ─ url ──────→ get_job_detail
143
+ │ │
144
+ └─ companyUrl ───────────┴──→ get_company_jobs
145
+ ```
146
+
147
+ ## 104 內部 API 參考
148
+
149
+ 主要 endpoint:
150
+
151
+ ```
152
+ GET https://www.104.com.tw/jobs/search/api/jobs
153
+ ```
154
+
155
+ 必要 header:`Referer: https://www.104.com.tw/jobs/search/`、`Accept-Language: zh-TW`
156
+
157
+ 常用查詢參數(本專案目前只用到部分,其餘供未來擴充):
158
+
159
+ | 參數 | 意義 | 範例值 |
160
+ |------|------|--------|
161
+ | `keyword` | 關鍵字 | 自由文字 |
162
+ | `kwop` | 關鍵字運算 | `7`(全符合) |
163
+ | `order` | 排序 | `15` 相關性(預設) · `16` 最新(實測) · `13` 薪資 |
164
+ | `page` / `pagesize` | 分頁 | `pagesize` 建議 20 |
165
+ | `area` | 地區碼(逗號分隔) | 查 `Area.json`(見下) |
166
+ | `jobcat` | 職類碼(逗號分隔) | 查 `JobCat.json` |
167
+ | `scmin` + `scstrict=1` | 最低薪資 | 整數 |
168
+ | `remoteWork` | 遠端 | `1` 完全遠端 · `2` 部分 · `1,2` 皆可(實測) |
169
+ | `ro` | 全/兼職 | `1` 全職 · `2` 兼職(實測;`wt` 部分值會 400,不用) |
170
+ | `jobexp` | 年資 | `1`/`3`/`5`/`10`/`99` = 1年下/1-3/3-5/5-10/10年上(互斥級距,實測) |
171
+ | `edu` | 學歷 | `4,5,6` 大學以上 等 |
172
+
173
+ 地區 / 職類代碼表(放 `static.104.com.tw`,**沒有 Cloudflare 擋**,一般 fetch 就能拿):
174
+
175
+ ```
176
+ https://static.104.com.tw/category-tool/json/Area.json
177
+ https://static.104.com.tw/category-tool/json/JobCat.json
178
+ ```
179
+
180
+ 其他 endpoint:
181
+ - 職缺詳情:`GET https://www.104.com.tw/job/ajax/content/{slug}`(Referer 指向 `/job/{slug}`)
182
+ - 公司職缺:`GET https://www.104.com.tw/api/companies/{code}/jobs?page=1&pageSize=20`(回 `list.topJobs` + `list.normalJobs`)
183
+
184
+ ## 檔案結構
185
+
186
+ ```
187
+ src/
188
+ index.ts 進入點:建 server、掛 tool、接 stdio、處理關閉
189
+ config.ts 所有設定 / 魔術數字(JA3 指紋、endpoint、節流區間…)
190
+ types.ts 乾淨型別 + normalizeJob / JobDetail / CompanyJob(防腐層)
191
+ query.ts 純函式:組查詢網址、client 端過濾、enum 對照
192
+ slug.ts 從 104 網址取出職缺 slug / 公司碼(types/query 共用)
193
+ codes.ts 地區/職類「名稱→官方代碼」解析(樹狀比對+剪枝,快取代碼表)
194
+ api/
195
+ httpClient.ts cycletls 單例(TLS 指紋偽裝)
196
+ throttle.ts 禮貌性隨機節流 1.5~3.5s
197
+ job104.ts 104 抓取層:組 URL → 打 API → 重試 → 正規化
198
+ tools/
199
+ searchJobs.ts search_jobs
200
+ getJobDetail.ts get_job_detail
201
+ getCompanyJobs.ts get_company_jobs
202
+ scripts/
203
+ smoke-test.mjs 手動發 JSON-RPC 驗證,不用開 Claude 也能測
204
+ test/
205
+ types.test.mjs normalize 邏輯(薪資格式、面議、哨兵值…)
206
+ query.test.mjs 組網址 / slug / 公司碼 / 過濾 / enum 對照
207
+ codes.test.mjs 代碼表樹狀比對 + 剪枝
208
+ ```
209
+
210
+ ## 開發
211
+
212
+ ```bash
213
+ npm run build # 編譯 src → dist
214
+ npm test # 跑單元測試(先 build 再 node --test,零額外依賴)
215
+ node scripts/smoke-test.mjs # 煙霧測試(連真實 104)
216
+ npm run inspect # 開 MCP Inspector GUI 除錯
217
+ ```
218
+
219
+ 改完 code 要 `npm run build`,然後重啟 Claude Code(或用 `/mcp` reconnect)才會生效 —— client 只在 session 啟動時抓一次工具清單。
220
+
221
+ **測試策略**:純邏輯(normalize、組網址、過濾)都抽到 `types.ts` / `query.ts`,用 Node 內建 `node --test` 測,快又不用連網 —— 改壞馬上知道。碰網路的部分(`job104.ts` / `httpClient.ts`)用 smoke-test 對真實 104 驗證。
222
+
223
+ ## ⚠️ 免責聲明
224
+
225
+ - 104 **沒有公開官方 API**。本專案使用的是網頁前端的**非官方內部 endpoint**,隨時可能因 104 改版而失效。
226
+ - 自動化存取**可能違反 104 的服務條款**。本專案僅供**個人、低頻、學習用途**。
227
+ - **請勿**拿去做高頻抓取、大量爬取,或架成公開服務 —— 容易被封鎖,也有法律風險。
228
+ - 本專案已內建禮貌性節流(每次請求間隔隨機 1.5~3.5 秒),請勿移除或調低。
229
+ - 使用本專案造成的任何後果,使用者自負。
@@ -0,0 +1,32 @@
1
+ /**
2
+ * HTTP client:懶啟動 + 全程共用一個 cycletls 實例。
3
+ *
4
+ * 為什麼共用:initCycleTLS 會啟一個 Go 子程序(偽裝 TLS 指紋用),
5
+ * 啟動很貴,所以整個 server 生命週期只開一次。
6
+ *
7
+ * 註:cycletls 是 CommonJS 套件,型別匯出在 TS7 + NodeNext 下有相容問題,
8
+ * 因此用 createRequire 載入 runtime、用 import type 拿型別,兩者分開最穩。
9
+ */
10
+ import { createRequire } from "node:module";
11
+ const require = createRequire(import.meta.url);
12
+ const initCycleTLS = require("cycletls");
13
+ const log = (...args) => console.error("[104-mcp:http]", ...args);
14
+ let clientPromise = null;
15
+ /** 取得共用的 cycletls client(第一次呼叫才啟動) */
16
+ export async function getClient() {
17
+ if (!clientPromise) {
18
+ log("starting cycletls...");
19
+ clientPromise = initCycleTLS();
20
+ }
21
+ return clientPromise;
22
+ }
23
+ /** 關掉 client 子程序(程式結束時呼叫,避免留殭屍程序) */
24
+ export async function closeClient() {
25
+ if (clientPromise) {
26
+ const client = await clientPromise;
27
+ await client.exit();
28
+ clientPromise = null;
29
+ log("cycletls closed");
30
+ }
31
+ }
32
+ //# sourceMappingURL=httpClient.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"httpClient.js","sourceRoot":"","sources":["../../src/api/httpClient.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAG5C,MAAM,OAAO,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAC/C,MAAM,YAAY,GAAG,OAAO,CAAC,UAAU,CAEX,CAAC;AAE7B,MAAM,GAAG,GAAG,CAAC,GAAG,IAAe,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,gBAAgB,EAAE,GAAG,IAAI,CAAC,CAAC;AAE7E,IAAI,aAAa,GAAmC,IAAI,CAAC;AAEzD,sCAAsC;AACtC,MAAM,CAAC,KAAK,UAAU,SAAS;IAC7B,IAAI,CAAC,aAAa,EAAE,CAAC;QACnB,GAAG,CAAC,sBAAsB,CAAC,CAAC;QAC5B,aAAa,GAAG,YAAY,EAAE,CAAC;IACjC,CAAC;IACD,OAAO,aAAa,CAAC;AACvB,CAAC;AAED,qCAAqC;AACrC,MAAM,CAAC,KAAK,UAAU,WAAW;IAC/B,IAAI,aAAa,EAAE,CAAC;QAClB,MAAM,MAAM,GAAG,MAAM,aAAa,CAAC;QACnC,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QACpB,aAAa,GAAG,IAAI,CAAC;QACrB,GAAG,CAAC,iBAAiB,CAAC,CAAC;IACzB,CAAC;AACH,CAAC"}
@@ -0,0 +1,135 @@
1
+ /**
2
+ * 104 抓取層 —— 唯一直接碰 104 的檔案。
3
+ *
4
+ * 手法:用 cycletls 偽裝 Chrome 的 TLS 指紋,直接打 104 內部 API。
5
+ * Cloudflare 看 TLS 握手指紋像真 Chrome 就放行 —— 不用開瀏覽器。
6
+ * 104 改版時,只改這個檔(+ types.ts 的 normalize)。
7
+ */
8
+ import { CONFIG } from "../config.js";
9
+ import { getClient } from "./httpClient.js";
10
+ import { waitForTurn } from "./throttle.js";
11
+ import { normalizeJob, normalizeJobDetail, normalizeCompanyJob, } from "../types.js";
12
+ import { buildSearchUrl, extractSlug, extractCompanyCode, filterJobs, REMOTE_CODES, JOB_TYPE_CODES, EXPERIENCE_CODES, SORT_CODES, } from "../query.js";
13
+ import { resolveAreaMatches, resolveJobCatCodes, buildAreaAmbiguity, } from "../codes.js";
14
+ const log = (...args) => console.error("[104-mcp:api]", ...args);
15
+ // ── 共用的底層抓取(搜尋、詳情、公司職缺都用這個)──────────────
16
+ /** 打一次 API,回傳解析後的物件;非 200 或非 JSON(被擋)時丟錯 */
17
+ async function fetchOnce(url, referer) {
18
+ await waitForTurn(); // 禮貌性節流:距上次請求隨機間隔 1.5~3.5s
19
+ const client = await getClient();
20
+ const res = await client(url, {
21
+ ja3: CONFIG.ja3,
22
+ userAgent: CONFIG.userAgent,
23
+ headers: {
24
+ Accept: "application/json, text/plain, */*",
25
+ "Accept-Language": "zh-TW,zh;q=0.9",
26
+ Referer: referer,
27
+ },
28
+ timeout: Math.floor(CONFIG.requestTimeoutMs / 1000), // cycletls 以秒為單位
29
+ }, "get");
30
+ if (res.status !== 200) {
31
+ throw new Error(`104 回應 status=${res.status}(可能被 Cloudflare 阻擋)`);
32
+ }
33
+ const body = res.data;
34
+ if (typeof body !== "object" || body === null) {
35
+ throw new Error("104 回應不是預期的 JSON(可能是 Cloudflare 挑戰頁)");
36
+ }
37
+ return body;
38
+ }
39
+ /** 帶重試的抓取 —— Cloudflare 偶發失敗,多試幾次通常就過(節流自然拉開間隔) */
40
+ async function fetchWithRetry(url, referer) {
41
+ let lastError;
42
+ for (let attempt = 0; attempt <= CONFIG.maxRetries; attempt++) {
43
+ if (attempt > 0)
44
+ log(`retry ${attempt}/${CONFIG.maxRetries}`);
45
+ try {
46
+ return await fetchOnce(url, referer);
47
+ }
48
+ catch (err) {
49
+ lastError = err;
50
+ log(`attempt ${attempt} failed:`, err instanceof Error ? err.message : err);
51
+ }
52
+ }
53
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
54
+ }
55
+ /** 依關鍵字 + 篩選條件搜尋職缺。地區同名多處時回 AreaAmbiguityResult(不搜尋,讓模型跟使用者確認) */
56
+ export async function searchJobs(params) {
57
+ const { keyword, area, salaryMin, excludeNegotiable, excludeFeatured, jobCategory, remote, jobType, experience, sort, page, limit, } = params;
58
+ // 地區、職類:名稱 → 官方代碼(抓不到就回空陣列,退回 client 端子字串過濾)
59
+ const [areaMatches, jobCatCodes] = await Promise.all([
60
+ area ? resolveAreaMatches(area) : Promise.resolve([]),
61
+ jobCategory ? resolveJobCatCodes(jobCategory) : Promise.resolve([]),
62
+ ]);
63
+ // 同名多區(如「信義區」= 台北+基隆):地理上不相干,聯集沒意義 → 回去確認
64
+ if (areaMatches.length > 1) {
65
+ log(`area "${area}" ambiguous: ${areaMatches.map((m) => m.name).join(", ")}`);
66
+ return buildAreaAmbiguity(area, areaMatches);
67
+ }
68
+ const areaCodes = areaMatches.map((m) => m.code);
69
+ const url = buildSearchUrl({
70
+ keyword,
71
+ salaryMin,
72
+ excludeNegotiable,
73
+ areaCodes,
74
+ jobCatCodes,
75
+ remoteWork: remote ? REMOTE_CODES[remote] : undefined,
76
+ jobType: jobType ? JOB_TYPE_CODES[jobType] : undefined,
77
+ experience: experience ? EXPERIENCE_CODES[experience] : undefined,
78
+ order: sort ? SORT_CODES[sort] : undefined,
79
+ page,
80
+ });
81
+ log(`search: ${url}`);
82
+ const body = await fetchWithRetry(url, CONFIG.referer);
83
+ const rawJobs = Array.isArray(body.data) ? body.data : [];
84
+ const pagination = body.metadata?.pagination;
85
+ log(`captured ${rawJobs.length} raw jobs (total=${pagination?.total ?? "?"})`);
86
+ // 正規化 → client 端過濾(地區 + 排除面議)
87
+ const normalized = rawJobs.map((raw) => normalizeJob(raw));
88
+ const filtered = filterJobs(normalized, { area, excludeNegotiable, excludeFeatured });
89
+ return {
90
+ total: pagination?.total ?? filtered.length,
91
+ page: page ?? 1,
92
+ jobs: filtered.slice(0, limit),
93
+ };
94
+ }
95
+ // ── 職缺詳情 ──────────────────────────────────────────────────
96
+ /** 取得單筆職缺的完整詳情 */
97
+ export async function getJobDetail(jobUrlOrSlug) {
98
+ const slug = extractSlug(jobUrlOrSlug);
99
+ if (!slug)
100
+ throw new Error("無法從輸入取得職缺代碼");
101
+ const url = `${CONFIG.jobDetailApiBase}${slug}`;
102
+ const referer = `${CONFIG.jobPageBase}${slug}`;
103
+ log(`detail: ${url}`);
104
+ const body = await fetchWithRetry(url, referer);
105
+ const data = body.data;
106
+ if (typeof data !== "object" || data === null) {
107
+ throw new Error(`找不到職缺 ${slug} 的詳情`);
108
+ }
109
+ // 補上 jobId(slug) 與 url,讓詳情跟搜尋/公司職缺的欄位一致
110
+ return normalizeJobDetail(data, { jobId: slug, url: referer });
111
+ }
112
+ /** 取得某公司的所有職缺(分頁) */
113
+ export async function getCompanyJobs(params) {
114
+ const { companyUrlOrCode, page, limit } = params;
115
+ const code = extractCompanyCode(companyUrlOrCode);
116
+ if (!code)
117
+ throw new Error("無法從輸入取得公司代碼");
118
+ const query = new URLSearchParams({ page: String(page ?? 1), pageSize: String(CONFIG.pageSize) });
119
+ const url = `${CONFIG.companyApiBase}${code}/jobs?${query.toString()}`;
120
+ const referer = `${CONFIG.companyPageBase}${code}`;
121
+ log(`company jobs: ${url}`);
122
+ const body = await fetchWithRetry(url, referer);
123
+ const data = body.data;
124
+ if (!data)
125
+ throw new Error(`找不到公司 ${code} 的職缺`);
126
+ // 合併「置頂職缺」+「一般職缺」
127
+ const raw = [...(data.list?.topJobs ?? []), ...(data.list?.normalJobs ?? [])];
128
+ const jobs = raw.map((r) => normalizeCompanyJob(r));
129
+ return {
130
+ total: data.totalCount ?? jobs.length,
131
+ page: page ?? 1,
132
+ jobs: jobs.slice(0, limit),
133
+ };
134
+ }
135
+ //# sourceMappingURL=job104.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"job104.js","sourceRoot":"","sources":["../../src/api/job104.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * 禮貌性節流:確保兩次對 104 的請求之間,至少間隔一段隨機時間。
3
+ *
4
+ * 為什麼隨機:固定間隔(例如每 2 秒一次)本身就是一種機器人特徵,
5
+ * 在 min~max 間隨機更像真人操作,降低被偵測/封鎖的機率。
6
+ */
7
+ import { CONFIG } from "../config.js";
8
+ let lastRequestAt = 0;
9
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
10
+ /** 呼叫後保證距離上一次請求已間隔隨機 min~max 毫秒 */
11
+ export async function waitForTurn() {
12
+ const span = CONFIG.throttleMaxMs - CONFIG.throttleMinMs;
13
+ const gap = CONFIG.throttleMinMs + Math.random() * span;
14
+ const elapsed = Date.now() - lastRequestAt;
15
+ if (lastRequestAt > 0 && elapsed < gap) {
16
+ await sleep(gap - elapsed);
17
+ }
18
+ lastRequestAt = Date.now();
19
+ }
20
+ //# sourceMappingURL=throttle.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"throttle.js","sourceRoot":"","sources":["../../src/api/throttle.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAEtC,IAAI,aAAa,GAAG,CAAC,CAAC;AAEtB,MAAM,KAAK,GAAG,CAAC,EAAU,EAAE,EAAE,CAAC,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC;AAEpE,mCAAmC;AACnC,MAAM,CAAC,KAAK,UAAU,WAAW;IAC/B,MAAM,IAAI,GAAG,MAAM,CAAC,aAAa,GAAG,MAAM,CAAC,aAAa,CAAC;IACzD,MAAM,GAAG,GAAG,MAAM,CAAC,aAAa,GAAG,IAAI,CAAC,MAAM,EAAE,GAAG,IAAI,CAAC;IACxD,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,aAAa,CAAC;IAC3C,IAAI,aAAa,GAAG,CAAC,IAAI,OAAO,GAAG,GAAG,EAAE,CAAC;QACvC,MAAM,KAAK,CAAC,GAAG,GAAG,OAAO,CAAC,CAAC;IAC7B,CAAC;IACD,aAAa,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;AAC7B,CAAC"}
package/dist/codes.js ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * 地區 / 職類「名稱 → 官方代碼」解析。
3
+ *
4
+ * 104 的搜尋 API 篩地區、職類要用官方代碼(例如台北市=6001001000)。
5
+ * 代碼表是樹狀 JSON,放在 static.104(沒有 Cloudflare),一般 fetch 就能拿。
6
+ * 抓一次快取在記憶體,之後查詢直接用。
7
+ */
8
+ import { CONFIG } from "./config.js";
9
+ const log = (...args) => console.error("[104-mcp:codes]", ...args);
10
+ /** 把樹狀代碼表攤平成一維陣列(含所有層級) */
11
+ export function flattenTree(nodes) {
12
+ const out = [];
13
+ const walk = (list) => {
14
+ for (const node of list) {
15
+ if (node.no && node.des)
16
+ out.push({ code: node.no, name: node.des });
17
+ if (node.n?.length)
18
+ walk(node.n);
19
+ }
20
+ };
21
+ walk(nodes);
22
+ return out;
23
+ }
24
+ /** 太多筆表示查詢字太模糊(例如「市」),回空陣列讓上層改用其他方式 */
25
+ const MAX_MATCHES = 30;
26
+ /**
27
+ * 用名稱在樹上找代碼,關鍵是「剪枝」:
28
+ * 一旦某節點名稱命中,就用它的代碼、且不再往下展開子節點
29
+ * —— 因為父節點(例如「新竹縣市」)本身就涵蓋所有子區,
30
+ * 展開成一堆子代碼反而會讓 104 回 400(代碼數量有上限)。
31
+ * 太模糊(>30 筆)就放棄,回空陣列讓上層改用 client 端子字串過濾。
32
+ * 回傳 code+name(名稱給同名多區的確認訊息用)。
33
+ */
34
+ export function matchCodes(nodes, query) {
35
+ const q = query.trim();
36
+ if (!q)
37
+ return [];
38
+ const out = [];
39
+ const walk = (list) => {
40
+ for (const node of list) {
41
+ if (node.des?.includes(q)) {
42
+ if (node.no)
43
+ out.push({ code: node.no, name: node.des }); // 命中 → 收代碼、剪枝(不進子節點)
44
+ }
45
+ else if (node.n?.length) {
46
+ walk(node.n); // 沒中才往下找更細的
47
+ }
48
+ }
49
+ };
50
+ walk(nodes);
51
+ if (out.length === 0 || out.length > MAX_MATCHES)
52
+ return [];
53
+ return out;
54
+ }
55
+ /** 組出同名多區的確認訊息(例如「信義區」→ 台北市信義區、基隆市信義區) */
56
+ export function buildAreaAmbiguity(query, matches) {
57
+ const names = matches.map((m) => m.name).join("、");
58
+ return {
59
+ ambiguousArea: {
60
+ query,
61
+ matches,
62
+ hint: `地區「${query}」同名多處:${names}。` +
63
+ `請向使用者確認要哪一個,再用完整名稱(如「${matches[0]?.name ?? query}」)重新搜尋;` +
64
+ `若使用者全部都要,就分別搜尋再彙整。`,
65
+ },
66
+ };
67
+ }
68
+ // ── 快取 + 抓取 ──────────────────────────────────────────────
69
+ let areaTree = null;
70
+ let jobCatTree = null;
71
+ async function fetchTree(url) {
72
+ const res = await fetch(url, {
73
+ headers: { "User-Agent": CONFIG.userAgent, Accept: "application/json" },
74
+ });
75
+ if (!res.ok)
76
+ throw new Error(`抓取代碼表失敗 ${url}: ${res.status}`);
77
+ return (await res.json());
78
+ }
79
+ /** 解析地區名稱 → 命中的節點清單(code+name)。抓不到表時回空陣列,不讓整個搜尋失敗 */
80
+ export async function resolveAreaMatches(query) {
81
+ if (!areaTree) {
82
+ log("fetching Area.json...");
83
+ areaTree = fetchTree(CONFIG.areaJsonUrl);
84
+ }
85
+ try {
86
+ return matchCodes(await areaTree, query);
87
+ }
88
+ catch (err) {
89
+ areaTree = null; // 失敗就清掉快取,下次重試
90
+ log("resolveAreaMatches failed:", err instanceof Error ? err.message : err);
91
+ return [];
92
+ }
93
+ }
94
+ /** 解析職類名稱 → 官方代碼陣列。多重命中維持聯集(相關職類一起查通常是想要的) */
95
+ export async function resolveJobCatCodes(query) {
96
+ if (!jobCatTree) {
97
+ log("fetching JobCat.json...");
98
+ jobCatTree = fetchTree(CONFIG.jobCatJsonUrl);
99
+ }
100
+ try {
101
+ return matchCodes(await jobCatTree, query).map((m) => m.code);
102
+ }
103
+ catch (err) {
104
+ jobCatTree = null;
105
+ log("resolveJobCatCodes failed:", err instanceof Error ? err.message : err);
106
+ return [];
107
+ }
108
+ }
109
+ //# sourceMappingURL=codes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"codes.js","sourceRoot":"","sources":["../src/codes.ts"],"names":[],"mappings":"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"}
package/dist/config.js ADDED
@@ -0,0 +1,38 @@
1
+ /** 全域設定:所有魔術數字集中在這裡,方便調整。 */
2
+ export const CONFIG = {
3
+ /** 104 內部搜尋 API(直接打,用 TLS 指紋偽裝過 Cloudflare) */
4
+ searchApiUrl: "https://www.104.com.tw/jobs/search/api/jobs",
5
+ /** 搜尋 API 的 Referer(104 會檢查) */
6
+ referer: "https://www.104.com.tw/jobs/search/",
7
+ /** 職缺詳情 API(slug 接在後面) */
8
+ jobDetailApiBase: "https://www.104.com.tw/job/ajax/content/",
9
+ /** 職缺頁網址(拿來當詳情請求的 Referer,slug 接在後面) */
10
+ jobPageBase: "https://www.104.com.tw/job/",
11
+ /** 公司職缺 API(公司代碼接在中間:/api/companies/{code}/jobs) */
12
+ companyApiBase: "https://www.104.com.tw/api/companies/",
13
+ /** 公司頁網址(當公司請求的 Referer,代碼接在後面) */
14
+ companyPageBase: "https://www.104.com.tw/company/",
15
+ /** 地區代碼表(樹狀 JSON,放 static.104,沒有 Cloudflare) */
16
+ areaJsonUrl: "https://static.104.com.tw/category-tool/json/Area.json",
17
+ /** 職類代碼表(樹狀 JSON,放 static.104,沒有 Cloudflare) */
18
+ jobCatJsonUrl: "https://static.104.com.tw/category-tool/json/JobCat.json",
19
+ /**
20
+ * Chrome 的 JA3 TLS 指紋。cycletls 用這個讓 TLS 握手長得像真 Chrome,
21
+ * 騙過 Cloudflare 的 bot 偵測。Chrome 大改版時可能要更新這串。
22
+ */
23
+ ja3: "771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513,29-23-24,0",
24
+ /** 配合指紋的 User-Agent(要跟 ja3 的 Chrome 版本大致一致) */
25
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
26
+ /** 單次請求逾時(毫秒) */
27
+ requestTimeoutMs: 15_000,
28
+ /** 回傳非 200 時的重試次數(Cloudflare 偶發 403 用) */
29
+ maxRetries: 2,
30
+ /** 兩次請求間的最小間隔(毫秒),實際會在 min~max 間隨機,降低被 ban 風險 */
31
+ throttleMinMs: 1_500,
32
+ throttleMaxMs: 3_500,
33
+ /** 每頁抓幾筆(我們再依 limit 切) */
34
+ pageSize: 20,
35
+ /** 回傳筆數硬上限,避免一次抓太多 */
36
+ maxLimit: 20,
37
+ };
38
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,6BAA6B;AAC7B,MAAM,CAAC,MAAM,MAAM,GAAG;IACpB,+CAA+C;IAC/C,YAAY,EAAE,6CAA6C;IAC3D,gCAAgC;IAChC,OAAO,EAAE,qCAAqC;IAC9C,0BAA0B;IAC1B,gBAAgB,EAAE,0CAA0C;IAC5D,wCAAwC;IACxC,WAAW,EAAE,6BAA6B;IAC1C,oDAAoD;IACpD,cAAc,EAAE,uCAAuC;IACvD,mCAAmC;IACnC,eAAe,EAAE,iCAAiC;IAClD,gDAAgD;IAChD,WAAW,EAAE,wDAAwD;IACrE,gDAAgD;IAChD,aAAa,EAAE,0DAA0D;IACzE;;;OAGG;IACH,GAAG,EAAE,8IAA8I;IACnJ,+CAA+C;IAC/C,SAAS,EACP,uHAAuH;IACzH,iBAAiB;IACjB,gBAAgB,EAAE,MAAM;IACxB,0CAA0C;IAC1C,UAAU,EAAE,CAAC;IACb,iDAAiD;IACjD,aAAa,EAAE,KAAK;IACpB,aAAa,EAAE,KAAK;IACpB,0BAA0B;IAC1B,QAAQ,EAAE,EAAE;IACZ,sBAAsB;IACtB,QAAQ,EAAE,EAAE;CACJ,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 104 MCP Server —— 進入點。
4
+ * 只做組裝:建 server、掛 tool、接上 stdio、處理關閉。
5
+ * 實際邏輯都在 tools/ 和 api/。
6
+ */
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import { registerSearchJobs } from "./tools/searchJobs.js";
10
+ import { registerGetJobDetail } from "./tools/getJobDetail.js";
11
+ import { registerGetCompanyJobs } from "./tools/getCompanyJobs.js";
12
+ import { closeClient } from "./api/httpClient.js";
13
+ const log = (...args) => console.error("[104-mcp]", ...args);
14
+ const server = new McpServer({ name: "mcp-server-104", version: "0.1.0" });
15
+ registerSearchJobs(server);
16
+ registerGetJobDetail(server);
17
+ registerGetCompanyJobs(server);
18
+ /** 收到結束訊號時先關 client 子程序再退出,避免留下殭屍程序 */
19
+ function installShutdown() {
20
+ const shutdown = async (signal) => {
21
+ log(`received ${signal}, closing http client...`);
22
+ await closeClient().catch(() => { });
23
+ process.exit(0);
24
+ };
25
+ process.on("SIGINT", () => void shutdown("SIGINT"));
26
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
27
+ }
28
+ async function main() {
29
+ installShutdown();
30
+ const transport = new StdioServerTransport();
31
+ await server.connect(transport);
32
+ log("server ready on stdio");
33
+ }
34
+ main().catch((err) => {
35
+ log("fatal:", err);
36
+ process.exit(1);
37
+ });
38
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA;;;;GAIG;AACH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACpE,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AACjF,OAAO,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,EAAE,oBAAoB,EAAE,MAAM,yBAAyB,CAAC;AAC/D,OAAO,EAAE,sBAAsB,EAAE,MAAM,2BAA2B,CAAC;AACnE,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAElD,MAAM,GAAG,GAAG,CAAC,GAAG,IAAe,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,WAAW,EAAE,GAAG,IAAI,CAAC,CAAC;AAExE,MAAM,MAAM,GAAG,IAAI,SAAS,CAAC,EAAE,IAAI,EAAE,gBAAgB,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,CAAC;AAE3E,kBAAkB,CAAC,MAAM,CAAC,CAAC;AAC3B,oBAAoB,CAAC,MAAM,CAAC,CAAC;AAC7B,sBAAsB,CAAC,MAAM,CAAC,CAAC;AAE/B,uCAAuC;AACvC,SAAS,eAAe;IACtB,MAAM,QAAQ,GAAG,KAAK,EAAE,MAAc,EAAE,EAAE;QACxC,GAAG,CAAC,YAAY,MAAM,0BAA0B,CAAC,CAAC;QAClD,MAAM,WAAW,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;QACpC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC,CAAC;IACF,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,GAAG,EAAE,CAAC,KAAK,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC;IACpD,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE,CAAC,KAAK,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC;AACxD,CAAC;AAED,KAAK,UAAU,IAAI;IACjB,eAAe,EAAE,CAAC;IAClB,MAAM,SAAS,GAAG,IAAI,oBAAoB,EAAE,CAAC;IAC7C,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;IAChC,GAAG,CAAC,uBAAuB,CAAC,CAAC;AAC/B,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;IACnB,GAAG,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC;IACnB,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
package/dist/query.js ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * 純函式:組查詢網址、解析 slug、套用 client 端過濾。
3
+ *
4
+ * 刻意跟 job104.ts(網路 I/O)分開 —— 這些沒有副作用、不碰網路,
5
+ * 好測、好懂。所有「眉角知識」(篩選參數對照)都集中在這。
6
+ */
7
+ import { CONFIG } from "./config.js";
8
+ import { NEGOTIABLE } from "./types.js";
9
+ // slug/公司碼解析移到 slug.ts(types.ts 也要用,抽出來避免重複)。這裡再匯出保持相容。
10
+ export { extractSlug, extractCompanyCode } from "./slug.js";
11
+ // ── 友善 enum → 104 代碼對照(都用 metadata.total 實測驗證過)───────
12
+ /** 遠端工作:完全遠端 / 部分遠端 / 兩者皆可 */
13
+ export const REMOTE_CODES = { full: "1", partial: "2", any: "1,2" };
14
+ /** 工作性質(ro):全職 / 兼職 */
15
+ export const JOB_TYPE_CODES = { fulltime: "1", parttime: "2" };
16
+ /** 年資(jobexp):104 的互斥級距 */
17
+ export const EXPERIENCE_CODES = {
18
+ "under-1y": "1",
19
+ "1-3y": "3",
20
+ "3-5y": "5",
21
+ "5-10y": "10",
22
+ "over-10y": "99",
23
+ };
24
+ /** 排序(order):newest=16 最新更新在前(掃新缺/擴編偵測用)、salary=13 待遇高→低。預設 15 相關性 */
25
+ export const SORT_CODES = { newest: "16", salary: "13" };
26
+ /** 組出搜尋 API 網址 */
27
+ export function buildSearchUrl(q) {
28
+ const params = new URLSearchParams({
29
+ keyword: q.keyword,
30
+ order: q.order ?? "15", // 預設相關性排序
31
+ pagesize: String(CONFIG.pageSize),
32
+ });
33
+ if (q.page && q.page > 1)
34
+ params.set("page", String(q.page));
35
+ if (q.salaryMin && q.salaryMin > 0) {
36
+ // 這幾個參數是「觀察 104 官網篩選 UI 實際送出的請求」得來的,缺一不可:
37
+ params.set("scmin", String(q.salaryMin)); // 薪資下限
38
+ params.set("sctp", "M"); // 薪資類型:月薪(104 會自動換算年薪職缺)
39
+ params.set("scstrict", "1"); // 嚴格比對 —— 少了這個 scmin 會被完全忽略!
40
+ // scneg 只在有薪資篩選時生效:1=保留面議、0=排除面議(實測單獨用無效)
41
+ params.set("scneg", q.excludeNegotiable ? "0" : "1");
42
+ }
43
+ if (q.areaCodes?.length)
44
+ params.set("area", q.areaCodes.join(","));
45
+ if (q.jobCatCodes?.length)
46
+ params.set("jobcat", q.jobCatCodes.join(","));
47
+ if (q.remoteWork)
48
+ params.set("remoteWork", q.remoteWork);
49
+ if (q.jobType)
50
+ params.set("ro", q.jobType); // 全職/兼職
51
+ if (q.experience)
52
+ params.set("jobexp", q.experience); // 年資級距
53
+ return `${CONFIG.searchApiUrl}?${params.toString()}`;
54
+ }
55
+ /** 套用 client 端過濾:地區子字串 + 排除面議 + 排除廣告 */
56
+ export function filterJobs(jobs, opts) {
57
+ let result = [...jobs];
58
+ if (opts.area) {
59
+ result = result.filter((job) => job.area.includes(opts.area));
60
+ }
61
+ // 排除面議:因為 104 的 scneg 只在有薪資篩選時生效,
62
+ // 且最上方常有不受篩選的推薦職缺漏進來,這裡再保險一次。
63
+ if (opts.excludeNegotiable) {
64
+ result = result.filter((job) => job.salary !== NEGOTIABLE);
65
+ }
66
+ // 排除 104 付費推廣位(jobType≠0)—— 這些會無視關鍵字硬塞在最前面
67
+ if (opts.excludeFeatured) {
68
+ result = result.filter((job) => !job.featured);
69
+ }
70
+ return result;
71
+ }
72
+ //# sourceMappingURL=query.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"query.js","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"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"}
package/dist/slug.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * 從 104 網址取出代碼的純函式(無相依,types/query 共用,避免重複 regex)。
3
+ */
4
+ /**
5
+ * 職缺 slug:吃得下完整網址或裸 slug。
6
+ * 例:https://www.104.com.tw/job/7uqyj?foo=bar → 7uqyj;7uqyj → 7uqyj
7
+ */
8
+ export function extractSlug(input) {
9
+ const match = input.match(/\/job\/([^/?#]+)/);
10
+ return (match ? match[1] : input).trim();
11
+ }
12
+ /**
13
+ * 公司代碼:吃得下完整公司網址或裸代碼。
14
+ * 例:https://www.104.com.tw/company/1a2x6blghh → 1a2x6blghh
15
+ */
16
+ export function extractCompanyCode(input) {
17
+ const match = input.match(/\/company\/([^/?#]+)/);
18
+ return (match ? match[1] : input).trim();
19
+ }
20
+ //# sourceMappingURL=slug.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"slug.js","sourceRoot":"","sources":["../src/slug.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH;;;GAGG;AACH,MAAM,UAAU,WAAW,CAAC,KAAa;IACvC,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,kBAAkB,CAAC,CAAC;IAC9C,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,CAAC;AAC3C,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,kBAAkB,CAAC,KAAa;IAC9C,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,sBAAsB,CAAC,CAAC;IAClD,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,CAAC;AAC3C,CAAC"}
@@ -0,0 +1,52 @@
1
+ /**
2
+ * get_company_jobs tool 的定義與註冊。
3
+ * 拿公司網址(或代碼),列出該公司所有在徵職缺(分頁)。
4
+ */
5
+ import { z } from "zod";
6
+ import { getCompanyJobs } from "../api/job104.js";
7
+ import { CONFIG } from "../config.js";
8
+ export function registerGetCompanyJobs(server) {
9
+ server.registerTool("get_company_jobs", {
10
+ title: "列出某公司所有職缺",
11
+ description: "列出「某一家指定公司」在 104 上所有在徵的職缺(職稱、地區、薪資、學經歷要求、網址)。這不是關鍵字搜尋 —— 是拿一家已知公司去看它全部的缺。公司網址請用 search_jobs 或 get_job_detail 回傳的 companyUrl 欄位。可分頁。",
12
+ inputSchema: {
13
+ companyUrlOrId: z
14
+ .string()
15
+ .min(1)
16
+ .describe("公司網址或代碼(用 search_jobs / get_job_detail 回傳的 companyUrl),例如 'https://www.104.com.tw/company/1a2x6blghh' 或 '1a2x6blghh'"),
17
+ page: z
18
+ .number()
19
+ .int()
20
+ .min(1)
21
+ .optional()
22
+ .describe("第幾頁(每頁 20 筆),預設 1"),
23
+ limit: z
24
+ .number()
25
+ .int()
26
+ .min(1)
27
+ .max(CONFIG.maxLimit)
28
+ .default(10)
29
+ .describe(`本頁回傳筆數上限,最多 ${CONFIG.maxLimit},預設 10`),
30
+ },
31
+ }, async ({ companyUrlOrId, page, limit }) => {
32
+ try {
33
+ const result = await getCompanyJobs({ companyUrlOrCode: companyUrlOrId, page, limit });
34
+ return {
35
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
36
+ };
37
+ }
38
+ catch (err) {
39
+ const message = err instanceof Error ? err.message : String(err);
40
+ return {
41
+ isError: true,
42
+ content: [
43
+ {
44
+ type: "text",
45
+ text: `取得公司職缺失敗:${message}。104 可能改版或被 Cloudflare 阻擋,稍後再試。`,
46
+ },
47
+ ],
48
+ };
49
+ }
50
+ });
51
+ }
52
+ //# sourceMappingURL=getCompanyJobs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"getCompanyJobs.js","sourceRoot":"","sources":["../../src/tools/getCompanyJobs.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAClD,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAEtC,MAAM,UAAU,sBAAsB,CAAC,MAAiB;IACtD,MAAM,CAAC,YAAY,CACjB,kBAAkB,EAClB;QACE,KAAK,EAAE,WAAW;QAClB,WAAW,EACT,qIAAqI;QACvI,WAAW,EAAE;YACX,cAAc,EAAE,CAAC;iBACd,MAAM,EAAE;iBACR,GAAG,CAAC,CAAC,CAAC;iBACN,QAAQ,CACP,sHAAsH,CACvH;YACH,IAAI,EAAE,CAAC;iBACJ,MAAM,EAAE;iBACR,GAAG,EAAE;iBACL,GAAG,CAAC,CAAC,CAAC;iBACN,QAAQ,EAAE;iBACV,QAAQ,CAAC,mBAAmB,CAAC;YAChC,KAAK,EAAE,CAAC;iBACL,MAAM,EAAE;iBACR,GAAG,EAAE;iBACL,GAAG,CAAC,CAAC,CAAC;iBACN,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC;iBACpB,OAAO,CAAC,EAAE,CAAC;iBACX,QAAQ,CAAC,eAAe,MAAM,CAAC,QAAQ,QAAQ,CAAC;SACpD;KACF,EACD,KAAK,EAAE,EAAE,cAAc,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,EAAE;QACxC,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,cAAc,CAAC,EAAE,gBAAgB,EAAE,cAAc,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;YACvF,OAAO;gBACL,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC;aACnE,CAAC;QACJ,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,OAAO,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YACjE,OAAO;gBACL,OAAO,EAAE,IAAI;gBACb,OAAO,EAAE;oBACP;wBACE,IAAI,EAAE,MAAM;wBACZ,IAAI,EAAE,YAAY,OAAO,iCAAiC;qBAC3D;iBACF;aACF,CAAC;QACJ,CAAC;IACH,CAAC,CACF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,38 @@
1
+ /**
2
+ * get_job_detail tool 的定義與註冊。
3
+ * 拿 search_jobs 回傳的職缺網址(或代碼),取得完整詳情。
4
+ */
5
+ import { z } from "zod";
6
+ import { getJobDetail } from "../api/job104.js";
7
+ export function registerGetJobDetail(server) {
8
+ server.registerTool("get_job_detail", {
9
+ title: "取得 104 職缺詳情",
10
+ description: "取得單筆 104 職缺的完整詳情:完整職務說明、薪資、地點、學經歷要求、需求技能、語言能力、福利、產業別。傳入 search_jobs 結果中的職缺網址(url 欄位)。",
11
+ inputSchema: {
12
+ jobUrlOrId: z
13
+ .string()
14
+ .min(1)
15
+ .describe("職缺網址或代碼,例如 'https://www.104.com.tw/job/7uqyj' 或 '7uqyj'(用 search_jobs 回傳的 url)"),
16
+ },
17
+ }, async ({ jobUrlOrId }) => {
18
+ try {
19
+ const detail = await getJobDetail(jobUrlOrId);
20
+ return {
21
+ content: [{ type: "text", text: JSON.stringify(detail, null, 2) }],
22
+ };
23
+ }
24
+ catch (err) {
25
+ const message = err instanceof Error ? err.message : String(err);
26
+ return {
27
+ isError: true,
28
+ content: [
29
+ {
30
+ type: "text",
31
+ text: `取得職缺詳情失敗:${message}。104 可能改版或被 Cloudflare 阻擋,稍後再試。`,
32
+ },
33
+ ],
34
+ };
35
+ }
36
+ });
37
+ }
38
+ //# sourceMappingURL=getJobDetail.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"getJobDetail.js","sourceRoot":"","sources":["../../src/tools/getJobDetail.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAEhD,MAAM,UAAU,oBAAoB,CAAC,MAAiB;IACpD,MAAM,CAAC,YAAY,CACjB,gBAAgB,EAChB;QACE,KAAK,EAAE,aAAa;QACpB,WAAW,EACT,uFAAuF;QACzF,WAAW,EAAE;YACX,UAAU,EAAE,CAAC;iBACV,MAAM,EAAE;iBACR,GAAG,CAAC,CAAC,CAAC;iBACN,QAAQ,CACP,gFAAgF,CACjF;SACJ;KACF,EACD,KAAK,EAAE,EAAE,UAAU,EAAE,EAAE,EAAE;QACvB,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,YAAY,CAAC,UAAU,CAAC,CAAC;YAC9C,OAAO;gBACL,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC;aACnE,CAAC;QACJ,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,OAAO,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YACjE,OAAO;gBACL,OAAO,EAAE,IAAI;gBACb,OAAO,EAAE;oBACP;wBACE,IAAI,EAAE,MAAM;wBACZ,IAAI,EAAE,YAAY,OAAO,iCAAiC;qBAC3D;iBACF;aACF,CAAC;QACJ,CAAC;IACH,CAAC,CACF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,88 @@
1
+ /**
2
+ * search_jobs tool 的定義與註冊。
3
+ * 只負責:宣告參數 schema、呼叫抓取層、把結果整理成給模型看的格式。
4
+ */
5
+ import { z } from "zod";
6
+ import { searchJobs } from "../api/job104.js";
7
+ import { CONFIG } from "../config.js";
8
+ export function registerSearchJobs(server) {
9
+ server.registerTool("search_jobs", {
10
+ title: "搜尋 104 職缺",
11
+ description: "依關鍵字與篩選條件搜尋台灣 104 人力銀行的即時職缺,回傳職稱、公司、地區、薪資、需求技能與職缺網址。支援地區、薪資下限、職類、遠端、全/兼職、年資篩選,以及分頁。" +
12
+ "若 area 同名多處(如「信義區」有台北市與基隆市兩個),不會直接搜尋,改回傳 ambiguousArea 候選清單 —— 此時請向使用者確認是哪一個,再用完整名稱(如「台北市信義區」)重新搜尋。",
13
+ inputSchema: {
14
+ keyword: z.string().min(1).describe("職務關鍵字,例如 'Rust 工程師'"),
15
+ area: z
16
+ .string()
17
+ .optional()
18
+ .describe("工作地區名稱,例如 '台北市'、'新竹'(會解析成 104 官方地區代碼查詢)"),
19
+ salaryMin: z
20
+ .number()
21
+ .int()
22
+ .min(0)
23
+ .optional()
24
+ .describe("月薪下限(新台幣),例如 60000。薪資明確低於此值的濾掉;面議預設保留"),
25
+ excludeNegotiable: z
26
+ .boolean()
27
+ .optional()
28
+ .describe("排除「面議」(沒寫薪資)的職缺,預設 false。想只看有明確薪資時設 true"),
29
+ excludeFeatured: z
30
+ .boolean()
31
+ .optional()
32
+ .describe("排除 104 付費推廣/廣告職缺(結果中 featured=true 的),預設 false。這些會被 104 硬塞在最前面、常不符合搜尋條件;想只看自然結果時設 true"),
33
+ jobCategory: z
34
+ .string()
35
+ .optional()
36
+ .describe("職務類別名稱,例如 '軟體工程師'、'行銷企劃'(會解析成 104 官方職類代碼查詢)"),
37
+ remote: z
38
+ .enum(["full", "partial", "any"])
39
+ .optional()
40
+ .describe("遠端工作:full 完全遠端 / partial 部分遠端 / any 兩者皆可"),
41
+ jobType: z
42
+ .enum(["fulltime", "parttime"])
43
+ .optional()
44
+ .describe("工作性質:fulltime 全職 / parttime 兼職"),
45
+ experience: z
46
+ .enum(["under-1y", "1-3y", "3-5y", "5-10y", "over-10y"])
47
+ .optional()
48
+ .describe("需求年資級距:under-1y(1年以下)/1-3y/3-5y/5-10y/over-10y(10年以上)"),
49
+ sort: z
50
+ .enum(["newest", "salary"])
51
+ .optional()
52
+ .describe("排序:newest 最新更新在前(找新開職缺/掃描擴編用,建議搭配 excludeFeatured=true,否則廣告位仍會無視排序卡在最前面)/ salary 待遇由高到低。不給則用 104 預設的相關性排序"),
53
+ page: z
54
+ .number()
55
+ .int()
56
+ .min(1)
57
+ .optional()
58
+ .describe("第幾頁(每頁 20 筆),預設 1。想看更多職缺就往後翻頁"),
59
+ limit: z
60
+ .number()
61
+ .int()
62
+ .min(1)
63
+ .max(CONFIG.maxLimit)
64
+ .default(5)
65
+ .describe(`本頁回傳筆數上限,最多 ${CONFIG.maxLimit},預設 5`),
66
+ },
67
+ }, async (args) => {
68
+ try {
69
+ const result = await searchJobs(args);
70
+ return {
71
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
72
+ };
73
+ }
74
+ catch (err) {
75
+ const message = err instanceof Error ? err.message : String(err);
76
+ return {
77
+ isError: true,
78
+ content: [
79
+ {
80
+ type: "text",
81
+ text: `搜尋 104 職缺失敗:${message}。104 可能改版或被 Cloudflare 阻擋,稍後再試。`,
82
+ },
83
+ ],
84
+ };
85
+ }
86
+ });
87
+ }
88
+ //# sourceMappingURL=searchJobs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"searchJobs.js","sourceRoot":"","sources":["../../src/tools/searchJobs.ts"],"names":[],"mappings":"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"}
package/dist/types.js ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * 職缺的「乾淨型別」—— 這是我們回給模型的格式。
3
+ *
4
+ * 刻意跟 104 的原始欄位脫鉤:104 改欄位名時,只改 normalizeJob,
5
+ * 這個型別和 tool 都不用動。
6
+ */
7
+ import { extractSlug } from "./slug.js";
8
+ export const NEGOTIABLE = "面議";
9
+ /** 104 用這個值表示「上限不設」,要正規化成「以上」,不能直接印出來 */
10
+ const NO_UPPER_LIMIT = 9_999_999;
11
+ /** 104 薪資類型代碼(搜尋 API 的 s10):10=面議、30=時薪、40=日薪、50=月薪、60=年薪 */
12
+ const SALARY_TYPE_PREFIX = { 30: "時薪", 40: "日薪", 50: "月薪", 60: "年薪" };
13
+ const SALARY_TYPE_NEGOTIABLE = 10;
14
+ /** 把薪資數字轉成人看得懂的字串(type=10 或 0/0 代表面議;上限 9999999 代表不設上限) */
15
+ function formatSalary(low, high, type) {
16
+ if (type === SALARY_TYPE_NEGOTIABLE || (!low && !high))
17
+ return NEGOTIABLE;
18
+ const prefix = SALARY_TYPE_PREFIX[type ?? 0] ?? "月薪"; // 沒給 s10 時退回月薪(歷史行為)
19
+ const fmt = (n) => n.toLocaleString("en-US");
20
+ const hasUpper = !!high && high < NO_UPPER_LIMIT; // 濾掉 9999999 這個哨兵值
21
+ if (low && hasUpper)
22
+ return `${prefix} ${fmt(low)}~${fmt(high)} 元`;
23
+ if (low)
24
+ return `${prefix} ${fmt(low)} 元以上`; // 含 low>0 且上限不設的情況
25
+ return `${prefix} ${fmt(high)} 元以下`;
26
+ }
27
+ /** 搜尋 API 的日期是 8 碼數字(20260817),轉成跟詳情 API 一致的 2026/08/17;非預期格式原樣放行 */
28
+ function formatAppearDate(raw) {
29
+ if (!raw)
30
+ return "";
31
+ const m = raw.match(/^(\d{4})(\d{2})(\d{2})$/);
32
+ return m ? `${m[1]}/${m[2]}/${m[3]}` : raw;
33
+ }
34
+ /** 104 用 [[[關鍵字]]] 標記命中的字,清掉這些標記 */
35
+ function stripHighlight(text) {
36
+ return text.replace(/\[\[\[|\]\]\]/g, "").trim();
37
+ }
38
+ /**
39
+ * 把一筆 104 原始職缺轉成乾淨的 Job(精簡列表用)。
40
+ * 刻意不含完整 JD —— 搜尋是概覽,完整內容用 get_job_detail 拿。
41
+ * 列表越精簡,模型整理成清單時越不會把某筆的網址對錯到別筆。
42
+ * ⚠️ 這是「防腐層」—— 104 改版時的唯一修改點。
43
+ */
44
+ export function normalizeJob(raw) {
45
+ // jobId 用 slug(跟 link.job / get_job_detail / 公司職缺一致),不用數字 jobNo
46
+ const slug = extractSlug(raw.link?.job ?? "");
47
+ return {
48
+ jobId: slug || (raw.jobNo ?? ""),
49
+ jobName: stripHighlight(raw.jobName ?? ""),
50
+ companyName: raw.custName ?? "",
51
+ companyUrl: raw.link?.cust ?? "",
52
+ area: raw.jobAddrNoDesc ?? "",
53
+ salary: formatSalary(raw.salaryLow, raw.salaryHigh, raw.s10),
54
+ applyCount: raw.applyCnt ?? 0,
55
+ employeeCount: Number(raw.employeeCount) || 0,
56
+ skills: (raw.pcSkills ?? []).map((s) => s.description ?? "").filter(Boolean),
57
+ url: raw.link?.job ?? "",
58
+ appearDate: formatAppearDate(raw.appearDate),
59
+ // 只有 jobType=1 是會無視關鍵字硬塞的廣告;0(一般)和 2(優先位) 都是有效結果,一起分析
60
+ featured: raw.jobType === 1,
61
+ };
62
+ }
63
+ const descriptions = (items) => (items ?? []).map((i) => i.description ?? "").filter(Boolean);
64
+ /** 把語言要求整理成 "英文 (聽:中等 說:中等)" 這種好讀字串 */
65
+ function formatLanguages(langs) {
66
+ return (langs ?? []).map((l) => {
67
+ const a = l.ability;
68
+ const parts = a
69
+ ? [
70
+ a.listening && `聽:${a.listening}`,
71
+ a.speaking && `說:${a.speaking}`,
72
+ a.reading && `讀:${a.reading}`,
73
+ a.writing && `寫:${a.writing}`,
74
+ ]
75
+ .filter(Boolean)
76
+ .join(" ")
77
+ : "";
78
+ return parts ? `${l.language ?? ""} (${parts})` : (l.language ?? "");
79
+ });
80
+ }
81
+ /**
82
+ * 把 104 原始詳情轉成乾淨的 JobDetail。
83
+ * ref 帶入這筆職缺的 jobId(slug) 與 url —— 詳情 body 本身沒有,由呼叫端補上,
84
+ * 讓 jobId/url 跟 search_jobs / get_company_jobs 一致。
85
+ * ⚠️ 跟 normalizeJob 一樣是防腐層,104 改版時的修改點。
86
+ */
87
+ export function normalizeJobDetail(raw, ref = { jobId: "", url: "" }) {
88
+ const jd = raw.jobDetail ?? {};
89
+ const cond = raw.condition ?? {};
90
+ const location = [jd.addressRegion, jd.addressDetail].filter(Boolean).join(" ");
91
+ return {
92
+ jobId: ref.jobId,
93
+ jobName: raw.header?.jobName ?? "",
94
+ companyName: raw.header?.custName ?? "",
95
+ companyUrl: raw.header?.custUrl ?? "",
96
+ url: ref.url,
97
+ appearDate: raw.header?.appearDate ?? "",
98
+ salary: jd.salary ?? "",
99
+ area: jd.addressRegion ?? "",
100
+ location,
101
+ description: (jd.jobDescription ?? "").trim(),
102
+ categories: descriptions(jd.jobCategory),
103
+ experience: cond.workExp ?? "",
104
+ education: cond.edu ?? "",
105
+ majors: cond.major ?? [],
106
+ otherConditions: (Array.isArray(cond.other) ? cond.other.filter(Boolean).join("\n") : (cond.other ?? "")).trim(),
107
+ // skills = 擅長工具/語言(跟 search 的 skills 一致),jobSkills = 職務技能(職類層級)
108
+ skills: descriptions(cond.specialty),
109
+ jobSkills: descriptions(cond.skill),
110
+ languages: formatLanguages(cond.language),
111
+ headcount: jd.needEmp ?? "",
112
+ manageResp: jd.manageResp ?? "",
113
+ businessTrip: jd.businessTrip ?? "",
114
+ industry: raw.industry ?? "",
115
+ employees: raw.employees ?? "",
116
+ welfareTags: raw.welfare?.tag ?? [],
117
+ welfare: (raw.welfare?.welfare ?? "").trim(),
118
+ };
119
+ }
120
+ /** 把公司 API 的原始職缺轉成乾淨的 CompanyJob(防腐層) */
121
+ export function normalizeCompanyJob(raw) {
122
+ return {
123
+ jobId: raw.jobNo ?? "",
124
+ jobName: (raw.jobName ?? "").trim(),
125
+ area: raw.jobAddrNoDesc ?? "",
126
+ salary: raw.jobSalaryDesc ?? "",
127
+ education: raw.edu ?? "",
128
+ experience: raw.periodDesc ?? "",
129
+ url: raw.jobUrl ?? "",
130
+ };
131
+ }
132
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "mcp-server-104",
3
+ "version": "0.1.0",
4
+ "description": "Unofficial MCP server for 104.com.tw (Taiwan job board): search jobs, job details, company openings",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "a7512cs",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/a7512cs/104-mcp-server.git"
11
+ },
12
+ "homepage": "https://github.com/a7512cs/104-mcp-server#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/a7512cs/104-mcp-server/issues"
15
+ },
16
+ "keywords": [
17
+ "mcp",
18
+ "mcp-server",
19
+ "model-context-protocol",
20
+ "104",
21
+ "job-search",
22
+ "taiwan",
23
+ "claude"
24
+ ],
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "files": [
29
+ "dist"
30
+ ],
31
+ "bin": {
32
+ "mcp-server-104": "dist/index.js"
33
+ },
34
+ "scripts": {
35
+ "build": "tsc",
36
+ "dev": "tsc --watch",
37
+ "inspect": "npx @modelcontextprotocol/inspector node dist/index.js",
38
+ "test": "npm run build && node --test test/*.test.mjs",
39
+ "prepublishOnly": "npm test"
40
+ },
41
+ "dependencies": {
42
+ "@modelcontextprotocol/sdk": "^1.30.0",
43
+ "cycletls": "^2.0.5",
44
+ "zod": "^4.4.3"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^24.0.0",
48
+ "typescript": "^5.0.0"
49
+ }
50
+ }