不是能抓到頁面,就能交給 Agent

找工作最煩的地方之一,是你明明只是想知道「最近有沒有值得投的職缺」,結果人先被平台耗乾。

LinkedIn 開一次、JobStreet 開一次、Jora 再開一次。關鍵字重打、地區重選、職缺一個一個點開,再把看起來有可能的丟進追蹤表。做久了真的會有一種感覺:我到底是在找工作,還是在幫平台做人工資料整理?

jobs-scraper 是我第三次嘗試把這件事自動化。第一次是用 Make 串 agentic workflow;第二次是把 workflow 搬進 AI agent,讓 agent 自己跑;這一次,我想把最共用、最不個人的那一層拆出來,標準化成 Skill-like tool,讓不同 agent,甚至其他人,也能直接用。

我另外還有一套比較私人的自動化:用我的長項去評分職缺、篩選、一路寫到 cover letter。那套東西太吃個人判斷,不適合直接公開。職缺爬蟲不一樣。把職缺抓下來、整理乾淨、交給下一層 workflow,這件事可以獨立出來。

所以 jobs-scraper 真正有意思的地方,不是「它可以抓到某個頁面」。那只是入口。比較麻煩的是:Agent 呼叫它的時候,權限要怎麼切?輸出要怎麼固定?上游職缺文字會不會被模型當成指令?一次 crawl 會不會莫名其妙變成寫入 Google Sheet?來源回 403 或 429 時,工具會不會越跑越激進?

這篇不是教你怎麼繞過平台防護。比較準確地說,這個 repo 使用各來源的 public / guest-facing data paths,搭配各自的 adapter、bounded pacing、明確失敗處理,並把整個能力包成本機優先的工具邊界。Agent-ready tool 不是「Agent 可以執行的腳本」,而是「Agent 可以在清楚邊界內呼叫的能力」。

目前 jobs-scraper 把同一條本機 workflow 包成四個入口:Python CLI、local STDIO MCP server、Agent Skill,以及可攜式 Google Sheet Job Tracker。外面看起來是四種 surface,裡面其實是在做同一件事:收集職缺、整理成穩定資料、保留本機狀態,需要同步時才明確寫進 tracker。

先跟著一個請求走完整條路

先不要想架構圖。想一個很普通的需求就好:

幫我找最近七天的新加坡 LinkedIn product management 職缺,要抓完整 JD,但先不要寫進我的 Sheet。

如果只是普通 scraper,做到「組 URL、抓資料、印結果」可能就差不多了。但 Agent 不是人坐在終端機前面一步一步確認。你把工具交給 Agent,它需要的是可以被限制住的入口。

在這個 repo 裡,Agent 呼叫的是 MCP tool。你可以先把 MCP tool 想成「Agent 能按的安全按鈕」:它不是讓 Agent 隨便跑 shell,而是把 source、region、query、time range、full JD、page ceiling 這些參數轉成 bounded CLI arguments。

這次請求也只是 crawl path,不是 Sheet sync path。工具可以讀來源、更新本機 cache、回傳 machine-readable summary;但它不應該因為模型中途看到某段 JD,或自己推理了一句「這看起來不錯」,就順手改寫 Google Sheet。

回傳資料也要是結構化結果,而不是把整坨 HTML 丟回模型說「你自己看」。Agent 最怕這種半資料半噪音的東西,因為職缺內容本身只是資料,不該變成控制工具的指令。

local-first 的好處也在這裡。credentials、cache、tracker 設定都留在本機;Agent 拿到的是一個明確定義過的能力。需要 full JD enrichment 時,runtime 仍然有 timeout、page ceiling、retry/backoff、soft stop 和 upstream error handling。這些功能聽起來不炫,但 Agent 真的開始跑工具時,靠的就是這些東西不失控。

流程圖顯示 Agent request 經 MCP contract 進入本機 jobs-scraper workflow,再由 LinkedIn、JobStreet、Jora adapters 產生 normalised job records,並把 Explicit Sheet sync 與 downstream scoring 分開。

