追蹤連載小說更新的 iOS App。這裡是開發人員網站,同時提供「解析器擴充」撰寫教學。
Serialist Tracker 是一款用來追蹤連載小說更新進度的 iOS App,內建多個小說站的解析支援,並提供「解析器擴充」機制,讓使用者或第三方作者不需要等 App Store 改版,就能新增或更新對特定網站的支援。
以下網址可貼到 App 的「解析器擴充」頁下載。內容同時也是 descriptor collection 的撰寫範例。
https://iceboxi-studio.github.io/descriptors.json
本節說明如何撰寫「解析器擴充」——一份 JSON 檔(必要時加一段 JavaScript),描述如何解析某個小說網站。把它放上 gist / GitHub raw 之類的公開網址,使用者就能在 App 的「解析器擴充」頁貼上網址下載,不需要等 App 改版。
擴充分兩層,能用宣告式就別寫腳本:
一份最小可用的 descriptor:
{
"id": "example_com",
"version": 1,
"domains": ["example.com"],
"meta": {
"titleSources": [{ "type": "ogMeta", "property": "og:title" }],
"coverSources": [{ "type": "ogMeta", "property": "og:image" }]
},
"chapters": {
"strategy": { "type": "cssContainer", "selector": "ul.chapter-list" }
},
"content": { "cssSelector": "div.content" }
}
意思是:書名取 og:title、封面取 og:image、章節連結在 ul.chapter-list 裡、內文在 div.content。把這份 JSON 放到公開網址,在 App 裡貼上即可。
| 欄位 | 型別 | 必填 | 說明 |
|---|---|---|---|
id | String | ✅ | 唯一識別符,建議用 domain_tld 格式,例如 "czbooks_net"。更新時以此比對。 |
version | Int | ✅ | 格式版本號。每次更新內容都要遞增,使用者「檢查更新」時才會套用。 |
domains | [String] | ✅ | 適用網域,以網域結尾比對。例:["example.com", "m.example.com"]。 |
encoding | String | 頁面編碼:"utf8"(預設)、"big5"、"gbk"。 | |
fetch | Object | 抓取策略,見下方。 | |
authentication | Object | 登入頁與未登入狀態辨識規則,見下方。 | |
chapterListURL | Object | 書籍主頁與目錄頁不同時的導航規則,見下方。 | |
meta | Object | ✅ | 書名 / 封面提取規則。 |
chapters | Object | ✅ | 章節列表提取規則。 |
content | Object | ✅ | 章節內文提取規則。 |
script | String | JavaScript 腳本,見「腳本層」。 |
⚠️ 所有欄位名稱區分大小寫。省略選填欄位時使用預設值。
titleSources 與 coverSources 都是陣列,App 依序嘗試,取第一個非空結果。每個來源用 type 區分:
type | 參數 | 說明 |
|---|---|---|
ogMeta | property, stripSuffix? | <meta property="og:title" content="…"> |
nameMeta | name | <meta name="…" content="…"> |
cssSelector | selector, stripSuffix? | 用 CSS selector 取元素文字 |
regex | pattern | 正規表示式,取第一個捕獲群組 |
titleTag | stripSuffix? | <title> 標籤內容 |
stripSuffix 是一段 regex,會從結果中移除(用來去掉「- 網站名」之類的後綴)。
"meta": {
"titleSources": [
{ "type": "cssSelector", "selector": "span.title", "stripSuffix": "[《》]" },
{ "type": "ogMeta", "property": "og:title", "stripSuffix": "\s*\|\s*小說狂人.*$" },
{ "type": "titleTag", "stripSuffix": " - .*$" }
],
"coverSources": [
{ "type": "ogMeta", "property": "og:image" }
]
}
"chapters": {
"strategy": { "type": "cssContainer", "selector": "ul.chapter-list" },
"pathPrefix": "/n/",
"dedupe": true
}
| 欄位 | 型別 | 說明 |
|---|---|---|
strategy | Object | 章節連結提取策略,見下表。 |
pathPrefix | String? | 只保留路徑以此開頭的連結(過濾非章節連結)。腳本策略不套用此欄位。 |
dedupe | Bool | 是否依絕對 URL 去重,預設 true。 |
strategy 種類:
type | 參數 | 說明 |
|---|---|---|
cssContainer | selector | 用 CSS selector 定位容器,抓其中所有 <a>。最常用。 |
listContainer | openingPattern | 用 regex 定位容器開頭,從該處到頁尾抓連結。 |
sequential | latestChapterURLMeta, urlSuffix | 由序列化 URL 產生列表(/p1.html、/p2.html…)。 |
paginated | containerPattern, nextPagePattern | 目錄跨多頁時,沿著「下一頁」累積。 |
swiftStrategy是 App 內建的逃生艙,下載擴充不可使用(沒有對應的 Swift 實作會直接報錯)。
"content": {
"cssSelector": "div.content",
"stopPatterns": ["<div class=\"nav\""],
"removePatterns": ["<script[\s\S]*?</script>", "廣告文字"]
}
| 欄位 | 型別 | 說明 |
|---|---|---|
cssSelector | String? | 用 CSS selector 定位內文容器(與 containerPattern 二選一)。 |
containerPattern | String? | 用 regex 定位內文容器開頭。 |
stopPatterns | [String] | 遇到任一 pattern 就在該處截斷(去掉尾端導覽等)。 |
removePatterns | [String] | 從內文移除的 pattern(去廣告等)。 |
App 會自動移除 <script>/<style>、把 <p> 轉成段落換行、清理標籤。
fetch——網站需要 JS 渲染時:
"fetch": {
"strategy": "webView",
"webViewJS": "document.querySelector('.load-more')?.click();",
"webViewWaitMs": 3000
}
| 欄位 | 說明 |
|---|---|
strategy | "smart"(預設,自動偵測 bot 防護並 fallback WebView)或 "webView"(強制 WebView 渲染)。 |
webViewJS | WebView 載入後注入的 JS(選填)。 |
webViewWaitMs | WebView 等待渲染的毫秒數(選填,預設 2500)。 |
authentication——部分頁面需要登入時:
"authentication": {
"loginURL": "https://example.com/login",
"unauthenticatedPatterns": [
"<title>\\s*登入",
"href=[\\\"']/login[\\\"']"
]
}
| 欄位 | 說明 |
|---|---|
loginURL | App 顯示的登入 WebView 網址,可使用絕對或相對 URL。 |
unauthenticatedPatterns | 辨識未登入/降級頁面的 regex 陣列;所有 pattern 都命中才會要求登入。 |
登入完成後 App 會重試原頁面。需要共用登入 cookie 的網站,建議將 fetch.strategy 設為 "webView";cookie 由 App 的 persistent WKWebView session 保存。若 cookie 過期、頁面再次符合全部未登入 pattern,App 會重新顯示登入流程。
chapterListURL——目錄頁跟書籍主頁不同網址時:
"chapterListURL": { "strategy": "appendPath", "value": "/dir" }
strategy | value 意義 |
|---|---|
appendPath | 直接在書頁 URL 後附加的路徑。 |
findLinkPath | regex,比對書頁中連結的 href,取第一個符合者。 |
findLinkText | regex,比對連結文字。 |
當宣告式規則搞不定時(例如內文被 base64 藏在 JS 變數裡、章節 URL 要計算),在 descriptor 加一個 script 欄位,內容是一段 JavaScript。
腳本可定義以下任意函式;有定義的優先於對應的宣告式規則:
// 回傳 null 代表「交回宣告式規則處理」
function parseMeta(html, url) {
return { title: "書名", coverURL: "https://…/cover.jpg" };
// title / coverURL 任一為 null 時,該欄位個別 fallback 回宣告式規則
}
function parseChapters(html, url) {
return [
{ title: "第一章 起點", url: "/c/1" }, // url 可為相對路徑,會以當前頁面解析
{ title: "第二章 續行", url: "/c/2" }
];
}
function parseContent(html, url) {
return { content: "純文字內文…", nextURL: null };
// nextURL 非 null 時,App 會抓下一頁再呼叫一次,把各頁 content 串起來(處理內文分頁)
}
html 是 App 抓回來的原始 HTML(已依 encoding 解碼),url 是當前頁面網址字串。parseContent 的 nextURL。沙箱裡只有這些,沒有 window、document、fetch、計時器:
| 函式 | 說明 |
|---|---|
atob(str) | Base64 解碼。與瀏覽器不同:優先以 UTF-8 解碼(小說站 payload 幾乎都是 UTF-8 中文),失敗才 fallback latin-1。 |
btoa(str) | Base64 編碼(UTF-8)。 |
console.log(str) | 除錯輸出(正式版不顯示)。 |
字串處理、正規表示式(String.match / RegExp)、JSON 等標準 JS 都能用。
假設某站把內文 base64 藏在 <div id="acontent" data-c="…">,且長章節分兩頁(頁尾有 <a class="next" href="…">):
{
"id": "tricky_site_com",
"version": 1,
"domains": ["tricky-site.com"],
"meta": {
"titleSources": [{ "type": "ogMeta", "property": "og:title" }],
"coverSources": [{ "type": "ogMeta", "property": "og:image" }]
},
"chapters": {
"strategy": { "type": "cssContainer", "selector": "ul.chapters" }
},
"content": { "cssSelector": "div#acontent" },
"script": "function parseContent(html, url) {\n const m = html.match(/data-c=\"([^\"]+)\"/);\n if (!m) return null;\n const text = atob(m[1]);\n const next = html.match(/<a class=\"next\" href=\"([^\"]+)\"/);\n return { content: text, nextURL: next ? next[1] : null };\n}"
}
script是 JSON 字串,裡面的引號與換行要照 JSON 規則跳脫(\"、\n)。建議先把 JS 寫成獨立檔案除錯,再用工具轉成單行 JSON 字串。
null = fallback:parseContent 第一頁就回 null 時,改用宣告式 content 規則。這讓你可以「大部分交給宣告式,只有特殊情況用腳本接管」。title 推導,你不用自己算。parseChapters 不套用 pathPrefix——腳本自己負責只回傳章節連結。dedupe 仍會生效。把一份 descriptor JSON 放到任何可直連的 HTTPS 網址即可,例如 GitHub raw:
https://raw.githubusercontent.com/<you>/<repo>/main/example_com.json
使用者在 App「解析器擴充」頁貼上這個網址 → 下載 → 驗證 → 立即生效。
一個網址也可以回傳 descriptor 陣列,一次匯入多站:
[
{ "id": "site_a", "version": 3, "domains": ["a.com"], "meta": {...}, "chapters": {...}, "content": {...} },
{ "id": "site_b", "version": 1, "domains": ["b.com"], "meta": {...}, "chapters": {...}, "content": {...} }
]
使用者匯入時,App 會記住來源網址。之後在擴充頁按「檢查更新」時,App 會重新抓來源、比對 version:遠端 version 較大才會覆蓋。
所以維護流程是:
version 加一(不加就不會被視為更新)。匯入時 App 會擋下:
http / https。script 有語法錯誤(載入即失敗)。其他限制:
swiftStrategy 章節策略在下載擴充中不可用。atob / btoa / console。{
"id": "string (必填, 唯一)",
"version": 1, // 必填, 更新要遞增
"domains": ["example.com"], // 必填
"encoding": "utf8 | big5 | gbk", // 選填, 預設 utf8
"fetch": { // 選填
"strategy": "smart | webView",
"webViewJS": "string",
"webViewWaitMs": 2500
},
"authentication": { // 選填
"loginURL": "https://example.com/login",
"unauthenticatedPatterns": ["regex"]
},
"chapterListURL": { // 選填
"strategy": "appendPath | findLinkPath | findLinkText",
"value": "string"
},
"meta": { // 必填
"titleSources": [ /* MetaSource */ ],
"coverSources": [ /* MetaSource */ ]
},
"chapters": { // 必填
"strategy": { "type": "cssContainer", "selector": "…" },
"pathPrefix": "string", // 選填
"dedupe": true // 選填, 預設 true
},
"content": { // 必填
"cssSelector": "string", // 或 containerPattern 二選一
"containerPattern": "regex",
"stopPatterns": ["regex"], // 選填
"removePatterns": ["regex"] // 選填
},
"script": "javascript source" // 選填
}
MetaSource:
{ "type": "ogMeta", "property": "og:title", "stripSuffix": "regex" }
{ "type": "nameMeta", "name": "author" }
{ "type": "cssSelector", "selector": "span.title", "stripSuffix": "regex" }
{ "type": "regex", "pattern": "regex with (capture)" }
{ "type": "titleTag", "stripSuffix": "regex" }
ChapterStrategy:
{ "type": "cssContainer", "selector": "ul.chapters" }
{ "type": "listContainer", "openingPattern": "<ul[^>]*class=\"chapter\"" }
{ "type": "sequential", "latestChapterURLMeta": "og:novel:latest_chapter_url", "urlSuffix": "/p{n}.html" }
{ "type": "paginated", "containerPattern": "<div id=\"list\"", "nextPagePattern": "href=\"([^\"]*next[^\"]*)\"" }