Serialist Tracker

追蹤連載小說更新的 iOS App。這裡是開發人員網站,同時提供「解析器擴充」撰寫教學。

關於 App

Serialist Tracker 是一款用來追蹤連載小說更新進度的 iOS App,內建多個小說站的解析支援,並提供「解析器擴充」機制,讓使用者或第三方作者不需要等 App Store 改版,就能新增或更新對特定網站的支援。

解析器擴充範例

以下網址可貼到 App 的「解析器擴充」頁下載。內容同時也是 descriptor collection 的撰寫範例。

https://iceboxi-studio.github.io/descriptors.json

回報失效或建議新增網站

解析器擴充撰寫指南

本節說明如何撰寫「解析器擴充」——一份 JSON 檔(必要時加一段 JavaScript),描述如何解析某個小說網站。把它放上 gist / GitHub raw 之類的公開網址,使用者就能在 App 的「解析器擴充」頁貼上網址下載,不需要等 App 改版

擴充分兩層,能用宣告式就別寫腳本:

  1. 宣告式規則(JSON):CSS selector / regex,覆蓋絕大多數網站。
  2. 腳本(JavaScript):宣告式表達不了的邏輯(內文反混淆、計算式 URL、特殊分頁)才用。

一分鐘上手

一份最小可用的 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 裡貼上即可。

頂層欄位

欄位型別必填說明
idString唯一識別符,建議用 domain_tld 格式,例如 "czbooks_net"。更新時以此比對。
versionInt格式版本號。每次更新內容都要遞增,使用者「檢查更新」時才會套用。
domains[String]適用網域,以網域結尾比對。例:["example.com", "m.example.com"]
encodingString頁面編碼:"utf8"(預設)、"big5""gbk"
fetchObject抓取策略,見下方。
authenticationObject登入頁與未登入狀態辨識規則,見下方。
chapterListURLObject書籍主頁與目錄頁不同時的導航規則,見下方。
metaObject書名 / 封面提取規則。
chaptersObject章節列表提取規則。
contentObject章節內文提取規則。
scriptStringJavaScript 腳本,見「腳本層」。

⚠️ 所有欄位名稱區分大小寫。省略選填欄位時使用預設值。

meta:書名與封面

titleSourcescoverSources 都是陣列,App 依序嘗試,取第一個非空結果。每個來源用 type 區分:

type參數說明
ogMetaproperty, stripSuffix?<meta property="og:title" content="…">
nameMetaname<meta name="…" content="…">
cssSelectorselector, stripSuffix?用 CSS selector 取元素文字
regexpattern正規表示式,取第一個捕獲群組
titleTagstripSuffix?<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:章節列表

"chapters": {
  "strategy": { "type": "cssContainer", "selector": "ul.chapter-list" },
  "pathPrefix": "/n/",
  "dedupe": true
}
欄位型別說明
strategyObject章節連結提取策略,見下表。
pathPrefixString?只保留路徑以此開頭的連結(過濾非章節連結)。腳本策略不套用此欄位。
dedupeBool是否依絕對 URL 去重,預設 true

strategy 種類:

type參數說明
cssContainerselector用 CSS selector 定位容器,抓其中所有 <a>。最常用。
listContaineropeningPattern用 regex 定位容器開頭,從該處到頁尾抓連結。
sequentiallatestChapterURLMeta, urlSuffix由序列化 URL 產生列表(/p1.html/p2.html…)。
paginatedcontainerPattern, nextPagePattern目錄跨多頁時,沿著「下一頁」累積。

swiftStrategy 是 App 內建的逃生艙,下載擴充不可使用(沒有對應的 Swift 實作會直接報錯)。

content:章節內文

"content": {
  "cssSelector": "div.content",
  "stopPatterns": ["<div class=\"nav\""],
  "removePatterns": ["<script[\s\S]*?</script>", "廣告文字"]
}
欄位型別說明
cssSelectorString?用 CSS selector 定位內文容器(與 containerPattern 二選一)。
containerPatternString?用 regex 定位內文容器開頭。
stopPatterns[String]遇到任一 pattern 就在該處截斷(去掉尾端導覽等)。
removePatterns[String]從內文移除的 pattern(去廣告等)。

App 會自動移除 <script>/<style>、把 <p> 轉成段落換行、清理標籤。

進階:fetch、authentication 與 chapterListURL

fetch——網站需要 JS 渲染時:

"fetch": {
  "strategy": "webView",
  "webViewJS": "document.querySelector('.load-more')?.click();",
  "webViewWaitMs": 3000
}
欄位說明
strategy"smart"(預設,自動偵測 bot 防護並 fallback WebView)或 "webView"(強制 WebView 渲染)。
webViewJSWebView 載入後注入的 JS(選填)。
webViewWaitMsWebView 等待渲染的毫秒數(選填,預設 2500)。

authentication——部分頁面需要登入時:

"authentication": {
  "loginURL": "https://example.com/login",
  "unauthenticatedPatterns": [
    "<title>\\s*登入",
    "href=[\\\"']/login[\\\"']"
  ]
}
欄位說明
loginURLApp 顯示的登入 WebView 網址,可使用絕對或相對 URL。
unauthenticatedPatterns辨識未登入/降級頁面的 regex 陣列;所有 pattern 都命中才會要求登入。

登入完成後 App 會重試原頁面。需要共用登入 cookie 的網站,建議將 fetch.strategy 設為 "webView";cookie 由 App 的 persistent WKWebView session 保存。若 cookie 過期、頁面再次符合全部未登入 pattern,App 會重新顯示登入流程。

chapterListURL——目錄頁跟書籍主頁不同網址時:

"chapterListURL": { "strategy": "appendPath", "value": "/dir" }
strategyvalue 意義
appendPath直接在書頁 URL 後附加的路徑。
findLinkPathregex,比對書頁中連結的 href,取第一個符合者。
findLinkTextregex,比對連結文字。

腳本層(script)

當宣告式規則搞不定時(例如內文被 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 串起來(處理內文分頁)
}

可用的內建函式

沙箱裡只有這些,沒有 windowdocumentfetch、計時器:

函式說明
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 反混淆 + 分頁

假設某站把內文 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 字串。

注意事項

Host 與更新

單一網站

把一份 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 較大才會覆蓋

所以維護流程是:

  1. 改 descriptor(必要時改腳本)。
  2. version 加一(不加就不會被視為更新)。
  3. push 到同一個網址。
  4. 使用者按「檢查更新」即自動套用,全程不經 App Store 審查。

驗證與限制

匯入時 App 會擋下:

其他限制:

附錄:完整欄位速查

{
  "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[^\"]*)\"" }