三個來源不能共用同一種抓法

jobs-scraper 支援 LinkedIn、JobStreet 和 Jora。名字都叫職缺平台,但抓起來完全不是同一回事。

LinkedIn 這邊走 guest-facing list/detail paths,查詢會用到 geoId、時間範圍、關鍵字和 pagination offset。JobStreet 的 listing 來自 JSON search API,JD enrichment 則走 GraphQL job-detail query,而不是靠 HTML detail page 硬拆。Jora 又是另一種路線:HTML list/detail parsing,遇到 403 時做 bounded retry/backoff。

一般讀者不需要記住每個細節。這裡真正值得看的,是為什麼不能把三個來源全塞進同一個 generic parser。

每個來源的資料形狀、地區限制、pagination、rate-limit response、detail format 都不一樣。如果硬做成一支「什麼頁面都解析」的萬用 parser,最後痛苦的會是 Agent:它拿到的不是穩定資料,而是一堆上游網站的脾氣。

所以這個 repo 用 source-specific adapters。每個 adapter 處理自己的來源差異,最後再轉成共享 job record。這樣 Agent 不需要知道 LinkedIn、JobStreet、Jora 各自怎麼彆扭;它只需要知道工具會回傳同一種格式。

這也是為什麼我不會把這件事寫成「打敗反爬」。browser-like request profile、persistent session、bounded pacing、429/403 handling 都只是實作細節。它們不是 authentication、CAPTCHA、private API、access control 或 rate limit 的繞過證明。真正可重用的工程判斷,是把來源差異關在 adapter 裡,失敗時也讓它失敗得清楚。

Agent 需要的是穩定紀錄,不是原始 HTML

Agent 不適合直接吃 raw HTML。HTML 裡有資料、有排版、有網站自己的文字,也可能有一堆跟任務無關的東西。你把它整包丟給模型,它就要同時猜「哪個是資料」和「哪個只是頁面雜訊」。

jobs-scraper 做的事,是把不同來源整理成共同的 job record:job id、title、company、location、posting time、URL、source,以及可選的 JD 內容。格式穩定了,後面的 workflow 才能穩定。

去重也在這裡變重要。同一個 source / job identity 不應該每跑一次就又進 tracker 一次;full JD enrichment 也可以透過本機 seen-JD cache,避免一直重抓看過的內容。

工具還會抓一些 work mode、visa / constraint signals,但這裡要講清楚:它們是 heuristic signals,不是法律分類,也不是「這份工作一定適合你」的判斷。

v1.2.1 有個很小但我覺得很關鍵的預設:title skip filtering 變成 opt-in。也就是說,預設 full-JD path 不會偷偷因為標題關鍵字跳過職缺。你真的要過濾,就自己提供 skip keywords。這不是什麼華麗功能,但對 Agent tool 很重要:不要在使用者沒說清楚時,替他做看不見的篩選。

讀取邊界與寫入邊界必須分開

Google Sheet 是這個工具最需要小心的地方。

「幫我找職缺」和「幫我把職缺寫進追蹤表」不是同一件事。前者是讀取;後者是寫入。人可以靠常識分辨這兩句差在哪裡,Agent 需要靠工具邊界分辨。

jobs-scraper 把 crawl、audit、stats 這些 read-only 操作,和 tracker initialisation、regional sync 這些會改變 Sheet 的操作分開。tracker initialisation 預設要能 dry-run;真的要寫入前,要先 preflight。source / region 不支援、設定缺失、region pair 不存在、schema 不相容,就 fail closed,不要猜。

這個設計聽起來保守,但我寧願它保守。上游 JD 裡的文字不能選 credentials,不能選 destination,不能改設定,也不能把一次讀取升級成寫入。職缺文字就是資料,不是工具指令。

這也讓 repo 比較適合公開。公開一個本機工具,不是把程式碼推上 GitHub 就完事。repo 裡不能有 package author 的 workbook metadata、私有 Sheet id、本機 secret path,也不能有一裝起來就默默寫到錯誤地方的預設值。

追蹤不是排名

jobs-scraper 也刻意不把「追蹤」講成「排名」。

Job Tracker schema 有 A:AA 欄位,但 scraper sync 只擁有 A 和 C:K。L:AA 留給後面的 scoring / application workflow:total score、verdict、decision、application strategy、role fit、proof、AI/tech leverage、seniority、company quality、domain advantage、ROI、constraints、positioning、risks、next action 等,這些不是 scraper 自己判斷的東西。

比較安全也比較準確的說法是:jobs-scraper 負責收集、標準化、去重,然後把資料路由到 agent-ready scoring surface。它不會自己決定哪份工作最適合我,也不該把個人化排名包裝成爬蟲功能。

這個切分很重要。很多 Agent workflow 會變難維護,就是因為一個工具什麼都想做:抓資料、判斷資料、排名、寫狀態、決定下一步。jobs-scraper 的邊界比較窄。它先把職缺資料整理到後面 workflow 能接,至於要不要投、怎麼投,那是下一層的事。

把私人腳本公開,本身就是產品工程

私人腳本其實很好糊。路徑寫死、Sheet 名稱寫死、憑證放在自己機器上,反正作者本人知道怎麼跑。只要東西能動,你還會覺得它很省時間。

但一旦要讓 Agent 呼叫,或讓另一個人照 README 裝起來,這些「我自己知道」就會開始變成坑。Agent 不知道哪個 Sheet 能寫,別人也不該拿到我的私有設定。這時候你會發現,公開 repo 不是「上傳一下」而已,還要把那些只有自己腦袋裡知道的安全邊界,真的做進工具裡。

這個 repo 從 v1.0.0 到 v1.2.1,大致就是把一個能跑的本機多來源工具,整理成比較能交給別人用的樣子:portable tracker、region-aware MCP、私有 metadata 清理、行為不變前提下的架構整理,以及 opt-in clean defaults。這些聽起來都不如「又支援一個平台」刺激,但少了就很麻煩。

CI 也只是這件事的一張收據。v1.2.1 的 exact candidate CI run 有成功完成;pytest coverage run 回報 178 passed、5 warnings、70% coverage。不過這個 70% 是 reported coverage,不是 enforced floor;CI 成功也不代表 live source 永遠穩定。它能證明的是:在那個 commit、那套測試環境裡,包裝、介面、行為和 repo hygiene 有被檢查過。

這也是我最後只把爬蟲這層公開出來的原因。評分、篩選、cover letter 那些東西太個人,跟我的強項和定位綁很深;但「把職缺穩定抓下來,整理成後面 workflow 能接的格式」可以標準化。先把水管接好,後面每個 Agent 才不用一直重接一次。真的,水管接到最後會懷疑人生。

Agent-Ready Tools 的第一個判準

放在 Agent-Ready Tools 這個系列裡,jobs-scraper 給我的第一個判準是:

Agent-ready tool 不是 Agent 能不能執行一支腳本,而是 Agent 能不能在明確邊界內呼叫一個本機能力。

這個能力需要幾件事:穩定輸入、結構化輸出、清楚失敗、讀寫分離、把上游內容當成不可信資料,以及基本驗證。jobs-scraper 的案例剛好把這些問題攤開:真正的工作不是抓到某一頁,而是把三個不一致的職缺來源整理成 bounded、local-first、testable 的工具。

它沒有證明 live source 永遠穩定,也沒有替平台條款下結論,更沒有自己完成個人化排名。那些都需要別的證據或別的 workflow。

但作為 Part 01,我覺得這個起點很實用。如果你也想把某個工具交給 Agent,不要只問「它能不能跑」。先問幾個比較無聊但更要命的問題:它的邊界在哪?輸出穩不穩?失敗講不講人話?寫入是不是需要明確授權?行為能不能被驗證?

這些答案,比單次成功抓到資料重要多了。

References