香色闺阁书源配置教程大全(小白版·带原理+仿写讲解)

发布于 2026-07-01 18:18 更新于 2026-07-17 16:47 50301 字 252 min read

Aegink avatar

Aegink

忘忧的个人博客,记录技术、工具、阅读与生活。

香色闺阁书源配置教程大全(小白版·带原理+仿写讲解) 本教程融合两部分: - 参考指南《香色闺阁书源配置完全指南》的结构与规则(版本 StandarReader 2.56.1); - 从 下解密的 296 个真实书源里扒出的实战写法(4102 条 XPath、682 条 JSONPath、631 条 混用、1018 条纯 、237 条 URL 模板)。 写给谁看:完全没写过书源、甚至没接触过...

香色闺阁书源配置教程大全(小白版·带原理+仿写讲解)

本教程融合两部分:

  • 参考指南《香色闺阁书源配置完全指南》的结构与规则(版本 StandarReader 2.56.1);
  • test/shuyuan/ 下解密的 296 个真实书源里扒出的实战写法(4102 条 XPath、682 条 JSONPath、631 条 ||@js: 混用、1018 条纯 @js:、237 条 URL 模板)。

写给谁看:完全没写过书源、甚至没接触过 XPath/JSON/正则的新手。 本教程的规矩:每给一条规则或案例,当场就解释原理和怎么仿写,不把解释堆到最后。看到不懂的写法,往下一两行就有大白话说明。


目录


第 0 章 预备知识:看懂网页长什么样

小白必读。写书源本质就是”从网页里把书名、作者、正文抠出来”。要抠,先得看懂网页是什么结构。这一章用最小的例子讲清 4 样东西:HTML、XPath、JSON、正则。这四样看懂了,后面所有案例都能读。

0.1 HTML 是什么?——网页的”套娃”结构

你在浏览器看到的小说网页,背后是一堆 HTML 标签套起来的。长这样:

<div class="book">
  <h3><a href="/book/123.html">斗破苍穹</a></h3>
  <span class="author">作者:天蚕土豆</span>
</div>

逐行解释

  • <div>...</div> 是一个”盒子”,class="book" 是给盒子起的名字(类名)。
  • <h3> 里套了 <a><a> 是链接,href="/book/123.html" 是它指向的地址,标签中间的”斗破苍穹”是显示出来的文字。
  • <span class="author"> 是另一个小盒子,里面写着”作者:天蚕土豆”。

关键概念

  • 标签名divh3aspan 这些。
  • 属性:写在标签里的 class="book"href="...",格式是 属性名="值"
  • 文本:标签中间夹的文字(“斗破苍穹”、“作者:天蚕土豆”)。

你要抠的东西无非三类:某个标签的文本某个属性的值(如 href 链接)、一组重复的标签(如一页 20 本书)。怎么定位它们?用 XPath。

0.2 XPath 是什么?——网页里的”寻宝路线”

XPath 就是一句”路线指令”,告诉 App”顺着标签往下走,走到哪个元素”。还是上面那段 HTML:

我想要XPath 写法读法(大白话)
书名文字”斗破苍穹”//h3/a/text()找任意位置的 h3,它下面的 a,取文字
链接 /book/123.html//h3/a/@href同上,但取 ahref 属性
作者那段文字//span[@class="author"]找 class 叫 author 的 span
整个 book 盒子//div[@class="book"]找 class 叫 book 的 div

语法拆解(记住这几个符号就够用):

  • // = “任意位置往下找”(最常用,开头几乎都是它)。
  • / = “紧挨着的下一层”。//h3/a 意思是 h3 的直接子级 a。
  • [@属性="值"] = “带某个属性的”,如 [@class="book"][@id="content"]
  • /text() = “取标签中间的文字”。
  • /@href = “取名叫 href 的属性值”。
  • [1] [2] = “第几个”(XPath 下标从 1 开始,不是 0)。如 //td[1] 是第一个 td。

仿写方法:打开目标网页 → 右键”检查”看 HTML 结构 → 找到你要的元素带什么 class/id → 照着上面表格套。比如你看到书名在 <h1 class="title"> 里,就写 //h1[@class="title"]/text()

0.3 JSON 是什么?——接口返回的”字典”数据

有些网站不返回 HTML,而是返回 JSON(多见于手机 App 接口)。JSON 长这样:

{
  "data": {
    "book_name": "斗破苍穹",
    "author": "天蚕土豆",
    "chapters": [
      {"title": "第一章", "id": 1001},
      {"title": "第二章", "id": 1002}
    ]
  }
}

逐行解释

  • 是”字典”(一堆 键:值)。"book_name": "斗破苍穹" 就是键 book_name 对应值”斗破苍穹”。
  • [ ] 是”数组/列表”,chapters 是一个数组,里面每个 {} 是一章。

从 JSON 里取值,不用 XPath,用 JSONPath(第 7 章详讲),先感受一下:

  • $.data.book_name → 取到”斗破苍穹”($ 代表最外层,. 一层层往里点)。
  • $.data.chapters[*] → 取到章节数组里的每一项。

0.4 正则是什么?——文字里的”模糊查找替换”

正则(正则表达式)是一种”按模式找文字”的工具,书源里主要用它去广告抠片段。例子:

result.replace(/作者:/g, "")

读法:把 result 这段文字里所有”作者:“替换成空(即删掉)。

  • /.../ 两个斜杠中间是”要找的模式”。
  • 结尾的 g = global,全局,找到所有的(不加只replace第一个)。

再看抠片段:

result.match(/\((.*?)\)/)[1]

读法:在 result 里找”括号包起来的内容”,取出来。

  • \(\) 是转义的圆括号(因为 ( 在正则里有特殊含义,加 \ 表示就是普通括号字符)。
  • (.*?) 是”捕获组”——. 代表任意字符,* 代表任意多个,? 代表”尽量少拿”(非贪婪)。外面的 ( ) 表示”把这部分单独抓出来”。
  • .match(...) 返回数组,[0] 是整段匹配,[1] 是第一个捕获组的内容。

不用背全:书源里 90% 的正则就是 replace(/要删的字/g, "")match(/(要抠的)/)[1] 这两种。看到复杂的,照着改关键词即可。

有了这四样底子,下面正式开始。每个案例都会标注它用到的是 XPath 还是 JS,并就地解释。


第 1 章 一张图看懂书源怎么工作

香色闺阁抓任何东西(搜索、详情、目录、正文)都是两步走

第一步:requestInfo          第二步:解析响应
决定"请求哪个网址"    ──发请求──►   从返回的 HTML/JSON 里
(拼出 URL、参数、头)  拿到数据    抠出书名/作者/章节/正文

为什么分两步? 因为”去哪拿”和”拿到后怎么用”是两件事。第一步你告诉 App 网址,App 替你上网把内容下载回来;第二步你告诉 App 从这堆内容里挑哪些字段。

你有两套工具,可混用

  • XPath / JSONPath:定位数据在哪(第 6、7 章)。
  • JavaScript(JS):做逻辑处理,如拼 URL、去广告、解密(第 8、9 章)。

三个保留字configparamsresult 是 App 预先塞给你的,只能读、不能拿来当自己的变量名。它们分别是:config=书源自身配置,params=这次请求的上下文(关键词、页码…),result=上一步的产物。第 19 章有详解,先记住这三个名字别乱用。


第 2 章 顶层结构:一个书源文件的骨架

问题情境:一个书源文件整体长什么样?

答案:最外层是一个”别名 → 配置”的字典,里面放基本信息 + 各功能模块。

{
  "我的笔趣阁": {
    "sourceName": "笔趣阁-v0701",
    "sourceUrl": "https://www.xbiquwx.la",
    "sourceType": "text",
    "enable": 1,
    "weight": "9999",
    "miniAppVersion": "1.0.0",
    "lastModifyTime": "1772463417",
    "searchBook":     { "actionID": "searchBook", "parserID": "DOM" },
    "bookDetail":     {  },
    "chapterList":    {  },
    "chapterContent": {  }
  }
}

逐字段解释 + 怎么填

字段是什么怎么填 / 坑
"我的笔趣阁"书源别名(最外层的键)随便起,全局唯一即可,用户看不到
sourceName显示名称只写站点名,如 笔趣阁-v0701。别把公众号写这里
sourceUrl站点首页https://...,后面 JS 里 config.host 取的就是它
sourceType类型小说填 text(真实源里 76 个 text、各 1 个 audio/comic/video)
enable是否启用填数字 1(开)或 0(关)。别写成 "1" 字符串或 true
weight权重(排序用)必须是字符串 "9999"!数字越大越靠前。写成数字 9999 会让编辑器保存时闪退(见第 17 章)
miniAppVersion版本号字符串,随便填 "1.0.0"
lastModifyTime修改时间戳字符串,秒级时间戳

为什么 weight 非要加引号? 这是 2.56.1 编辑器的硬性要求——它内部把 weight 当字符串处理,你给个纯数字它解析时会崩。这类”类型敏感”的坑第 17 章集中讲。

问题情境:模块下面的 actionIDparserID 是什么?

答案

  • actionID:模块的身份标识,和模块名一致(如 searchBook 模块里 "actionID": "searchBook"),照抄即可。
  • parserID:用什么解析器。填 "DOM" 表示”按 HTML 解析”(真实源里 3026 处用 DOM),这是最常见的。如果整个模块想用纯 JS 解析,才改别的。

问题情境:想给整个书源配统一的请求头?

答案:在书源里配 httpHeaders(全局),之后任何模块的 JS 都能用 config.httpHeaders 取到。

"httpHeaders": {
  "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15",
  "Referer": "https://www.baidu.com"
}

解释

  • User-Agent(简称 UA)是”我假装成哪种浏览器/设备”。很多站点不给”非浏览器”返回数据,所以要伪装。真实源里 153 个搜索模块都配了 UA。
  • Referer 是”我从哪个页面跳来的”,有些站点校验这个防盗链。
  • 配了全局的,单个请求还能在 requestInfo 里用 httpHeaders 覆盖(就近优先)。

第 3 章 第一步 requestInfo:告诉 App 请求哪个网址

requestInfo 是所有模块都要面对的第一件事。真实数据里它是”JS 占绝对主导”的字段:817 条纯 JS、199 条 URL 模板。两种写法都讲,从最简单的开始。

3.1 一句话前提:子模块常常不用写 requestInfo

答案:详情(bookDetail)/ 目录(chapterList)/ 正文(chapterContent)的 requestInfo 多数留空——App 会自动用上一级传来的地址(比如详情页自动用搜索结果里的 detailUrl)。

证据:真实源里 chapterList.requestInfo 只有 145/291 个源写了,bookDetail.requestInfo 只有 46 个。只有当要请求的地址和上级给的不一样时,才自己写。

为什么? 想象你搜到一本书,点进去看详情——那个”详情网址”搜索结果里已经带了(就是 detailUrl)。App 很聪明,会自动拿它去请求,你不用重复写。

3.2 方式一:URL 模板(最简单,能不写 JS 就不写)

问题情境:搜索网址很简单,就是把关键词塞进去?

答案:直接写 URL,用 %@keyWord 这种占位符代表动态值。

真实案例(多个源原样):

http://www.xiaoshuoba.com/modules/article/search.php?q=%@keyWord
/search.php?searchkey=%@keyWord
/search?q=%@keyWord&page=%@pageIndex

逐个占位符解释(App 运行时自动替换):

占位符替换成例子
%@keyWord用户搜的关键词(自动 URL 编码)搜”斗破”→ q=斗破
%@pageIndex当前页码(从 1 开始)第 2 页 → page=2
%@result上一级传下来的地址/值详情页拿搜索结果的 detailUrl
%@filter分类页当前选中的过滤值选”玄幻”→ 玄幻对应的值
%@offset偏移量(另一种分页)第 2 页 → offset=20

仿写方法:打开网站搜一本书,看浏览器地址栏变成什么。比如搜”斗破”后网址是 xxx.com/search.php?key=斗破,你就把”斗破”换成 %@keyWord,写 xxx.com/search.php?key=%@keyWord。就这么简单。

注意 /search.php?searchkey=%@keyWord 开头没有域名? 没关系——App 会自动用 sourceUrl 补全 host。所以相对路径能直接写。

🔍 输入 → 规则 → 发出的请求(拿小说吧真实搜索举例,看 %@keyWord 怎么被替换成真实网址):

App 给你的输入(用户在搜索框搜了”斗破”,翻到第 1 页):

用户输入的关键词 = 斗破
当前页码 = 1

规则(requestInfo 里写的 URL 模板,原样):

http://www.xiaoshuoba.com/modules/article/search.php?q=%@keyWord

App 替换占位符后发出的真实请求%@keyWord 被换成编码后的关键词):

GET http://www.xiaoshuoba.com/modules/article/search.php?q=%E6%96%97%E7%A0%B4
                                                            └─ "斗破" 自动 URL 编码 ─┘

❌ 新手常踩的错(对着②的模板看为什么错):

错误写法会怎样正确写法
把关键词写死 ?q=斗破搜任何词都只返回”斗破”的结果会变的关键词位置写 %@keyWord
自己先 encodeURIComponent 再写进模板编码两次,变成 %25E6... 搜不到URL 模板里 %@keyWord 由 App 自动编码,别手动编
占位符拼错成 %keyWord/@keyWord认不出占位符,原样发出去搜不到严格写 %@keyWord%@ + 名字)

看懂这四段的意义:URL 模板是最省事的请求写法——你在网站搜一本书,看地址栏(对应③)→ 把关键词那段换成 %@keyWord、页码那段换成 %@pageIndex(对应②)→ App 运行时自动代入用户输入并编码(①→③)。能用模板就别写 JS,%@ 占位符 App 自动填。

问题情境:详情/目录网址要用”上一级传下来的地址”?

答案:用 %@result 占位。真实案例:

https://api.fanqiesdk.com/api/novel/book/page/data/v1/?book_id=%@result
http://app2.motie.com/pc/book/%@result/catalog
/novel/%@result/chapters

解释:搜索结果里每本书带了个 id(存在 detailUrl 里),进详情/目录时 App 把它填到 %@result 位置。比如番茄搜到 book_id=7143,请求详情就变成 ...?book_id=7143

🔍 上一步产物 → 规则 → 拼出的网址(拿番茄详情举例,看 %@result 装的是搜索给的 id):

上一级(搜索)传下来的值(搜索结果里这本书的 detailUrl 字段):

detailUrl = "7143"        ← 搜索模块取到的书 id,App 存进 detailUrl

规则(详情/目录模块 requestInfo,URL 模板里用 %@result 占位):

https://api.fanqiesdk.com/api/novel/book/page/data/v1/?book_id=%@result

App 替换 %@result 后发出的真实请求(把上一步的 detailUrl 填进去):

GET https://api.fanqiesdk.com/api/novel/book/page/data/v1/?book_id=7143
                                                                    └── %@result 换成了 7143

❌ 新手常踩的错

错误写法会怎样正确写法
详情模块又写 %@keyWord详情页要的是书 id 不是关键词,取到空详情/目录用 %@result(上一级给的地址/id)
搜索的 detailUrl 没配对%@result 是空的,详情请求残缺先保证搜索模块的 detailUrl 取到了书 id
把整条 detailUrl 又拼一遍域名地址重复、404%@result 装什么由搜索决定,这里只管填占位

看懂这四段的意义:详情/目录不是凭空请求的,它要用搜索那一步产出的地址/id(对应①的 detailUrl)→ 在 URL 模板里用 %@result 占位(②)→ App 自动把上一级的值代进去(③)。%@result = “上一步传下来的那个值”,模块之间就是这样一环扣一环传递的。

3.3 方式二:@js: 返回请求信息(复杂场景,如 POST、加密、拼参数)

问题情境:搜索是 POST 请求,要提交表单参数?

答案:用 @js: 开头写脚本,return 一个对象,POST:true + httpParams 放表单。

真实案例(抖音小说.searchBook.requestInfo,原样):

@js:
let url = "https://www.douyinxs.com/search/"
let hp = { "searchkey": params.keyWord, "Submit": "" }
return {
    "url": url,
    "POST": true,
    "httpParams": hp,
    "httpHeaders": config.httpHeaders,
    "forbidCookie": false,
    "cacheTime": 600,
}

逐行解释

  • @js: 开头 = 告诉 App”下面是 JS 脚本,不是普通网址”。
  • let url = "..." = 定义一个变量存网址。let 是 JS 声明变量的关键字。
  • let hp = = 定义表单参数。params.keyWord 就是用户搜的词。hp 是你随便起的变量名(headers/params 缩写习惯)。
  • return = 必须 return,返回的这个对象就是”请求配置”。
  • 返回对象里各键含义见下表。

返回对象支持的键(真实源里实际用到的)

作用大白话
url请求地址去哪拿
POSTtrue 走 POST有些搜索必须 POST,参数放 body 而非网址
httpParams参数对象GET 时拼成 ?a=1&b=2,POST 时当表单
httpHeaders请求头多数直接给 config.httpHeaders(用全局那份)
forbidCookietrue 禁用 Cookie有些站带 Cookie 反而被拦,就禁掉(210 段代码用到)
cacheTime缓存秒数600 = 缓存 10 分钟,减少重复请求(273 段用到)
response直接塞假数据不上网,直接造结果(见第 9 章)
webView / webViewJs走 WebView破反爬用(见第 14 章)

这些键的具体行为由阅读器 App 决定,本教程只教”真实源里怎么用”。

🔍 输入 → 规则 → 发出的请求(拿抖音小说真实搜索举例,看 @js: 到底拼出了什么请求):

App 给你的输入(用户在搜索框搜了”斗破”):

params.keyWord = "斗破"
config.httpHeaders = { "Referer": "https://www.douyinxs.com/", "User-Agent": "Mozilla/5.0 ..." }

规则(抖音小说真实源 searchBook.requestInfo):

@js:
let url = "https://www.douyinxs.com/search/"
let hp = { "searchkey": params.keyWord, "Submit": "" }
return {
    "url": url, "POST": true, "httpParams": hp,
    "httpHeaders": config.httpHeaders, "forbidCookie": false, "cacheTime": 600,
}

App 据此发出的真实请求(把 return 的对象翻译成一次网络请求):

POST https://www.douyinxs.com/search/
Referer: https://www.douyinxs.com/
User-Agent: Mozilla/5.0 ...
Content-Type: application/x-www-form-urlencoded

searchkey=斗破&Submit=

读法POST:true 决定用 POST;httpParams 里的键值对被拼成表单 body(searchkey=斗破&Submit=),不进网址httpHeaders 变成请求头。发出去后,服务器返回的搜索结果 HTML 就进入下一步(第 4 章解析)。

❌ 新手常踩的错

错误写法会怎样正确写法
忘了 return,直接 let url=...App 拿不到请求信息,搜索空白@js: 脚本必须 return 一个对象
searchkey 拼进 url(/search/?searchkey=斗破)还写 POST:true服务器在 body 里找不到参数,返回空POST 的参数放 httpParams,不放 url
"searchkey": keyWord(漏了 params.keyWord 未定义,脚本报错用户输入只能通过 params.keyWord

问题情境:GET 接口参数一大串,要拼页码和分类?

答案:用模板串(反引号 `)拼 params.pageIndex 等。真实案例(息壤中文网,简化):

@js:
let url = config.host + "/api/getTypeNovel?second_type=" + params.filters.type + "&page=" + params.pageIndex
return { 'url': url, 'POST': false, "httpHeaders": config.httpHeaders, forbidCookie: true, cacheTime: 3600 };

解释

  • config.host = 站点首页(你填的 sourceUrl),用它开头保证域名对。
  • + = 字符串拼接,把网址一段段接起来。
  • params.filters.type = 用户在分类页选的分类值(第 12 章详讲)。
  • params.pageIndex = 当前页码,翻页时自动变。

仿写方法:看接口网址长啥样,把里面会变的部分(关键词、页码、分类)换成 params.xxx,用 + 拼回去。

🔍 输入 → 规则 → 发出的请求(拿息壤中文网分类页举例,看 + 拼接怎么把分类值和页码拼进接口 URL):

App 给你的输入(用户在分类页选了”都市”类,翻到第 3 页):

config.host = "https://www.xrzww.com"
params.filters.type = "4"     ← "都市"这个分类对应的值
params.pageIndex = 3

规则(息壤中文网真实源 bookWorld.requestInfo):

@js:
let url = config.host + "/api/getTypeNovel?second_type=" + params.filters.type + "&page=" + params.pageIndex
return { 'url': url, 'POST': false, "httpHeaders": config.httpHeaders, forbidCookie: true, cacheTime: 3600 };

App 据此发出的真实请求(三段变量各就各位拼成完整 URL):

GET https://www.xrzww.com/api/getTypeNovel?second_type=4&page=3
        └── config.host ──┘                        └type┘     └pageIndex┘

❌ 新手常踩的错(对着①的变量看为什么错):

错误写法会怎样正确写法
URL 里写死 second_type=4用户选别的分类还是查”都市”,分类失效会变的位置换成 params.filters.type
"...&page=" + params.pageIndex 漏了 +字符串拼接断开、语法报错每段之间都要用 + 连起来
config.host 时首页地址末尾多带了 /拼出 .com//api 双斜杠,部分服务器 404config.host 末尾别留 /,或拼时注意

看懂这四段的意义:GET 接口的地址就是”域名 + 固定路径 + 会变的参数”(对应②)→ 把关键词/页码/分类这些会变的位置换成 params.xxx,其余用 + 拼死(对应①的三个变量)→ App 代入当前值拼出真实 URL(③)。先看接口网址哪几段会变,那几段就是要换成 params 的地方。

问题情境:子模块要改一下上级给的地址?

答案:requestInfo 里对 result(上级传来的地址)做处理。真实案例(大灰狼听书.chapterList.requestInfo):

@js:
return result.replace('/detail?', '/catalog?')

解释result 这里是上级详情页地址。.replace('/detail?','/catalog?') 把地址里的 /detail? 换成 /catalog?(详情接口改成目录接口)。这是最省事的写法——不用重拼,改一个词就行。

🔍 上级地址 → 规则 → 改后地址(拿大灰狼听书举例,看 result 怎么被一句 replace 改成目录接口):

上级(详情模块)传下来的 result(就是搜索/详情阶段拿到的 detailUrl,指向详情接口):

https://api.dhlts.com/api/book/detail?book_id=7143

规则大灰狼听书.chapterList.requestInfo,只改一个词):

@js:
return result.replace('/detail?', '/catalog?')

App 据此发出的目录请求/detail? 被换成 /catalog?,其余原样保留):

https://api.dhlts.com/api/book/catalog?book_id=7143
                              └ detail 变成 catalog ┘

❌ 新手常踩的错(对着①的地址看为什么错):

错误写法会怎样正确写法
重新手拼整条目录 URLbook_id 得自己再取一遍,麻烦又易错详情/目录接口只差一个词时,result.replace 改词最省事
忘了 return目录模块拿不到请求地址,章节列表空白@js: 处理完 result 一定要 return 出去
replace('detail','catalog') 少了 /?若地址别处也含 “detail” 字样会误替换带上 / ? 把要替换的片段写具体,避免误伤

看懂这四段的意义:子模块(目录/正文)拿到的 result 往往就是上一级的地址,只需小改(换个词、加个后缀)就能变成本模块要请求的地址(对应②)→ 用 result.replace(旧, 新) 改到位(③)→ 比从头拼整条 URL 省事得多。上下级接口地址长得像时,改词优先于重拼。


第 4 章 第二步 解析响应:从返回内容里抠出字段

拿到 App 下载回来的内容后,你要从里面抠出书名、作者等。有两种写法。

4.1 写法一:规则字符串(XPath / JSONPath / JS 混用)

答案:一个字段的值,可以是下面五种形态之一,App 看开头自动判断:

形态怎么认真实例子用在
纯 XPath//( 开头//h4[@class="bookname"]/a/text()HTML 站
JSONPath$ 开头$.data.chapter_lists接口站
混用选择器 + ||@js: + 脚本//div[3]||@js:return result.replace('【展开】','')取到值再加工
纯 JS@js: 开头@js:return params.keyWord;纯逻辑
URL 模板%@keyWord/search?q=%@keyWordrequestInfo

问题情境:混用时 XPath 和 JS 谁先谁后?

答案选择器在前,||@js: 在后。 选择器先取到值,作为 result 传进 JS,JS 里 return 的是最终结果。

真实案例(101言情小说.searchBook.author):

//div[@class="author"]|@js: return result.replace('作者:','');

拆开看

  1. //div[@class="author"] —— XPath 先取到节点文字,比如”作者:张三”。
  2. |@js: —— 分隔符,左边结果进入右边 JS 的 result 变量。
  3. return result.replace('作者:','') —— 把”作者:“删掉,返回”张三”。

注:标准分隔符是 ||@js:(两竖线),但真实源里也有 122 条用单竖线 |@js: 的老写法,两种 App 都认。

🔍 原响应 → 规则 → 解析后(拿 101言情小说 真实作者字段举例,看”先取后改”的顺序):

XPath //div[@class="author"] 先取到的原始值(带着”作者:“前缀):

作者:张三

规则101言情小说 真实源 searchBook.author,选择器在前、JS 在后):

阶段这一段干了什么
选择器(|| 左边)//div[@class="author"]先取到”作者:张三”,塞进 result
JS(||@js: 右边)return result.replace('作者:','')result 加工,删掉”作者:“前缀

App 解析后得到(JS return 的才是最终值):

{ "author": "张三" }

❌ 新手常踩的错(对着②的先后顺序看为什么错):

错误写法会怎样正确写法
把 JS 写前面:@js:...||//div[...]顺序反了,result 是空的,取不到值永远选择器在前、||@js: 在后
JS 里忘了 return字段拿到 undefined,作者空白JS 最后一定 return 出加工结果
以为 JS 能重新去页面取值JS 里只有上一步的 result,没有整页 HTML要换取别的节点得改前面的选择器,不是在 JS 里找

看懂这四段的意义:混用的铁律是”选择器负责取、JS 负责改”,顺序不能反(对应②)→ 选择器取到的值变成 JS 里的 result(①)→ JS return 的才是 App 最终拿到的(③)。记死这个数据流向:result 永远来自左边选择器,右边 JS 只能加工它。

4.2 写法二:完整 JS 函数

答案:解析方式选”普通字符串”时,写一个必须叫 functionName 的函数:

function functionName(config, params, result) {
    // 处理 result(这里是服务器返回的完整 HTML/JSON)
    return 解析结果;
}

解释:这三个参数 App 自动传给你——result 就是整页返回内容。名字必须functionName,改了 App 找不到。具体用法第 16 章有整站示例。

4.3 各模块必须返回什么(新手最容易搞错)

答案:不同模块要求返回不同结构:

模块必须返回例子
搜索 / 分类list 的对象,每项有 bookName/author/detailUrl{list:[{bookName:"..",detailUrl:".."}]}
详情书信息对象,或 {response: 书信息}{response:{desc:"..",cover:".."}}
目录list 的对象,每项有 title/url{list:[{title:"第一章",url:".."}]}
正文content 字符串,或 {response: 正文}{response:"正文文字..."}

为什么强调这个? 小白最常见的错就是”搜索模块没返回 list”或”正文返回了 list”,导致 App 不认。记住:列表类必带 list,正文类给 content/response

🔍 返回结构 → 对错对照 → App 表现(拿一个用 functionName 写的搜索模块举例,看返回结构错在哪):

模块拿到的输入(用户搜”斗破”,functionName 里已经从 HTML 抠出两本书的字段):

准备好的数据:
  第1本 = { bookName: "斗破苍穹", author: "天蚕土豆", detailUrl: "/book/6909/" }
  第2本 = { bookName: "斗破之无上之境", author: "尘缘", detailUrl: "/book/7021/" }

规则(搜索模块必须把每本书装进 list 数组再返回):

function functionName(config, params, result) {
  let list = [];
  list.push({ bookName: "斗破苍穹", author: "天蚕土豆", detailUrl: "/book/6909/" });
  list.push({ bookName: "斗破之无上之境", author: "尘缘", detailUrl: "/book/7021/" });
  return { "list": list };   // ← 搜索/目录/分类:必须是 {list:[...]}
}

App 解析后得到(结构对了,搜索页正常出两本书):

{ "list": [
  { "bookName": "斗破苍穹", "author": "天蚕土豆", "detailUrl": "/book/6909/" },
  { "bookName": "斗破之无上之境", "author": "尘缘", "detailUrl": "/book/7021/" }
]}

❌ 新手常踩的错(对着各模块该返回什么看为什么错):

错误写法会怎样正确写法
搜索模块 return list(直接返回数组,不裹 {list:...}App 在返回值里找不到 list 键,搜索页空白搜索/目录/分类一律 return {list:[...]}
正文模块 return {list:[正文]}正文类不认 list,读不出正文正文返回 content 字符串或 {response:正文}
目录每项只给 title 漏了 url章节点进去没地址,打不开正文目录每项必须同时有 titleurl
详情返回 {list:[书信息]}详情不是列表,套 list 反而认不出详情直接返回字段对象或 {response:书信息}

看懂这四段的意义:每个模块 App 要的”收货格式”是固定的——搜索/目录/分类要 {list:[...]}(对应②③),正文要 content/response,详情要字段对象。先想清楚”这个模块该交出什么结构”,再往里装数据,别把列表和正文的结构搞混。

4.4 章节列表字段的强制写法(真实踩坑心得)

答案:即使 list 已经定位到 <a> 标签了,title/url 仍要写全,并且// 开头

title: //text()
url:   //@href

为什么?./text()(点开头)会污染整行文本,取到一堆不该要的东西。真实源里章节标题几乎都是 //a/text()//a,链接都是 //a/@href记死:章节字段一律 // 开头。

🔍 原响应 → 规则 → 解析后(拿抖音小说真实目录页举例,看章节列表怎么从 HTML 变成 list):

服务器返回的目录页 HTML(每章一个 <dd>,删了无关属性):

<div id="list">
  <dl>
    <dd><a href="/book/12345/1.html">第一章 陨落的天才</a></dd>
    <dd><a href="/book/12345/2.html">第二章 斗气大陆</a></dd>
    <dd><a href="/book/12345/3.html">第三章 罗汉果</a></dd>
  </dl>
</div>

规则(抖音小说真实源里的写法):

字段规则在啃 HTML 的哪一块
list//div[@id="list"]//dd每个 <dd> = 一章(重复单元)
title//a单元内 <a> 的文字 = 章节名
url//a/@href同一个 <a>href = 章节地址

App 解析后得到(每个 <dd> 一条,目录有几章就几条):

{ "list": [
  { "title": "第一章 陨落的天才", "url": "/book/12345/1.html" },
  { "title": "第二章 斗气大陆",   "url": "/book/12345/2.html" },
  { "title": "第三章 罗汉果",     "url": "/book/12345/3.html" }
]}

❌ 新手常踩的错

错误写法后果正确写法
title: ./text()(点开头)污染整行、取到多余文字title: //a// 开头)
只写了 list,没写 title/urlApp 拿不到章节名和地址,目录空白list/title/url 三个都写全
目录模块返回了 content结构不对,App 不认目录必须返回含 list 的对象

看懂这三段的意义:目录页说白了就是”一堆 <dd><a>”,list 圈住每个 <dd>title/url 在单元内取 <a> 的文字和 href。以后遇到任何目录页,F12 看章节外层是什么标签(dd/li/p),写进 list,剩下照套。


第 5 章 五大功能模块

一个完整书源通常配这几个模块。它们的关系是一条链:搜索 → 详情 → 目录 → 正文,再加一个独立的分类

模块干什么关键字段真实覆盖
searchBook搜书list bookName author detailUrl cover desc291 源
bookDetail书籍详情bookName author cover desc status cat291 源
chapterList章节目录list title url291 源
chapterContent正文content291 源
bookWorld分类/排行requestFilters + list + 字段291 源

这条链怎么走通(理解了这个才知道每个模块干嘛):

  1. 用户搜”斗破” → searchBook 返回一列书,每本带 detailUrl
  2. 用户点某本 → App 拿 detailUrl 去请求 → bookDetail 补全简介/封面。
  3. 用户点”目录” → chapterList 返回章节列表,每章带 url
  4. 用户点某章 → App 拿 url 请求 → chapterContent 返回正文。
  5. bookWorld 是独立的——用户逛分类/排行榜时走它,内部可含多个子分类。

字段是继承的:搜索时拿到的书名、作者,进详情时通过 params.queryInfo 还能取到(第 13/19 章)。所以详情模块常常只需补搜索时没有的字段(简介、封面)。


第 6 章 XPath 定位大全(配真实案例,逐条解释)

XPath 是抓 HTML 站点的主力。本批 296 个源里,纯 XPath 规则有 4102 条,是所有写法里最多的。这一章把最常用的定位套路一条条讲清楚,每条都配真实源里的写法。

回顾第 0 章:XPath 就是「网页元素的路径」。// 表示”任意层级往下找”,[@class="x"] 表示”筛选 class 是 x 的”,/text() 取文字,/@href 取链接属性。

6.1 最基础:按标签层级往下走

真实案例小说吧 搜索列表):

list:      //div[@id="jieqi_page_contents"]/div
bookName:  //div[2]/div[1]/span/a[1]
author:    //div[2]/div[2]/span[2]
detailUrl: //div[2]/div[1]/a/@href

逐条解释

  • list 先圈定”每本书的外框”——id="jieqi_page_contents" 这个大容器下的每个 <div> 就是一本书。list 是列表的”重复单元”,后面的 bookNameauthor 都是在单元内部再往下找。
  • //div[2]/div[1]/span/a[1]:在单元里,第 2 个 div → 第 1 个 div → span → 第 1 个 a。[2][1]序号(从 1 数),用来在多个同名标签里挑第几个。
  • /@href:取 <a> 标签的 href 属性(也就是链接地址)。

怎么仿写:先在浏览器里右键”检查”,看清列表每本书外层是什么标签、有没有 id/class,先写 list;再看书名、作者分别在单元内哪个位置,一层层写下来。

🔍 原响应 → 规则 → 解析后(拿小说吧真实搜索页举例,这站结构没 class 可用,只能数序号):

服务器返回的 HTMLid="jieqi_page_contents" 容器里,每本书一个 <div>,内部全靠位置区分):

<div id="jieqi_page_contents">
  <div>
    <div><a href="/book/9527/"><img src="/cover/9527.jpg"></a></div>
    <div>
      <span><a>斗破苍穹</a></span>
      <span>作者</span><span>天蚕土豆</span>
      <span>字数</span><span>530万字</span>
      <span>状态</span><span>已完结</span>
    </div>
  </div>
  <div> …第二本书… </div>
</div>

规则(小说吧真实源里的写法,全是序号定位):

字段规则在啃 HTML 的哪一块
list//div[@id="jieqi_page_contents"]/div容器下每个直接子 <div> = 一本书
bookName//div[2]/div[1]/span/a[1]单元内第2个div → 第1个div → span → 第1个a
author//div[2]/div[2]/span[2]第2个div里第2个span(“作者”后那格)
wordCount//div[2]/div[2]/span[6]同一串span里第6个(字数值)
detailUrl//div[2]/div[1]/a/@href单元内那个 <a> 的 href

App 解析后得到

{ "list": [
  { "bookName": "斗破苍穹", "author": "天蚕土豆", "wordCount": "530万字", "detailUrl": "/book/9527/" },
  { "…第二本书…" }
]}

❌ 新手常踩的错

错误写法结果正确写法
author: //span[2]从整页第2个span取,跨出了单元,取到别家的//div[2]/div[2]/span[2](先进单元再数)
list: //div(不加容器限定)把页头页尾的div也当成书,混入垃圾条目//div[@id="jieqi_page_contents"]/div(限定容器)
序号从0数://div[1] 想取第2个取错格,XPath序号从1开始第2个就写 [2]

为什么这站只能数序号:它的 span 全是光秃秃的 <span>,没有 class 能抓。这种情况下位置就是唯一线索——先用 list 圈死单元,再在单元内数第几个。能有 class 就别数序号(见6.2),数序号是没办法时的办法。

6.2 按 class / id 精确定位(比数序号稳)

真实案例精华书阁 搜索):

list:      //div[@class="searchbook"]
bookName:  //h4[@class="bookname"]/a/text()
detailUrl: //h4[@class="bookname"]/a/@href
cover:     //a/img/@src

解释:用 [@class="bookname"] 直接锁定”类名是 bookname 的 h4”,比 //div[2]/div[3] 这种数序号的写法稳得多——因为网页改版时序号容易变,但 class 名一般不变。 怎么仿写:优先找元素有没有独特的 classid,有就用它定位。/text() 取显示的文字,/@src 取图片地址。

🔍 原响应 → 规则 → 解析后(拿精华书阁真实搜索页举例,看规则到底在啃什么):

服务器返回的 HTML(搜”斗破”,页面里每本书长这样,删了无关属性):

<div class="searchbook">
  <div class="bookimg"><a href="/book/斗破苍穹/"><img src="https://m.jhssd.com/cover/1.jpg"></a></div>
  <h4 class="bookname"><a href="/book/斗破苍穹/">斗破苍穹</a></h4>
  <div class="author">天蚕土豆</div>
  <div class="cat">玄幻</div>
  <div class="update"><a href="/book/斗破苍穹/last.html">第1648章 大结局</a></div>
</div>
<div class="searchbook"> …第二本书… </div>

规则(精华书阁真实源里的写法):

字段规则在啃 HTML 的哪一块
list//div[contains(@class,"searchbook")]每个 <div class="searchbook"> 整块 = 一本书
bookName//h4[@class="bookname"]//a单元内 bookname 里那个 <a> 的文字
detailUrl//h4[@class="bookname"]//a/@href同一个 <a>href
cover//div[@class="bookimg"]//img/@srcbookimg<img>src
author//div[@class="author"]author 这个 div 的文字

App 解析后得到(一本书一条,list 有几个 searchbook 就有几条):

{ "list": [
  { "bookName": "斗破苍穹", "detailUrl": "/book/斗破苍穹/", "cover": "https://m.jhssd.com/cover/1.jpg", "author": "天蚕土豆", "cat": "玄幻" },
  { "…第二本书…" }
]}

❌ 新手常踩的错(对着①的 HTML 看为什么错):

错误写法会发生什么正确写法
list: //div[@class="searchbook"]若真实 class 是 searchbook clearfix(带多个类),精确等号匹配不到,列表为空//div[contains(@class,"searchbook")] 用 contains 更稳
bookName: //h4/a/text() 但 list 已定位到单元字段规则里又从 // 顶层找,会跨出当前单元、串到别的书字段在单元内仍写 //h4[@class="bookname"]//a,App 会限定在单元内找
cover: //img/@src页面里有多个 <img>(图标、广告),取到第一个不一定是封面带上父级 //div[@class="bookimg"]//img/@src 锁定

看懂这四段的意义:以后你打开任意搜索页 → F12 看 HTML 长啥样(对应①)→ 找每本书的外框 class 写 list、找书名/作者所在标签写字段规则(对应②)→ 心里就能预判 App 会拆成什么(对应③)→ 避开④里那些坑。规则不是背的,是照着①的结构套出来的。

6.3 取属性:@href / @src / @content / @alt

真实案例♡⃝.4小说 详情页,用 og 元数据):

cover:            //*[@*="og:image"]/@content
status:           //*[@*="og:novel:status"]/@content
lastChapterTitle: //*[@*="og:novel:latest_chapter_name"]/@content
cat:              //*[@*="og:novel:category"]/@content

解释

  • //* 里的 * 是通配符,表示”任意标签”。[@*="og:image"] 表示”任意属性的值等于 og
    ”。合起来:“页面里任何一个标签,只要它有个属性值是 og
    ,就选中,然后取它的 content 属性”。
  • 为什么用这招?很多小说站在网页 <head> 里放了一堆 <meta property="og:xxx" content="值"> 的元数据(给搜索引擎和分享用的),这些数据最干净、最稳定,比从正文里抠强。 怎么仿写:打开详情页源码,搜 og:,如果有 og:imageog:novel:author 这类,直接照上面套,把字段一一对应。

🔍 原响应 → 规则 → 解析后(拿 ♡⃝.4小说 详情页举例,看 og 元数据怎么取):

服务器返回的 HTML(详情页 <head> 里的 meta 标签,删了无关行):

<head>
  <meta property="og:image" content="http://m.4xiaoshuo.info/cover/9527.jpg">
  <meta property="og:novel:status" content="连载中">
  <meta property="og:novel:latest_chapter_name" content="第2048章 终局">
  <meta property="og:novel:category" content="都市">
  <meta property="og:description" content="简介:这是一个普通人的逆袭故事……">
</head>

规则♡⃝.4小说 真实源里的写法):

字段规则在啃 HTML 的哪一块
cover//*[@*="og:image"]/@content属性值是 og
那个 meta 的 content
status//*[@*="og:novel:status"]/@contentog:novel
的 content
lastChapterTitle//*[@*="og:novel:latest_chapter_name"]/@content最新章 meta 的 content
cat//*[@*="og:novel:category"]/@content分类 meta 的 content

App 解析后得到(详情是单本书信息,不是 list):

{ "cover": "http://m.4xiaoshuo.info/cover/9527.jpg", "status": "连载中", "lastChapterTitle": "第2048章 终局", "cat": "都市" }

❌ 新手常踩的错

错误写法会怎样正确写法
//meta[@property="og:image"]/@content也能用,但只认 property 这一个属性名;有的站写成 name="og:image" 就取不到//*[@*="og:image"]/@content@* 兼容任意属性名,更稳
忘了 /@content,只写到 //*[@*="og:image"]取到整个 <meta> 标签、不是那串网址末尾一定加 /@content 取属性值

看懂这三段的意义:详情页最稳的数据都藏在 <head>og: meta 里(对应①)。你 F12 搜一下 og:,有几条就照②配几个字段,@*="og:xxx" 照抄、末尾 /@content——比从正文里一层层抠强得多。

6.4 用 contains / text() 模糊匹配

真实案例(下一页链接):

//a[contains(text(),"下一页")]/@href       (找文字含"下一页"的 a,取链接)
//*[contains(text(),"下一页")]/@href        (任意标签,文字含"下一页")
//a[text()="下一页"]/@href                   (文字正好是"下一页")

解释

  • contains(text(),"下一页"):筛选”文字内容包含’下一页‘“的元素。适合”翻页按钮”这种——你不知道它在第几个位置,但知道它上面写着”下一页”。
  • text()="下一页":要求文字完全等于”下一页”,更严格。 怎么仿写:当元素没有好用的 class、但有固定文字时,用 contains(text(),"关键字") 按文字找。

🔍 原响应 → 规则 → 解析后(拿一个真实目录页的翻页按钮举例,看 contains 怎么在一堆链接里挑出”下一页”):

服务器返回的 HTML(目录页底部的翻页区,一排链接文字各不相同):

<div class="pagelink">
  <a href="/book/9527/1.html">上一页</a>
  <a href="/book/9527/index.html">目录</a>
  <a href="/book/9527/3.html">下一页</a>
  <a href="/book/9527/99.html">尾页</a>
</div>

规则(三种写法,都想取”下一页”那个 <a> 的 href):

规则在啃 HTML 的哪一块结果
//a[contains(text(),"下一页")]/@href文字”下一页”的那个 <a>/book/9527/3.html
//a[text()="下一页"]/@href文字正好等于”下一页”的 <a>/book/9527/3.html
//a[3]/@href靠数序号数到第 3 个 <a>这页对,但站点改版加个链接就错位 ❌

App 解析后得到(拿到下一页地址,交给翻页逻辑,见第 11 章):

{ "nextPageUrl": "/book/9527/3.html" }

❌ 新手常踩的错(对着①的 HTML 看为什么错):

错误写法会发生什么正确写法
//a[3]/@href 数序号”上一页/目录/下一页”顺序一变、或多个广告链接插进来,第 3 个就不是”下一页”了认准文字 //a[contains(text(),"下一页")]/@href
//a[text()="下一页 "](文字里多打了空格)完全匹配要求一字不差,多个空格就匹配不到,返回空拿不准就用 contains 而非 text()=,更宽容
contains(text(),"页") 关键字太短”上一页/尾页”文字里都含”页”,会一次选中好几个关键字写全 "下一页",避免误伤

看懂这四段的意义:翻页按钮在页面里位置不固定、但文字是固定的(对应①)→ 与其数序号,不如认文字 contains(text(),"下一页")(对应②)→ 稳稳取到那一条 href(③)→ 避开④里”数序号错位""空格匹配不上”这些坑。位置会变,文字不变——这就是按文字定位比按序号稳的原因。

6.5 多个选择器取并集:|

真实案例笔趣看 章节,兼容两种页面结构):

(//a/@onclick | //a/@href) || @js: ...

解释:XPath 里单个 | 是””(取并集)——同时取 <a>onclickhref 两种来源。有的页面链接在 href、有的藏在 onclick,用 | 一次都捞上来,再用后面的 JS 挑出有用的。

⚠️ 注意区分:XPath 里一个 | 是”取并集”;书源规则里两个 || 才是”分隔 XPath 和 JS”。别搞混。

🔍 原响应 → 规则 → 解析后(同一个站,有的书链接在 href、有的藏在 onclick,用 | 一网打尽):

服务器返回的 HTML(搜索结果里两本书,链接写法不一致):

<a class="bk" href="/book/1001/">斗破苍穹</a>
<a class="bk" onclick="go('/book/1002/')">武动乾坤</a>

规则笔趣看 真实源思路:两种来源并集,再用 JS 收尾):

片段作用
//a[@class="bk"]/@href只取到第一本的 /book/1001/,第二本没有 href → 漏
//a[@class="bk"]/@onclick只取到第二本的 go('/book/1002/'),第一本没有 onclick → 漏
(//a[@class="bk"]/@href | //a[@class="bk"]/@onclick)两种来源并集,两本都捞上来 ✅

并集取到的原始值(注意 onclick 那条还带着 go('...') 外壳,要靠后面 ||@js: 抠出来):

["/book/1001/", "go('/book/1002/')"]

❌ 新手常踩的错

错误写法会发生什么正确写法
只写 //a/@href用 onclick 的那半数书全丢,列表缺一半(… /@href | … /@onclick) 并集兜住两种
写成 //a/@href || //a/@onclick两个竖线 || 是”XPath 和 JS 的分隔符”,App 会把后半当 JS 报错并集是一个竖线 |,别写成两个
并集后不清洗直接用go('/book/1002/') 带外壳,不是干净地址后接 ||@js:match/replace 抠出括号里的地址

看懂这四段的意义:同一个站不同书的链接来源可能不统一(对应①)→ 用一个 | 把 href、onclick 两种来源取并集,一次兜住(②)→ 得到混合的原始值(③)→ 再用 JS 统一清洗。|(并集)解决”来源不止一处”,||(分隔)解决”取完还要改”,一个竖线和两个竖线是两回事。

6.6 用轴选取:following-sibling(选”后面的兄弟”)

真实案例

//h2/following-sibling::div

解释following-sibling::div 表示”选 h2 后面、和它同级的所有 div”。用在”标题 <h2> 后面跟着一堆内容 div”这种结构里。这是进阶写法,不常用,看到能认识即可。

🔍 原响应 → 规则 → 解析后(分类页用 <h2> 当小标题,标题下面平铺着一排书,没有外层容器把它们框起来):

服务器返回的 HTML(书和标题是”平级兄弟”,不是父子嵌套):

<h2>热门推荐</h2>
<div class="bk"><a href="/b/1/">斗破苍穹</a></div>
<div class="bk"><a href="/b/2/">武动乾坤</a></div>
<h2>最新上架</h2>
<div class="bk"><a href="/b/9/">大主宰</a></div>

规则(用轴锁定”热门推荐”这个 h2 后面的兄弟 div):

字段规则在啃 HTML 的哪一块
list//h2[text()="热门推荐"]/following-sibling::div[@class="bk"]”热门推荐”这个 h2 之后、同级的所有 bk div
bookName//a(单元内)每个 bk div 里 <a> 的文字
detailUrl//a/@href同一个 <a> 的 href

App 解析后得到(只圈到”热门推荐”名下那两本,“最新上架”名下的不会混进来):

{ "list": [
  { "bookName": "斗破苍穹", "detailUrl": "/b/1/" },
  { "bookName": "武动乾坤", "detailUrl": "/b/2/" }
]}

❌ 新手常踩的错

错误写法会发生什么正确写法
list: //div[@class="bk"]页面所有栏目的书全被圈进来,“热门/最新”混成一锅following-sibling 从指定 h2 起圈,分栏才干净
following-sibling::div(不加过滤)把 h2 后面所有 div 都算上,可能带进广告 div[@class="bk"] 只要书那种 div
以为它选的是”h2 里面的 div”兄弟轴选的是同级后面的,不是子级子级用 /,同级往后用 following-sibling::

看懂这四段的意义:当书和它的栏目标题是”平级兄弟”、没有共同外层容器时(对应①)→ 普通 //div 会把各栏目搅在一起 → 用 following-sibling:: 从某个标题起、只圈它后面的兄弟(②),就能按栏目分开(③)。嵌套结构用 / 往里钻,平铺结构用 following-sibling:: 往后数——这是它唯一的用武之地。

6.7 去掉前缀:内置 ||@replace:(不用写 JS)

真实案例精华书阁):

//div[@class="author"]/text()  ||@replace:作者:
//div[@class="cat"]/text()     ||@replace:分类:

解释:取到的文字是”作者:张三”,但你只想要”张三”。||@replace:作者: 是 App 内置的替换指令,把”作者:“删掉。这是不写 JS 的最简单清洗法——||@replace: 后面跟你要删的文字即可。 怎么仿写:取到的值带固定前缀(作者:、分类:、更新时间:),直接在 XPath 后面加 ||@replace:那段前缀

🔍 原响应 → 规则 → 解析后(精华书阁详情页,看 ||@replace 到底删了什么):

服务器返回的 HTML(详情页里作者、分类这两块):

<div class="author">作者:天蚕土豆</div>
<div class="cat">分类:玄幻</div>

规则

字段规则做了什么
author//div[@class="author"]/text() ||@replace:作者:先取到”作者:天蚕土豆”,再删掉”作者:“前缀
cat//div[@class="cat"]/text() ||@replace:分类:先取到”分类:玄幻”,再删掉”分类:“前缀

App 解析后得到(前缀已被 ||@replace 吃掉):

{ "author": "天蚕土豆", "cat": "玄幻" }

❌ 新手常踩的错

错误写法会怎样正确写法
只写 //div[@class="author"]/text()结果带前缀”作者:天蚕土豆”,界面难看||@replace:作者:
||@replace:作者(漏掉冒号)只删”作者”两字,剩”:天蚕土豆”前缀带的标点也要一起写进去
||@js:return result.replace(...) 去删这种固定前缀能work但杀鸡用牛刀固定前缀优先用 ||@replace,不必写 JS

第 7 章 JSONPath:接口站怎么取字段

有些书源不是抓网页,而是直接调接口(API),返回的是 JSON(第 0 章讲过 JSON 长什么样)。这时候不能用 XPath,要用 JSONPath。本批源里 JSONPath 规则有 682 条

回顾:JSON 就是 {"key":"值"} 的嵌套结构。JSONPath 用 $ 代表最外层,. 一层层往里点。

7.1 最基础:一层层点进去

真实案例(大灰狼听书 / 七猫接口):

cover:            $.data.thumb_url
wordCount:        $.data.word_number
lastChapterTitle: $.data.book.latest_chapter_title
author:           $.original_author

解释:假设接口返回 {"data":{"thumb_url":"http://.../a.jpg","book":{"latest_chapter_title":"第10章"}}}

  • $.data.thumb_url = 从最外层 → data → thumb_url,取到封面地址。
  • $.data.book.latest_chapter_title = 再深一层,取到最新章标题。 怎么仿写:先看接口返回的 JSON(浏览器 F12 → Network → 点接口 → Response),照着层级用 $. 点下去,点到你要的那个值。

🔍 原响应 → 规则 → 解析后(拿七猫详情接口举例,看 $. 怎么一层层点进嵌套 JSON):

服务器返回的 JSON(点开一本书的详情接口,删了无关字段):

{
  "data": {
    "book": {
      "title": "斗破苍穹",
      "author": "天蚕土豆",
      "category2_name": "东方玄幻",
      "latest_chapter_title": "第1648章 大结局",
      "words_num": "530万字"
    }
  }
}

想要的值埋在 databook 两层里面,得一层层点进去。

规则(七猫真实源 bookDetail 里的写法):

字段规则在 JSON 里怎么点
cat$.data.book.category2_name最外层 → databookcategory2_name
lastChapterTitle$.data.book.latest_chapter_title同样点到 book,再取 latest_chapter_title

App 解析后得到

{ "cat": "东方玄幻", "lastChapterTitle": "第1648章 大结局" }

❌ 新手常踩的错(对着①的 JSON 看为什么错):

错误写法会发生什么正确写法
$.data.category2_name 少点一层跳过了 book 这层,data 下直接没有 category2_name,取空一层不落 $.data.book.category2_name
用 XPath //book/category2_name这是 JSON 接口,XPath 全取不到JSON 一律 $ 开头、用 . 一层层点
$data.book.xxx(漏了 $ 后的点)语法不对,解析失败$ 后面每下一层都用 . 连接

看懂这四段的意义:以后点开任意详情接口 → 看要的值藏在哪几层(对应①)→ 从 $ 开始照层级用 . 点到底(对应②)→ 心里就能预判 App 取到啥(③)→ 避开④那几个坑。JSON 嵌套再深,也就是顺着 key 一层层点下去而已。

7.2 取数组:[*] 全部 / [0] 第一个 / [-1] 倒数第一

真实案例

list:  $.data[*]                   (data 数组的所有元素)
list:  $.data.chapter_lists        (章节数组)
       $..book_data[0]             (深度找 book_data,取第一个)
       $.data.list[-1].updatedAt   (list 最后一个元素的 updatedAt)

解释

  • [*] = 数组里每一个元素(列表 list 最常用它,一个元素就是一本书/一章)。
  • [0] = 第一个,[-1] = 倒数第一个。取”最新更新时间”常用 [-1](最后一章的时间)。 怎么仿写:列表字段 list 指向那个”装着一堆书/章的数组”,末尾加 [*]

🔍 原响应 → 规则 → 解析后(拿大灰狼听书真实搜索接口举例,看 JSONPath 到底在接口 JSON 里点哪):

服务器返回的 JSON(搜”斗破”,接口返回,删了无关字段):

{
  "data": [
    { "book_id": "7143", "book_name": "斗破苍穹", "author": "天蚕土豆",
      "thumb_url": "https://v10.czyl.cf/cover/1.jpg", "word_number": "530万",
      "status": "完结", "tags": "玄幻", "source": "咪咕" },
    { "book_id": "8021", "book_name": "武动乾坤", "author": "天蚕土豆",
      "thumb_url": "https://v10.czyl.cf/cover/2.jpg", "word_number": "410万",
      "status": "完结", "tags": "玄幻", "source": "咪咕" }
  ]
}

规则(大灰狼听书真实源里的写法):

字段规则在接口 JSON 里点哪
list$.data最外层 data 这个数组,一个元素 = 一本书
bookName$.book_name(相对每个元素)元素里的 book_name
author$.author元素里的 author
cover$.thumb_url元素里的 thumb_url
wordCount$.word_number元素里的 word_number
status$.status元素里的 status
cat$.tags元素里的 tags

注意:list$.data 圈定数组后,下面每个字段的 $ 是相对”数组里单个元素”的,不是再从最外层开始。这跟 XPath 的 list + 相对字段是一个道理。

App 解析后得到data 数组里几个元素就几条):

{ "list": [
  { "bookName": "斗破苍穹", "author": "天蚕土豆", "cover": "https://v10.czyl.cf/cover/1.jpg", "wordCount": "530万", "status": "完结", "cat": "玄幻" },
  { "bookName": "武动乾坤", "author": "天蚕土豆", "cover": "https://v10.czyl.cf/cover/2.jpg", "wordCount": "410万", "status": "完结", "cat": "玄幻" }
]}

❌ 新手常踩的错

错误写法会怎样正确写法
list$.data[*] 后,字段又写 $.data.book_name字段从最外层找,取不到(数组上没有 book_name)字段相对元素写 $.book_name
接口明明返回 JSON,却用 XPath //divJSONPath 站点用 XPath 全部取空接口站一律 $ 开头
list 指向的不是数组(如写成 $.data.book_namelist 不是数组,App 不认list 必须指到那个数组本身

7.3 深度搜索:$..key(不确定在第几层时)

真实案例(番茄接口):

list:  $..book_data[*]
list:  $..chapterListWithVolume[*].*
       $..books[*]
       $..author

解释$..book_data 里的 .. 表示”不管在第几层,只要有叫 book_data 的就找出来”。当 JSON 嵌套很深、你懒得一层层数时用它。$..chapterListWithVolume[*].* 里最后的 .* 是”取该对象的所有值”(用于把分卷展开成章节)。 怎么仿写:接口返回层级复杂、或者你不确定确切路径时,用 $..目标字段名 直接捞。

🔍 原响应 → 规则 → 解析后(拿微信读书搜索接口举例,看 $.. 怎么不管埋多深都能把 bookInfo 捞出来):

服务器返回的 JSON(搜”斗破”,接口返回,删了无关字段):

{
  "books": [
    { "bookInfo": { "bookId": "108526", "title": "斗破苍穹", "author": "天蚕土豆", "cover": "https://res.weread.qq.com/1.jpg", "finished": 1 } },
    { "bookInfo": { "bookId": "203391", "title": "遮天", "author": "辰东", "cover": "https://res.weread.qq.com/2.jpg", "finished": 1 } }
  ]
}

每本书的信息包在 books[*] 里,再套一层 bookInfo——层级有点绕。

规则(微信读书真实源里的写法):

字段规则在接口 JSON 里点哪
list$..bookInfo不管 bookInfo 埋在第几层,全部捞出来,一个 = 一本书
bookNametitle(相对每个 bookInfobookInfo 里的 title
authorauthorbookInfo 里的 author
covercoverbookInfo 里的 cover
detailUrlbookIdbookInfo 里的 bookId

注意:$..bookInfo 圈定后,下面每个字段是相对”单个 bookInfo 对象”的,直接写 title,不用再从最外层 $ 开始。

App 解析后得到(有几个 bookInfo 就几条):

{ "list": [
  { "bookName": "斗破苍穹", "author": "天蚕土豆", "cover": "https://res.weread.qq.com/1.jpg", "detailUrl": "108526" },
  { "bookName": "遮天", "author": "辰东", "cover": "https://res.weread.qq.com/2.jpg", "detailUrl": "203391" }
]}

❌ 新手常踩的错

错误写法会怎样正确写法
老实写 $.books[*].bookInfo能用,但接口哪天改成 data.books[*].bookInfo 就得跟着改$..bookInfo 不管在第几层都能捞,抗改版
$..bookInfo 写成 $.bookInfo(单点)单点只找最外层这一层,深处的 bookInfo 找不到,列表空两个点 $.. 才是”任意深度找”
$.. 之后字段又写 $..title会把每一层的 title 都捞来串味字段相对 bookInfotitle 就行

看懂这四段的意义:遇到嵌套深、或结构可能变的接口 → 找到那个”装单本书信息的对象名”(这里是 bookInfo)→ 用 $..对象名 一把捞(②)→ 字段相对它写(③)→ 省得数层级还抗改版。$.. = 不数层数,按名字在整个 JSON 里全局找。

7.4 简单路径(不带 $

真实案例y-一纸(app) 正文):

data/chapterInfo[0]/chapterContent[0]

解释:这是简写形式,等价于 JS 的 result.data.chapterInfo[0].chapterContent[0]。看到这种没有 $、用 / 分隔的,就理解成”一层层的 key”。用 $ 开头的标准 JSONPath 更通用,推荐优先用它。

🔍 原响应 → 规则 → 解析后(拿 y-一纸(app) 正文接口举例,看不带 $/ 路径怎么走):

服务器返回的 JSON(正文接口返回,chapterInfochapterContent 都是数组):

{ "data": { "chapterInfo": [ { "chapterName": "第1章", "chapterContent": [ "斗气大陆,天才辈出……" ] } ] } }

规则y-一纸(app) 真实源 chapterContent.content):

字段规则这条路径怎么走
contentdata/chapterInfo[0]/chapterContent[0]datachapterInfo 第 0 个 → chapterContent 第 0 个

完全等价于标准写法 $.data.chapterInfo[0].chapterContent[0]——只是把 . 换成 /、开头省了 $

App 解析后得到

{ "content": "斗气大陆,天才辈出……" }

❌ 新手常踩的错(对着①的数组层看为什么错):

错误写法会怎样正确写法
data.chapterInfo[0].... 分隔简写路径的分隔符是 / 不是 .不带 $ 时一律用 / 分隔
data/chapterInfo/chapterContent 漏下标数组不写 [0] 取到的是整个数组不是那个元素数组那层记得加 [0]
$data/chapterInfo... 又加 $ 又用 /两种写法混用,解析器不认要么全 $.a.b[0],要么全 a/b[0],别混

看懂这四段的意义:见到没有 $、用 / 隔开的路径,脑子里翻译成 result.a.b[0].c[0] 就懂了(对应②)→ 数组那层补 [下标](①里两层都是数组)→ 结果就是那层的值(③)。它只是 JSONPath 的偷懒简写,能看懂就行,自己写还是推荐 $ 开头的标准写法。


第 8 章 ||@js: 混用:选择器打底 + JS 收尾

这是最需要掌握的写法。本批源里混用规则 631 条

核心原理(务必记住):

选择器(XPath或JSONPath)  ||@js:  JS代码
        ↓                          ↓
   先取到原始值               这个值变成 result 传进来,你加工后 return

选择器负责””,JS 负责””。什么时候用它?——能定位到值,但取到的值还需要清洗/计算时。

为什么需要它:纯 XPath 取到的常常是”作者:张三”带前缀、“[玄幻]“带括号、或者一个数字 0/1 需要变成”连载/完结”。这些”再加工一步”的活,XPath 干不了,就交给后面的 JS。

8.1 一步小清洗:replace 去杂字

真实案例

//div[3]||@js:
return result.replace('【展开】【收起】',"")

//h1/span[2]||@js:
return result.replace(/更新时间:/,"")

author||@js:
return result.replace("辰东", "辰東")

逐条解释

  • ||@js: 后面就是 JS 代码(源文件里 ||@js: 后会换行)。
  • result = 前面选择器取到的值。result.replace('【展开】【收起】',"") = 把”【展开】【收起】“替换成空字符串(即删掉)。
  • result.replace(/更新时间:/,"")/更新时间:/ 是正则写法(第 0 章讲过),效果和写字符串一样,把”更新时间:“删掉。
  • 第三条很有意思:繁体站点把”辰东”显示成乱码,源作者干脆把”辰东”替换成繁体”辰東”。这是真实源里反复出现的小技巧。 怎么仿写:取到的值多了几个字 → 选择器||@js: 换行 return result.replace('要删的字',"")

🔍 原响应 → 规则 → 解析后(拿 天天书吧 搜索页举例,看 replace 怎么把”作者:“前缀擦掉):

XPath //span 先取到的原始值(作者名前面粘着”作者:”):

作者:辰东

规则天天书吧-电脑版 真实源 searchBookauthor 字段):

字段规则拿到 result 后做什么
author//span ||@js: return result.replace(/作者:/,"")取到”作者:辰东”,把”作者:“这几个字替换成空(删掉)

App 解析后得到(前缀被擦掉):

{ "author": "辰东" }

❌ 新手常踩的错

错误写法会怎样正确写法
replace("作者:","") 用半角冒号页面里是全角”:“,半角 : 匹配不到,前缀没删掉复制页面里真实那个冒号(多半是全角”:“)
忘了写 returnJS 不 return,字段拿到 undefinedreplace 完的结果一定要 return 出去
直接 //span 不清洗界面显示成”作者:辰东”,白带个前缀||@js: 用 replace 去掉

看懂这四段的意义:取到的值多带了”作者:""更新时间:“这类前缀 → 选择器后接 \|\|@js:(①→②)→ replace('前缀',"") 擦掉 → 得到干净值(③)。replace 就是”把某段字换成别的”,换成空字符串就是删。

8.2 正则捕获一部分:match(…)[1]

真实案例

//span[1] ||@js:
return result.match(/\[(.*?)\]/)[1]          (从"[玄幻]"取"玄幻")

//h1/a|@js: return result.match(/\((.*?)\)/)[1]   (从"书名(作者)"取作者)

解释

  • result.match(/正则/) 是”用正则去匹配”,返回一个数组:[0] 是整段匹配到的,[1]第一个括号 ( ) 里捕获到的
  • /\[(.*?)\]/\[\] 是转义的方括号(匹配真实的 [ ]),(.*?) 是”括号中间任意内容”并捕获。所以 [1] 取到的就是方括号里的”玄幻”。
  • 第二条 /\((.*?)\)/ 同理,抠圆括号里的作者名。 怎么仿写:值里”我只要某对括号/引号里的东西”→ 用 match(/前缀(.*?)后缀/)[1]

🔍 原响应 → 规则 → 解析后(拿 知轩藏书 搜索举例,一整行文字用 match 抠出书名/作者/状态三部分):

XPath //span[contains(@class,"link")] 取到的一整行(三样信息挤在一起):

《斗破苍穹》作者:天蚕土豆(已完结)

书名被《》包着、作者被”作者:“和”(“夹着、状态被()包着。

规则知轩藏书 真实源:同一个 span 接不同的 match):

字段规则抓的是哪一段
bookName//span[...] ||@js: return result.match(/《(.*?)》/)[1]《》中间的书名
author//span[...] ||@js: return result.match(/作者:([^(]+)/)[1]”作者:“之后、”(“之前的作者
status//span[...] ||@js: return result.match(/((.*?))/)[1]()中间的状态

App 解析后得到(一行被拆成三段):

{ "bookName": "斗破苍穹", "author": "天蚕土豆", "status": "已完结" }

❌ 新手常踩的错

错误写法会怎样正确写法
match(/《(.*?)》/)[0][0][0] 是整段含符号的”《斗破苍穹》“,[1] 才是括号捕到的”斗破苍穹”要捕获的内容取 [1]
match(/《.*?》/)[1] 正则里没写 ( )没有捕获括号,[1]undefined想要的那段用 ( ) 圈起来才捕得到
有的书没有(状态)时硬取 [1]match 不中返回 nullnull[1] 直接报错拿不准可写 (result.match(...)||[])[1] 兜底

看懂这四段的意义:一段文字里”我只要某对符号中间的东西”→ 写 match(/符号(.*?)符号/)(②)→ 用 ( ) 圈住要留的那段 → 取 [1](③)。[0] 是整段匹配、[1] 是第一对括号捕到的,千万别记反。

8.3 分割取段:split(…)[n]

真实案例♡⃝.新御宅屋 搜索):

status:    //p[1]/span ||@js:
return result.split('  ')[1]

wordCount: //p[1]/span ||@js:
return result.split('  ')[0]

解释result.split(' ') 把字符串按”两个空格”切成数组。假设取到”12万字 连载中”,split 后是 ["12万字","连载中"][0]=字数、[1]=状态。同一个选择器取到一整段,用 split 拆开分别喂给不同字段怎么仿写:一段文字里挤了好几个信息(用空格/竖线/斜杠分隔)→ split('分隔符')[序号] 挑出你要的那段。

🔍 原响应 → 规则 → 解析后♡⃝.新御宅屋 搜索,同一段 span 被拆成两个字段):

XPath //p[1]/span 先取到的原始值(一段挤了字数和状态的文字):

12万字  连载中

注意”12万字”和”连载中”之间是两个空格——这正是下面 split(' ') 要切的分隔符。

规则(同一个 XPath 取到同一段,后面接不同的 split 下标):

字段规则拿到 result 后做什么
wordCount//p[1]/span ||@js: return result.split(' ')[0]切成 ["12万字","连载中"],取 [0]
status//p[1]/span ||@js: return result.split(' ')[1]同上,取 [1]

解析后得到(一段文字被拆进两个字段):

{ "wordCount": "12万字", "status": "连载中" }

❌ 新手常踩的错

错写法出什么问题对的写法
split(' ')(一个空格)“12万字”和”连载中”中间是两个空格,用一个空格切会切出 ["12万字","","连载中"][1] 变成空串数清楚分隔符几个空格,split(' ')
直接 //p[1]/span 不 split字数和状态挤在一个字段里,显示成”12万字 连载中”用 split 拆开分别喂给两个字段
split(' ')[2]只有 2 段(下标 0、1),[2] 取到 undefined下标从 0 数,最多到”段数-1”

8.4 数字/布尔 → 文字(状态归一)

真实案例

$.finished | @js:
return result == 0 ? '连载':'完结'

$.finished | @js:
return result == false ? '连载':'完结'

creation_status||@js:
if(result==0){return '已完结'}else{return '连载中'}

解释:接口给的状态是数字 0/1 或布尔 true/false,读者看不懂,要转成中文。

  • result == 0 ? '连载' : '完结'三元表达式:“如果 result 等于 0,返回’连载’,否则返回’完结’”。
  • 第三条用 if/else 写,效果一样。 怎么仿写:字段是数字/布尔状态 → return result == 值 ? '文字A' : '文字B'

🔍 原响应 → 规则 → 解析后(接口把”是否完结”存成数字,要翻译成中文):

接口返回的 JSONfinished 是个数字,光看数字用户不懂):

{ "book_name": "斗破苍穹", "finished": 0 }
{ "book_name": "遮天", "finished": 1 }

规则(JSONPath 先取到数字,||@js: 把数字翻译成中文):

字段规则拿到 result 后做什么
status$.finished ||@js: return result == 0 ? '连载':'完结'result0 → 返回”连载”;是 1 → 返回”完结”

解析后得到(数字变成了人能看懂的中文):

{ "bookName": "斗破苍穹", "status": "连载" }
{ "bookName": "遮天",     "status": "完结" }

❌ 新手常踩的错

错写法出什么问题对的写法
result === '0'(带引号)接口给的是数字 0 不是字符串 '0'=== 严格比较类型不同永远不相等,全都走”完结”==(松比较)或直接比数字 result == 0
只写 $.finished 不转界面直接显示”0""1”,用户一脸问号必须接 ||@js: 把 0/1 翻译成中文
记反了 0 和 1很多接口 0=连载、1=完结,但有的站相反;写反了状态全颠倒拿一本已完结的书测一下,确认哪个数字对应完结

8.5 补全 URL

真实案例

//a/@href||@js:
return params.responseUrl + result;          (相对路径接到当前页地址后)

//a/@href||@js:
let url = result.replace("www.","m."); return url;   (换成手机版域名)

//h3/a/@href|@js:
return result.replace("//","https://")        (补协议头)

解释

  • params.responseUrl = 当前这次请求的真实网址(第 13 章详解)。取到的 href 若是相对的(如 /read/123),接在 responseUrl 后面就成完整地址。
  • 第三条:有的链接是 //example.com/x(省略了 https:),replace("//","https://") 给它补上。 怎么仿写:取到的链接不完整 → 用 replace 补域名/协议,或接 params.responseUrl。多数情况 App 会自动补 host,不确定时先不写 JS,测一下。

🔍 原响应 → 规则 → 解析后(拿 ♡⃝.盘古小说网 章节列表举例,看相对链接怎么用 params.responseUrl 补成完整网址):

XPath //a/@href 取到的原始值(只有个相对片段,缺域名和路径):

1.html

规则♡⃝.盘古小说网 真实源 chapterListurl 字段):

字段规则拿到 result 后做什么
url//a/@href ||@js: return params.responseUrl + resulthref 只有”1.html”,接到当前目录页地址后面,补成完整网址

App 解析后得到(假设当前目录页 responseUrlhttps://www.pangu.com/book/12345/):

{ "url": "https://www.pangu.com/book/12345/1.html" }

❌ 新手常踩的错

错误写法会怎样正确写法
直接 //a/@href 不补只有”1.html”,App 请求这个不完整地址会失败params.responseUrlconfig.host 补全
相对当前目录的链接却用 config.host + resultconfig.host 是根域名,接上会丢掉中间的 /book/12345/ 路径相对当前页用 responseUrl,相对根域名才用 host
href 是 //cdn.x.com/1.jpg(省了协议)直接用会被当成相对路径result.replace("//","https://") 补协议头

看懂这四段的意义:取到的链接不完整时先看它缺什么——缺域名就接 config.host、相对当前页就接 params.responseUrl(②)、只缺协议就 replace("//","https://")——补齐成绝对地址(③)。多数情况 App 会自己补 host,不确定就先不写 JS 测一下,缺了再补。


第 9 章 纯 JS:请求改写 / 循环建表 / 伪造数据

@js: 开头(前面没有选择器)的字段就是纯 JS。本批源 1018 条,其中 817 条是 requestInfo——因为拼请求地址是 JS 最擅长的活。

纯 JS 用在”选择器完全无能为力”的地方:动态拼 URL、用循环手动建列表、不请求网络直接造数据。

9.1 分类页:按用户选的筛选拼 URL

真实案例bookWorld.分类.requestInfo):

@js:
let {_type} = params.filters
let url = `http://www.xiaoshuoba.com${_type}${params.pageIndex}.html`;
return {url:url, 'httpHeaders':config.httpHeaders}

逐行解释

  • let {_type} = params.filters:这叫解构——从 params.filters(用户选的筛选项,第 12 章详解)里取出 _type 这个值,存进变量。等价于 let _type = params.filters._type
  • 反引号包起来的是模板字符串(第 0 章讲过),${} 里的变量会被替换进去。假设 _type="/xuanhuan/"pageIndex=2,拼出来就是 http://www.xiaoshuoba.com/xuanhuan/2.html
  • return:返回一个对象,告诉 App”请求这个 url,带上全局请求头”。 怎么仿写:分类/排行页的地址是”域名+分类参数+页码”拼出来的 → 照这个模板,把 _type 换成你的筛选值。

🔍 输入 → 规则 → 发出的请求(拿小说吧分类页举例,看筛选值和页码怎么拼成分类 URL):

App 给你的输入(用户在分类页选了”玄幻”,翻到第 2 页):

params.filters = { _type: "/xuanhuan/" }   ← "玄幻"这个选项对应的值
params.pageIndex = 2

规则(小说吧真实源 bookWorld.分类.requestInfo):

@js:
let {_type} = params.filters
let url = `http://www.xiaoshuoba.com${_type}${params.pageIndex}.html`;
return {url:url, 'httpHeaders':config.httpHeaders}

App 据此发出的真实请求_typepageIndex 代入模板串):

GET http://www.xiaoshuoba.com/xuanhuan/2.html
                             └─ _type ─┘└页码┘

❌ 新手常踩的错(对着①的输入看为什么错):

错误写法会怎样正确写法
let _type = params.filters(漏了 {} 解构)_type 拿到整个 filters 对象,拼进 URL 变 [object Object]let {_type} = params.filters 解构出那个值
+ 拼但忘了反引号模板串的 ${}拼出字面量 ${_type} 而非真实值反引号串里变量必须写 ${变量}
_type 组名和 requestFilters 里定义的不一致取到 undefined,URL 少一段组名和过滤器定义处完全一致(第 12 章)

看懂这四段的意义:分类页的地址 = 域名 + 用户选的分类值 + 页码(对应②的模板串)→ 从 params.filters 解构出选中值、用 params.pageIndex 取页码(对应①)→ 代进模板拼出真实 URL(③)。会变的分类值和页码用变量,其余写死。

9.2 搜索用 POST + 表单

真实案例抖音小说 搜索):

@js:
let url = "https://www.douyinxs.com/search/"
let hp = { "searchkey": params.keyWord, "Submit": "" }
return {
    "url": url,
    "POST": true,
    "httpParams": hp,
    "httpHeaders": config.httpHeaders,
    "forbidCookie": false,
    "cacheTime": 600,
}

逐行解释

  • params.keyWord = 用户搜索的关键词。
  • hp 是要提交的表单参数(httpParams):把关键词放进 searchkey
  • 返回对象里 "POST": true 表示用 POST 方式提交(对应第 0 章讲的 GET/POST);httpParams 就是表单内容;cacheTime: 600 表示结果缓存 600 秒。 怎么仿写:站点搜索是 POST 表单时(F12 Network 里看到 Request Method: POST)→ 照这个模板,把表单字段名和值填对。

🔍 输入 → 规则 → 发出的请求(拿抖音小说搜索举例,看 POST 表单参数怎么进 body 而不是网址):

App 给你的输入(用户搜”斗破”):

params.keyWord = "斗破"

规则(抖音小说真实源 searchBook.requestInfo):

@js:
let url = "https://www.douyinxs.com/search/"
let hp = { "searchkey": params.keyWord, "Submit": "" }
return { "url": url, "POST": true, "httpParams": hp, "httpHeaders": config.httpHeaders }

App 据此发出的真实请求POST:true 让参数进 body,不拼进网址):

POST https://www.douyinxs.com/search/
Content-Type: application/x-www-form-urlencoded

searchkey=斗破&Submit=

❌ 新手常踩的错(对着②的写法看为什么错):

错误写法会怎样正确写法
把参数拼进 url 还写 POST:true服务器在 body 里找不到参数,返回空POST 的参数放 httpParams,别拼进 url
"searchkey": keyWord(漏了 params.keyWord 未定义、脚本报错用户输入只能用 params.keyWord
该 POST 的站写成 GET(不加 POST:true用 GET 请求 POST 接口,多半返回 405 或空F12 看到 Method: POST 就必须 POST:true

看懂这四段的意义:F12 看到搜索是 POST(对应③的请求方法)→ 参数放进 httpParams、加 POST:true(对应②)→ App 把它们拼成表单 body 发出去(③)。POST 的参数进 body 不进网址,这是和 GET 最大的区别。

9.3 接口分页用 offset(不是页码)

真实案例(番茄搜索):

@js:
return `...&passback=${(params.pageIndex-1)*10}&limit=10...`;

解释:有的接口不认页码,认”跳过多少条”(offset)。第 1 页跳过 0 条、第 2 页跳过 10 条…所以用 (params.pageIndex-1)*10 算出来。limit=10 是每页 10 条。 怎么仿写:接口参数里有 offset/start/passback 这类 → 用 (pageIndex-1)*每页数量 算。

🔍 输入 → 规则 → 发出的请求(拿番茄搜索举例,看页码怎么换算成 offset):

App 给你的输入(用户翻页,App 把页码 params.pageIndex 从 1 往上递增,每页 10 条):

第 1 页:params.pageIndex = 1
第 2 页:params.pageIndex = 2
第 3 页:params.pageIndex = 3

规则(番茄真实源,用 (pageIndex-1)*10 把页码换成”跳过多少条”):

页码 pageIndex算式 (pageIndex-1)*10得到的 passback(offset)
1(1-1)×100(从第 0 条开始,即第一页)
2(2-1)×1010(跳过前 10 条)
3(3-1)×1020(跳过前 20 条)

App 据此发出的真实请求(页码代入算式,拼出带 offset 的接口地址):

第 1 页 → ...&passback=0&limit=10...
第 2 页 → ...&passback=10&limit=10...
第 3 页 → ...&passback=20&limit=10...

❌ 新手常踩的错(对着①的页码看为什么错):

错误写法会怎样正确写法
直接把 pageIndex 当 offset 用第 2 页只跳过 2 条,和第 1 页几乎重复,列表一直是开头那批要乘每页条数 (pageIndex-1)*10
忘了减 1:pageIndex*10第 1 页就跳过了 10 条,漏掉最前面 10 条内容页码从 1 起,offset 要 (pageIndex-1)
limit 和算式里的每页数不一致(如 ×10 但 limit=20)翻页会跳条或重叠算式里的乘数 = limit 的值,保持一致

看懂这四段的意义:接口分页有两派——认”页码”的直接塞 pageIndex(第 11.1),认”跳过多少条”的要换算。看到参数叫 offset/start/passback/skip(对应①)→ 用 (pageIndex-1)*每页数 换算(②)→ 拼进请求(③)。关键是那个”减 1”和”乘每页条数”,两个都不能漏。

9.4 HTML 太乱,用正则循环手动建列表

真实案例(笔趣阁类,思路演示):

@js:
let list = [];
let reg = /<li><a href="(.*?)">(.*?)<\/a><\/li>/gi;
let tem;
while (tem = reg.exec(result)) {
  list.push({ "title": tem[2], "url": params.queryInfo.detailUrl + tem[1] });
}
return { "list": list };

逐行解释

  • let list = []:先建一个空数组,准备往里装。
  • reg 是正则,(.*?) 有两个括号:第一个捕获 href(链接),第二个捕获文字(标题)。gi = 全局+忽略大小写。
  • while (tem = reg.exec(result))reg.exec 每执行一次抓一条匹配,配合 while 循环抓完所有tem[1]=第一个括号(链接),tem[2]=第二个括号(标题)。
  • list.push({...}):把这一条塞进数组。params.queryInfo.detailUrl + tem[1] 是把书的地址和章节相对链接拼成完整章节地址。
  • return:列表模块必须返回 {list:[...]} 这个结构。 怎么仿写:XPath 抓不干净的乱 HTML → 写正则把每条的关键部分用 ( ) 括起来,while(exec) 循环 push。这是硬核招,能不用尽量先试 XPath。

🔍 原响应 → 规则 → 解析后(一段挤在一行、没有换行和 class 的乱 HTML,XPath 很难下手):

服务器返回的 HTML(整个目录被压成一行,<li> 之间毫无缝隙):

<ul><li><a href="/read/9527/1.html">第一章 出山</a></li><li><a href="/read/9527/2.html">第二章 下山</a></li><li><a href="/read/9527/3.html">第三章 遇敌</a></li></ul>

规则(纯 JS,正则循环建表):

@js:
let list = [];
let reg = /<li><a href="(.*?)">(.*?)<\/a><\/li>/gi;
let tem;
while (tem = reg.exec(result)) {
  list.push({ "title": tem[2], "url": params.queryInfo.detailUrl + tem[1] });
}
return { "list": list };
  • 正则里第一个 (.*?) 抓 href(tem[1]),第二个抓标题文字(tem[2])。
  • while(reg.exec(result)) 反复跑,一次抓一个 <li>,直到抓完。

App 解析后得到(乱 HTML 被硬拆成规整 list,url 已拼上书地址前缀):

{ "list": [
  { "title": "第一章 出山", "url": "https://站点/book/9527/read/9527/1.html" },
  { "title": "第二章 下山", "url": "https://站点/book/9527/read/9527/2.html" },
  { "title": "第三章 遇敌", "url": "https://站点/book/9527/read/9527/3.html" }
]}

❌ 新手常踩的错

错误写法后果正确写法
忘了 g 标志:/.../iwhile 死循环(exec 永远从头匹配同一条)正则必须带 g/.../gi
list.push(tem) 直接塞整个匹配数组每项变成 ["整段","链接","标题"],App 认不出push 一个 {title,url} 对象
tem[0] 当标题tem[0] 是整段 <li>...</li>,带标签标题用捕获组 tem[2]

举一反三:这招是 XPath 的兜底——当 HTML 压成一行、没有任何 class/id、层级也数不清时,就把”一条记录”的 HTML 特征写成正则,用 ( ) 圈出要的字段,while(exec) 循环 push。代价是正则比 XPath 脆(HTML 稍变就失配),所以能用 XPath 就别用它

9.5 不请求网络,直接造数据

真实案例(工具类源,如 QQ 头像工具):

bookName:  @js: let str = params.keyWord + '的QQ头像'; return str;
detailUrl: @js: return params.responseUrl;
author:    @js: return params.keyWord;

解释:这类”源”根本不是小说站,是借书源框架做工具。字段值直接用 params.keyWord(用户输入)拼出来,不解析任何网页。 另一种是 requestInfo 直接返回 {response:{...}}

@js:
return { response: { "list": [{ "bookName": txt, "detailUrl": "" }] } };

response 字段的意思是”不用发网络请求了,这就是响应”,直接把造好的数据塞进去。 怎么仿写:不需要网络、纯本地生成结果时用 return {response: 你造的数据}

🔍 输入 → 规则 → 解析后(拿”QQ头像工具”这类借壳源举例,看不请求网络怎么直接造出一条结果):

App 给你的输入(用户在搜索框输了一个 QQ 号,params.keyWord = "10001",此源不打算请求任何网站):

params.keyWord = "10001"
params.responseUrl = "https://q1.qlogo.cn/g?b=qq&nk=10001&s=640"

规则(工具类源 searchBook,字段值全靠 JS 就地拼,不解析网页):

字段规则就地造了什么
bookName@js: return params.keyWord + '的QQ头像';用关键词拼出”10001的QQ头像”
author@js: return params.keyWord;直接用关键词当作者
detailUrl@js: return params.responseUrl;用当前地址占位,不另外请求

另一种更彻底的:requestInfo 直接 return {response:{...}}response 键的含义是”别发网络请求了,这段就是响应”,把造好的整个 list 塞进去。

App 解析后得到(没访问任何网站,一条结果凭空造出来):

{ "list": [ { "bookName": "10001的QQ头像", "author": "10001", "detailUrl": "https://q1.qlogo.cn/g?b=qq&nk=10001&s=640" } ] }

❌ 新手常踩的错(对着②的思路看为什么错):

错误写法会怎样正确写法
造好数据却仍写 requestInfo 去请求某网址多发一次没用的网络请求,还可能报错覆盖造的数据return {response: 造好的数据} 告诉 App 别请求
response 里塞的结构和正常解析不一致App 按解析流程走,字段对不上取空response 里放的要和该模块正常返回结构一致(列表就放 {list:[...]}
忘了 return,只在 JS 里算不吐出来字段拿到 undefined造好的值一定要 return 出去

看懂这四段的意义:有些”书源”其实是借框架做的小工具(头像、天气、计算器…),根本没有可爬的网站。这时数据全靠 params.keyWord 等输入就地拼(对应②)→ 用 return {response:...} 跳过网络请求(③)→ App 照常渲染。认清”这源要不要真访问网站”,不需要就别写 requestInfo,直接造 response。


第 10 章 正文清洗:去广告、去水印、解密

chapterContent.content(正文)是”混用 ||@js:”占比最高的字段(104 条纯 XPath、94 条混用)。因为免费小说站的正文里塞满广告、推广语、水印,几乎都要清洗。

10.1 正文就是某个容器里的段落(不用清洗时)

真实案例

//div[@id="content"]/text()
//div[@id="nr1"]/p
//div[@class="content"]//p/text()
$.data.content                (接口正文,直接一个字段)

解释:正文一般在一个 id 是 content/nr1 的 div 里。/text() 取纯文字,/p 取所有段落。接口站直接 $.data.content 一个字段拿到整篇。

🔍 原响应 → 规则 → 解析后(拿一个正文本身就干净、不用清洗的站举例,看最省事的正文规则长啥样):

服务器返回的正文 HTML(正文老实实躺在一个 <div id="content"> 里,没夹广告):

<div id="content">
  萧炎缓缓睁开双眼,眸中精光一闪而逝。<br>
  他站起身,望向远方的天际。<br>
  "三十年河东,三十年河西,莫欺少年穷!"<br>
</div>

规则(真实源 chapterContent.content,就这么一行,没有 ||@js:):

字段规则在啃 HTML 的哪一块
content//div[@id="content"]id 是 content 的那个 div,整块正文
(另一种写法)//div[@id="content"]/text()只取纯文字,剥掉里面的 <br><span> 等标签

App 解析后得到(正文本来就干净,取到就是成品,不用再加工):

  萧炎缓缓睁开双眼,眸中精光一闪而逝。
  他站起身,望向远方的天际。
  "三十年河东,三十年河西,莫欺少年穷!"

❌ 新手常踩的错(对着①的 HTML 看为什么错):

错误写法会怎样正确写法
明明正文干净,却硬套 10.3 的正则数组白写一堆 .replace,还可能误删正常内容先在 App 里看一眼正文脏不脏,干净就一行 //div[@id="content"] 收工
content: //div/text()(不加 id 限定)页面里 div 一大堆,取到导航/广告 div 的文字带上 [@id="content"] 锁定正文那个 div
/p 但正文段落是 <br> 分行不是 <p>一个 <p> 都没有,取到空先看正文是 <p>段</p> 还是 <br> 换行,<p>/p,纯文字用 /text()

看懂这四段的意义:不是每篇正文都要清洗。打开 App 读一章 → 正文干净利落没广告(对应①)→ 那就一行 XPath 定位容器(②)→ 取到即成品(③)。先判断脏不脏,别上来就套一堆清洗代码。 脏了再看 10.2 起的各招。

10.2 去几段固定广告:链式 replace

真实案例精华书阁):

//*[@id="nr1"] | @js:
return result
  .replace(/精华书阁.*?最新章节!/, '')
  .replace(/为您提供.{1,100}保存好书签!/g, '')
  .replace(/\(本章未完,点击下一页精彩继续\)/g,"")
  .replace(/『章节有误?登录后点此报错~』/g,"")
  .replace(/☆免费小说.{1,20}无弹窗☆/g,"");

逐条解释

  • .replace(...).replace(...) 一个接一个叫链式调用——每个 replace 删一种广告,串起来一次删干净。
  • /精华书阁.*?最新章节!/.*? 是”任意字符、尽量少”(第 0 章讲过非贪婪)。整条意思是”从’精华书阁’到最近一个’最新章节!‘之间的所有内容全删掉”。
  • . = “任意 1 到 100 个字符”,用来兜住长度不定的广告词。
  • /g 是”全局”——一段里出现多次都删。转义的 \( \) 匹配真实括号。 怎么仿写:把正文里看到的每种广告,写一条 .replace(/广告特征/g, ""),串起来。特征里变化的部分用 .*?. 兜住。

🔍 原响应 → 规则 → 解析后(拿精华书阁真实正文举例,看链式 replace 一条条删掉了什么):

服务器返回的正文//*[@id="nr1"] 取到的原始文字,正文里夹着几段固定广告语):

  萧炎缓缓睁开双眼,眸中精光一闪而逝。
精华书阁提醒您:本站为您提供斗破苍穹最新章节!
  他站起身,望向远方的天际。
(本章未完,点击下一页精彩继续)
  "三十年河东,三十年河西,莫欺少年穷!"
☆免费小说App,全网无弹窗☆

规则(精华书阁真实源 chapterContent.content,链式 replace):

//*[@id="nr1"] | @js:
return result
  .replace(/精华书阁.*?最新章节!/, '')
  .replace(/\(本章未完,点击下一页精彩继续\)/g, "")
  .replace(/☆免费小说.{1,20}无弹窗☆/g, "");

App 解析后得到(三条 replace 各删一种固定广告,只剩正文):

  萧炎缓缓睁开双眼,眸中精光一闪而逝。
  他站起身,望向远方的天际。
  "三十年河东,三十年河西,莫欺少年穷!"

逐条对应(看①里每段广告被哪条 replace 干掉):

①里的广告命中的 replace结果
精华书阁提醒您:本站为您提供斗破苍穹最新章节!.replace(/精华书阁.*?最新章节!/, '')
(本章未完,点击下一页精彩继续).replace(/\(本章未完…\)/g,"")
☆免费小说App,全网无弹窗☆.replace(/☆免费小说.{1,20}无弹窗☆/g,"")

❌ 新手常踩的错(对着②的写法看为什么错):

错误写法会怎样正确写法
括号不转义:.replace(/(本章未完.*)/,"")( ) 在正则里是分组符号,不是真括号,匹配不到那段广告真括号要转义 \( \)
只写一条 replace 想删所有广告每种广告文字不一样,一条正则盖不全,漏删一种广告一条 .replace,链式串起来
广告种类多到七八条还硬串一长串 replace 又臭又长、难维护超过四五条就改用 10.3 的”正则数组 + for 循环”

看懂这四段的意义:正文夹着两三段固定广告时,链式 replace 最直接——一段广告写一条 replace(对应②),串起来一次删干净(③)。广告一多就升级到 10.3 的数组写法。记住括号、点号这些正则特殊字符要转义。

10.3 广告太多:正则数组 + 循环(最流行范式)

真实案例♡⃝.格格党g 等几十个源都用这招):

//div[@id="content"] ||@js:
return ad(result)
function ad(str) {
    let arr = [
        /.*?向你推荐他的其他作品:.*?/gi,
        /希望你也喜欢.*/gi,
        /请牢记.*/gi,
        /最近转码严重.*谢谢/gi,
        /已改网址.*群号/gi,
        /.想看.*?完整章节/gi,
    ]
    for (let i in arr) {
        str = str.replace(arr[i], "")
    }
    return str
}

逐行解释

  • return ad(result)——把正文交给下面定义的 ad 函数处理。(JS 里函数可以写在调用后面,能正常运行。)
  • function ad(str):定义一个清洗函数,str 就是传进来的正文。
  • let arr = [ /正则1/gi, /正则2/gi, ... ]:把所有广告正则装进一个数组,一目了然、好维护。
  • for (let i in arr):循环遍历数组,arr[i] 是第 i 条正则,逐条把匹配到的删掉。
  • 最后 return str 返回洗干净的正文。 怎么仿写:广告种类多时,别写一长串链式 replace,改用这个”数组 + for 循环”结构——以后加新广告只要往 arr 里加一行正则。这是真实源里最推荐的正文清洗写法。

🔍 原响应 → 规则 → 解析后(拿一段真实脏正文举例,看数组循环到底删掉了什么):

服务器返回的正文//div[@id="content"] 取到的原始文字,广告混在正文里):

  萧炎缓缓睁开双眼,眸中精光一闪而逝。
  小medusa向你推荐他的其他作品:《武动乾坤》,希望你也喜欢。
  他站起身,望向远方的天际。
  请牢记本站域名 www.gegedang.com
  "三十年河东,三十年河西,莫欺少年穷!"
  .想看斗破苍穹完整章节请下载我们的App

规则♡⃝.格格党g 真实源,即上面那段代码):

//div[@id="content"] ||@js:
return ad(result)
function ad(str){ let arr=[ /.*?向你推荐他的其他作品:.*?/gi, /希望你也喜欢.*/gi, /请牢记.*/gi, /.想看.*?完整章节/gi ]; for(let i in arr){ str=str.replace(arr[i],"") } return str }

App 解析后得到(数组里每条正则各扫一遍,广告行被删空,只剩正文):

  萧炎缓缓睁开双眼,眸中精光一闪而逝。
  他站起身,望向远方的天际。
  "三十年河东,三十年河西,莫欺少年穷!"

逐条对应(看①里每行被哪条正则干掉):

①里的脏内容命中的正则结果
小medusa向你推荐他的其他作品:《武动乾坤》,/.*?向你推荐他的其他作品:.*?/gi
希望你也喜欢。/希望你也喜欢.*/gi
请牢记本站域名 www.gegedang.com/请牢记.*/gi
.想看斗破苍穹完整章节请下载我们的App/.想看.*?完整章节/gi
三行正文没命中任何正则

❌ 新手常踩的错

错误写法会怎样正确写法
正则忘了加 g(如 /请牢记.*/i一段里出现两次广告,只删第一次每条都加 g(全局),gi = 全局+忽略大小写
想删整行却写成 /请牢记/(没 .*只删”请牢记”三个字,域名还在/请牢记.*/g.* 兜住这行后面全部
.* 太贪吃(如 /推荐.*喜欢/从第一个”推荐”吃到最后一个”喜欢”,误删中间正文用非贪婪 .*?,就近匹配

看懂这三段的意义:你在 App 里读到正文夹广告 → 把每种广告复制出来当①→ 针对每行的固定特征写一条正则塞进 arr(对应②)→ 心里预判洗完剩哪几行(③)。广告以后变种了,往数组里再加一行正则即可,不用动主体逻辑。

10.4 按位置裁掉首尾水印

真实案例小说吧):

///div[@id="acontent"]/text() | @js:
var ind1 = result.indexOf('(小说吧 www.xiaoshuoba.com)');
if(ind1>0){ result = result.substring(ind1+48, result.length); }
var ind2 = result.lastIndexOf('小说吧 www.xiaoshuoba.com');
if(ind2>0){ result = result.substring(0, ind2); }
return result;

逐行解释

  • result.indexOf('...'):找这段水印第一次出现的位置(下标);找不到返回 -1。
  • result.substring(起点, 终点):从起点截到终点。ind1+48 是跳过水印那 48 个字符,从水印之后开始要。
  • lastIndexOf:找最后一次出现的位置。substring(0, ind2) 是从头截到水印之前——把结尾水印切掉。
  • if(ind1>0) 是保险:只有确实找到了才裁,避免误伤。 怎么仿写:广告/水印固定出现在正文开头或结尾时,用 indexOf/lastIndexOf 找到它,再 substring 把它前面或后面的正文切出来。

🔍 原响应 → 规则 → 解析后(小说吧真实正文,水印卡在头和尾,中间是正文):

服务器返回的正文(头尾各有一段站点水印,正文夹在中间):

(小说吧 www.xiaoshuoba.com)第十章 突破
  萧炎盘膝而坐,周身斗气缭绕。
  经过三天三夜的苦修,他终于突破到了斗者境界。
小说吧 www.xiaoshuoba.com

规则(小说吧真实源 chapterContent.content,简化):

///div[@id="acontent"]/text() | @js:
var ind1 = result.indexOf('(小说吧 www.xiaoshuoba.com)');
if(ind1>0){ result = result.substring(ind1+48, result.length); }
var ind2 = result.lastIndexOf('小说吧 www.xiaoshuoba.com');
if(ind2>0){ result = result.substring(0, ind2); }
return result;

App 解析后得到(头部水印被 substring(ind1+48,...) 跳过,尾部水印被 substring(0,ind2) 切掉):

第十章 突破
  萧炎盘膝而坐,周身斗气缭绕。
  经过三天三夜的苦修,他终于突破到了斗者境界。

为什么用位置裁剪、不用 replace:头尾水印每章都一样,但 +48 这种”跳过固定字数”只对开头/结尾这种位置确定的场景才好用。中间乱插的广告还是得用 10.3 的正则数组。

❌ 新手常踩的错

错误写法会怎样正确写法
不写 if(ind1>0) 直接裁某章没这段水印时 indexOf 返回 -1,substring(-1+48,...) 从错误位置切,正文残缺一定先 if(ind>0) 判断”找到了才裁”
+48 硬编码字数记错多切或少切几个字,正文开头缺字或残留半截水印数准水印的字节/字符长度,或改用 ind1 + '水印文字'.length
indexOf 找结尾水印找到的是”第一次”出现,若正文中间也提到站名会误切结尾用 lastIndexOf(最后一次出现)

看懂这三段的意义:正文开头/结尾每章都挂着同一段水印时 → 复制那段水印当”锚点”→ 用 indexOf/lastIndexOf 定位、substring 切掉锚点外侧(对应②)→ 中间正文原样保留(③)。位置固定用裁剪,位置乱窜用 10.3 正则。

10.5 段落是数组要合并 / 去 <p> 标签

真实案例(番茄接口):

$.data.content || @js:
return result.replaceAll("</p><p>","\n").replace(/.*.*/,"")

解释:接口返回的正文带着 </p><p> 标签,replaceAll("</p><p>","\n") 把它们换成换行。replaceAllreplace(/x/g) 效果类似(都全部替换)。 段落是数组时用 join

return result.join("\n")   (把数组每段用换行连起来成整篇)

🔍 原响应 → 规则 → 解析后(拿番茄类接口举例,看数组/<p> 标签怎么变成整篇正文):

服务器返回的正文(接口 $.data.content 返回的是一串 <p>段</p>,或 XPath 取 /p 得到一个段落数组):

接口字符串形态:
第十章 突破</p><p>  萧炎盘膝而坐,周身斗气缭绕。</p><p>  三天后,他突破到了斗者境界。

XPath 数组形态(//div[@id="content"]/p 取到):
["  萧炎盘膝而坐,周身斗气缭绕。", "  三天后,他突破到了斗者境界。"]

规则(两种真实写法,看正文是”带标签的字符串”还是”数组”分别处理):

正文形态规则干了什么
</p><p> 标签的字符串$.data.content || @js:return result.replaceAll("</p><p>","\n")把标签换成换行,段落就分开了
XPath 取到的段落数组//div[@id="content"]/p ||@js:\nreturn result.join("\n")join("\n") 把数组每段用换行连成一篇

App 解析后得到(两种写法殊途同归,都变成分好行的整篇正文):

第十章 突破
  萧炎盘膝而坐,周身斗气缭绕。
  三天后,他突破到了斗者境界。

❌ 新手常踩的错(对着①的两种形态看为什么错):

错误写法会怎样正确写法
数组直接 return result(不 join)App 拿到的是数组不是字符串,正文显示成 段1,段2 挤成一行或报错数组一律 result.join("\n") 先连成字符串
replace("</p><p>","\n")(少了 all/g)一篇几十个 </p><p> 只换掉第一个,正文还是挤成一坨replaceAllreplace(/<\/p><p>/g,"\n") 全换
只换 </p><p>,忘了首尾的 <p> </p>正文开头结尾残留半截 <p></p> 标签首尾标签也补一条 replace 删掉

看懂这四段的意义:正文取出来若是数组(XPath /p 常见)就 join("\n") 连起来;若是<p> 标签的字符串(接口常见)就 replaceAll 把标签换成换行(对应②)。两条路都通向③那样分好行的正文。先看 result 到底是数组还是字符串,再决定用 join 还是 replace。

10.6 正文是加密的:nativeTool 解密

真实案例长佩文学99):

$.data..content ||@js:
let key = "u0LRrbu$Enm84koA";
let iv = "$h$b3!iGzsYnnshj";
let plain = params.nativeTool.Base64DecodeToString(result, key, "AES/CBC/PKCS5Padding", iv);
return plain;

解释

  • 有的站点把正文用 AES 加密了,直接取是乱码。params.nativeTool 是 App 提供的原生工具(第 13 章详解)。
  • Base64DecodeToString(密文, key, 算法, iv):用密钥 key 和初始向量 iv 把密文解开。keyiv 要从网页的 JS 里逆向找到(进阶操作)。 怎么仿写:正文是乱码/base64 → 说明加密了,去网页 JS 里找 key 和 iv,用 nativeTool 的解密方法。这是高难度场景,新手先跳过。

🔍 原响应 → 规则 → 解析后(拿 长佩文学99 真实接口举例,看一串乱码怎么解成正文):

服务器返回的正文(接口 $.data.content 返回的不是文字,而是一串 AES 加密后的 base64 乱码):

{ "data": { "content": "U2FsdGVkX1+8kZq3n2vT9pL0aWx4Rc7mB1dE...==" } }

规则长佩文学99 真实源 chapterContent.content,用 nativeTool 解密):

$.data..content ||@js:
let key = "u0LRrbu$Enm84koA";
let iv  = "$h$b3!iGzsYnnshj";
let plain = params.nativeTool.Base64DecodeToString(result, key, "AES/CBC/PKCS5Padding", iv);
return plain;

App 解析后得到(用 key 和 iv 把 base64 密文 AES 解密,还原成明文正文):

  萧炎缓缓睁开双眼,眸中精光一闪而逝。
  他站起身,望向远方的天际。
  "三十年河东,三十年河西,莫欺少年穷!"

❌ 新手常踩的错(对着②的参数看为什么错):

错误写法会怎样正确写法
看到乱码就上正则 replace 硬删加密串是整体密文,删几段照样解不开,只会更乱认出是加密(乱码/base64),走 nativeTool 解密这条路
key 或 iv 抄错一个字符AES 解密对 key/iv 敏感,错一位就解出全乱码key、iv 从网页 JS 里逐字符抠准,别手打错
算法/填充写错(如漏了 PKCS5Padding解密方式对不上,抛错或乱码算法串照网页 JS 里的原样填 AES/CBC/PKCS5Padding

看懂这四段的意义:正文取出来是乱码/base64(对应①)就说明被加密了 → 去网页 JS 里逆向找出 key、iv、算法 → 用 params.nativeTool.Base64DecodeToString 解开(②)→ 得到明文正文(③)。这是高难度进阶场景,key/iv 逆向门槛高,新手先跳过,会认出”这是加密”就够了。


第 11 章 分页与翻页

11.1 列表翻页:URL 带页码

真实案例

/search?q=%@keyWord&page=%@pageIndex
https://www.txt101.com/top/postdate_%@pageIndex.html

解释%@pageIndex 是页码占位符(第 3 章讲过),App 翻页时自动换成 1、2、3…。JS 里对应 params.pageIndex怎么仿写:看第 2 页的网址和第 1 页差在哪,把变化的数字换成 %@pageIndex

🔍 原响应 → 规则 → 解析后(用户从第 1 页翻到第 2 页,看 %@pageIndex / params.pageIndex 怎么拼出第 2 页地址):

用户的操作(在分类/搜索列表里往下翻,App 把页码 params.pageIndex 从 1 递增到 2):

第 1 页:params.pageIndex = 1
第 2 页:params.pageIndex = 2   ← 用户翻页,App 自动 +1

规则(两种真实写法,URL 模板 和 JS 拼接):

写法规则页码占位
URL 模板https://www.txt101.com/top/postdate_%@pageIndex.html%@pageIndex 由 App 自动换成当前页码
JS 拼接(真实源 小说吧@js: let url=`http://www.xiaoshuoba.com${_type}${params.pageIndex}.html`; return {url:url}JS 里用 params.pageIndex 取页码

App 解析后得到(第 2 页时 pageIndex=2 代入,拼出第 2 页真实请求 URL):

URL 模板  → https://www.txt101.com/top/postdate_2.html
JS 拼接    → http://www.xiaoshuoba.com/xuanhuan/2.html

❌ 新手常踩的错(对着①的页码看为什么错):

错误写法会怎样正确写法
URL 里写死页码:postdate_1.html翻到第 2、3 页还是请求第 1 页,列表永远是第一页那批变化的数字换成 %@pageIndex
JS 里用 %@pageIndex(模板占位符)JS 代码里不认模板占位符,拿到的是字面字符串JS 里改用变量 params.pageIndex
站点页码从 0 开始,直接用 pageIndexApp 的 pageIndex 从 1 起,首页对不上、错位需要时 params.pageIndex - 1 校正起始值

看懂这四段的意义:翻页的本质是”页码变、URL 跟着变”。对比第 1 页和第 2 页的网址,差的那个数字(对应①的 pageIndex)就是要替换的位置 → URL 模板用 %@pageIndex、JS 用 params.pageIndex(②)→ App 每翻一页自动代入新页码拼出新 URL(③)。先找出两页 URL 的差异位,那里就是页码占位符该放的地方。

11.2 正文分页:nextPageUrl 算下一页

有的站点一章正文拆成好几页,需要 nextPageUrl 字段告诉 App 下一页地址。

真实案例♡⃝.2k小说阅读):

//a[contains(text(),'下一页')]/@href ||@js:
if(result){
  var pageid = params.pageIndex + 1;
  var url = params.queryInfo.url.replace(/.html/,"");
  return url + "_" + pageid + ".html";
}else{
  return ""
}

逐行解释

  • 先用 XPath 取”下一页”链接到 result
  • if(result):如果取到了(说明还有下一页),就算下一页地址。
  • params.queryInfo.url 是本章的地址,去掉 .html,接上 _页码.html,拼出下一页。
  • else return "":取不到就返回空——告诉 App “没有下一页了,停”。这个 else 很重要,否则会无限翻页。 怎么仿写:正文分页时,取”下一页”按钮的链接;取到就返回下一页地址,取不到就 return "" 收尾。

🔍 原响应 → 规则 → 解析后♡⃝.2k小说阅读 第 1 页正文底部,看 nextPageUrl 怎么算出第 2 页):

服务器返回的 HTML(本章第 1 页,底部有翻页按钮。本章地址 params.queryInfo.url = "https://xxx.com/read/8_9527.html"params.pageIndex = 1):

<div class="page">
  <a href="/read/8_9527.html">上一页</a>
  <a href="/read/8_9527_2.html">下一页</a>
</div>

规则chapterContent.nextPageUrl):

//a[contains(text(),'下一页')]/@href ||@js:
if(result){
  var pageid = params.pageIndex + 1;
  var url = params.queryInfo.url.replace(/.html/,"");
  return url + "_" + pageid + ".html";
}else{
  return ""
}

App 解析后得到(分两种页面):

本章第 1 页:XPath 取到 result="/read/8_9527_2.html"(非空)
             → 去掉 .html → 拼 "_2.html" → nextPageUrl = "https://xxx.com/read/8_9527_2.html"
             → App 继续抓第 2 页正文接在后面
本章最后一页:页面没有"下一页"按钮 → result="" → 走 else → 返回 ""
             → App 认为"没有下一页了",停止翻页

❌ 新手常踩的错

错误写法会怎样正确写法
去掉 else return ""最后一页 result 为空时函数没返回值,App 行为不定,可能无限翻页或报错必须写 else return "" 明确收尾
直接 return result(不加工)返回的是相对路径 /read/8_9527_2.html,且没处理”到底还有没有下一页”按本章地址算出完整下一页地址,取不到就返空

看懂这三段的意义:正文分页的核心是”每翻一页都要判断还有没有下一页”。①里有没有”下一页”按钮,决定了②里 result 是否为空,进而决定③走”算地址”还是”返空收尾”。那个 else return "" 不是可有可无——它是翻页的刹车。

11.3 防翻过头(守卫)

真实案例中文看):

//a[@id='pb_next']||@js:
if(result.indexOf('章')<0){
  ...返回下一页...
}

解释if(result.indexOf('章')<0)——如果”下一页”按钮文字里不含”章”字,才翻页。因为很多站点最后一页的”下一页”其实是指向”下一章”,文字里带”下一章”。用这个判断挡住,避免正文串到下一章去。 怎么仿写:翻页容易翻到下一章时,加个判断——按钮文字含”章”就不翻。

🔍 原响应 → 规则 → 解析后(拿 中文看 真实源举例,看”守卫”判断怎么在最后一页刹住车):

服务器返回的 HTML(正文分页时底部那个 #pb_next 按钮,本章还没完 vs 到本章最后一页,文字不一样):

本章中间页(还有下一页):
<a id="pb_next" href="/read/9527_2.html">下一页</a>

本章最后一页(没有下一页了,按钮指向下一章):
<a id="pb_next" href="/read/9528.html">下一章</a>

规则中文看 真实源 chapterContent.nextPageUrl,用”含不含’章’字”当守卫):

//a[@id='pb_next']/@href ||@js:
if(result.indexOf('章') < 0){
  // 按钮文字不含"章"→ 还是"下一页"→ 返回它继续翻
  return result;
}else{
  // 文字含"章"→ 已经是"下一章"→ 返空,停在本章末尾
  return "";
}

App 解析后得到(守卫判断决定翻不翻):

本章中间页:按钮文字"下一页"不含"章" → indexOf('章')=-1 <0 成立 → 返回下一页地址 → 继续翻
本章最后页:按钮文字"下一章"含"章"   → indexOf('章')=2 不<0 → 走 else 返回 "" → 停止翻页

❌ 新手常踩的错(对着①两种按钮看为什么错):

错误写法会怎样正确写法
不加守卫,取到”下一页”链接就翻最后一页按钮其实指向”下一章”,App 把下一章正文接到本章末尾,两章串一起加判断:文字含”章”就不翻
if(result.indexOf('章') > 0) 判反了逻辑写反,正常”下一页”反而不翻、“下一章”反而翻想”不含章才翻”就写 < 0(找不到返回 -1)
只判断 result 非空,不看文字”下一章”按钮也非空,照样会翻过头光判非空不够,还得判断按钮文字内容

看懂这四段的意义:正文分页最怕”翻过头翻到下一章”。很多站最后一页的翻页按钮文字是”下一章”而非”下一页”(对应①)→ 用 indexOf('章')<0 当守卫,只在按钮**不含”章”**时才翻(②)→ 一旦文字变”下一章”就返空刹车(③)。守卫就是给翻页装的刹车片,专治正文串章。


第 12 章 分类与过滤器 filters

分类页/排行榜要让用户选”玄幻/都市""连载/完结”这些筛选项,靠 requestFilters 定义选项,选中的值进 params.filters

12.1 定义过滤器(requestFilters)

真实案例moreKeys 里):

{
  "pageSize": "30",
  "requestFilters": {
    "玄幻": "1", "科幻": "6", "修真": "2", "都市": "3"
  }
}

解释:这是”简单键值对”格式——左边”玄幻”是显示给用户看的名字,右边”1”是传给服务器的值。用户点”玄幻”,App 就把 “1” 代进请求。

🔍 定义 → 界面 → 用户选中后(看一段 requestFilters 怎么变成用户能点的筛选栏):

你在 moreKeys.requestFilters 里写的定义(左显示名、右服务器值):

{ "requestFilters": { "玄幻": "1", "科幻": "6", "修真": "2", "都市": "3" } }

App 据此渲染出的筛选栏(把左边的”名字”排成一排按钮给用户点):

[玄幻]  [科幻]  [修真]  [都市]

用户点了”都市”后,App 内部记下的值(存进 params.filters,交给请求那一步用):

params.filters.分类 = "3"      ← 显示"都市",实际传值 "3"

❌ 新手常踩的错(对着①的定义看为什么错):

错误写法会怎样正确写法
左右写反:"1": "玄幻"界面按钮显示成”1""6”,用户看不懂左边放显示名、右边放服务器值
值用了数字 "玄幻": 1(没引号)香色对类型敏感,可能闪退值也用字符串 "1"
定义了 filters 却没在请求里用 %@filter/params.filters用户选了没反应,永远第一类定义后一定要在 requestInfo 里取用(见 12.2)

看懂这四段的意义requestFilters 就是”给 App 一张选项表”(①)→ App 自动把左边的名字排成筛选按钮(②)→ 用户点谁,右边对应的值就进 params.filters(③)→ 请求那一步再取出来拼进 URL/表单。左名字、右传值,别写反。

12.2 在请求里取选中值

用 URL 模板%@filter 代表选中值:

/category/%@filter?page=%@pageIndex

用户选玄幻(值=1),就变成 /category/1?page=1

用 JS:从 params.filters 取:

@js:
let url = config.host + "/api/getTypeNovel?second_type=" + params.filters.type + "&page=" + params.pageIndex
return { 'url': url, "httpHeaders": config.httpHeaders };

解释params.filters.type 就是用户在名为 type 的筛选组里选中的值。

🔍 选中值 → 规则 → 发出的请求(用户选了”玄幻”,看 %@filterparams.filters.type 两种取法各自拼出什么):

用户选中后 App 手里的值(12.1 里定义的”玄幻”→ 值 “1”):

params.filters.type = "1"      params.pageIndex = 1

两种取法的规则(URL 模板用 %@filter,JS 用 params.filters.组名):

写法规则谁来替换
URL 模板/category/%@filter?page=%@pageIndexApp 自动把 %@filter 换成选中值
JS 取值config.host + "/api/getTypeNovel?second_type=" + params.filters.typeJS 里手动取 params.filters.type

App 据此发出的真实请求(值 “1” 代进去):

URL 模板 → /category/1?page=1
JS 取值  → https://站点/api/getTypeNovel?second_type=1&page=1

❌ 新手常踩的错(对着①的值看为什么错):

错误写法会怎样正确写法
JS 里用 %@filter(模板占位符)JS 不认模板占位符,拿到字面字符串JS 里改用 params.filters.组名
params.filters.typetype 和定义的组名不符取到 undefined,请求参数变空组名必须和 requestFilters/filter 组定义一致
只取了筛选值,忘了拼页码 %@pageIndex翻页无效,永远第一页筛选值和页码都要拼进请求

看懂这四段的意义:用户选中的值进了 params.filters(①)→ 简单场景用 URL 模板 %@filter 让 App 自动替换(②③)、复杂场景用 JS 取 params.filters.组名 手动拼 → 得到带筛选和页码的真实请求。模板占位符只在 URL 模板里认,JS 里一律用 params.filters

12.3 多个筛选维度一起选

真实案例追书神器 女频,同时选类型+排序+状态+字数):

@js:
let { _alias, _query, _status, _word, _sort } = params.filters
let hp = {
  "query": `${_alias},${_query}`,
  "status": _status,
  "wordCount": _word,
  "sort": _sort,
  "start": (params.pageIndex - 1) * 20,
  "limit": 50,
}
return { "url": url, "httpParams": hp, "httpHeaders": config.httpHeaders }

解释let { _alias, _query, _status, _word, _sort } = params.filters 一次性把 5 个筛选值都解构出来,分别放进请求参数。 怎么仿写:多个下拉筛选时,在 requestFilters 里定义好每一组,JS 里用 params.filters.组名 取各自选中值。

🔍 输入 → 规则 → 发出的请求(用户在分类页选了 4 个筛选项,看它们怎么变成一次接口请求):

App 给你的输入(用户在筛选栏点了:类型=玄幻、状态=连载、字数=100万+、排序=人气,翻到第 2 页):

params.filters = { _alias: "玄幻", _query: "1", _status: "1", _word: "3", _sort: "hot" }
params.pageIndex = 2

规则(追书神器女频 bookWorld.requestInfo):

@js:
let { _alias, _query, _status, _word, _sort } = params.filters
let hp = {
  "query": `${_alias},${_query}`,
  "status": _status,
  "wordCount": _word,
  "sort": _sort,
  "start": (params.pageIndex - 1) * 20,
  "limit": 50,
}
return { "url": url, "httpParams": hp, "httpHeaders": config.httpHeaders }

App 据此发出的真实请求(每个筛选值各就各位,start 由页码算出来):

httpParams = {
  "query": "玄幻,1",     ← _alias 和 _query 拼在一起
  "status": "1",         ← 连载
  "wordCount": "3",      ← 100万+ 对应的值
  "sort": "hot",         ← 人气排序
  "start": 20,           ← (2-1)*20,第 2 页跳过前 20 条
  "limit": 50
}

看懂这三段的意义:用户在界面点的每个下拉选项,都是 requestFilters 里定义好的「显示名 → 值」;选中后值进 params.filters.组名,你在 JS 里解构出来塞进 httpParams界面上几个筛选框,JS 里就解构几个变量——一一对应,不会错位。

❌ 新手常踩的错

错误写法后果正确写法
params.filters._alias(组名和 requestFilters 里定义的不一致)取到 undefined,请求参数变空组名必须和 requestFilters 里定义的完全一致
"start": params.pageIndex * 20第 1 页就跳过 20 条,漏掉开头(params.pageIndex - 1) * 20,第 1 页跳过 0 条
少解构一个维度,直接写死 "status":"1"用户选”完结”也永远只查连载每个用户能选的维度都从 params.filters

12.4 关于筛选键名

解释params.filters 里的键名(_type_fl_class_sort)是书源作者自己起的,常带下划线前缀以区别普通字段。你在 requestFilters 里定义什么名字,JS 里就用 params.filters.那个名字 取。本批源里 params.filters.type 出现最多(58 次)。

🔍 定义 → 取值 → 对应关系(同一个键名,定义处和取值处必须一字不差,看它俩怎么对上):

requestFilters 里定义的键名(作者给这组筛选起名叫 _type):

"requestFilters": {
  "_type": { "玄幻": "/xuanhuan/", "都市": "/dushi/" }
}

JS 里取值的规则(用同一个名字 _type 取):

定义处(requestFilters)取值处(JS)必须满足
组名 _typeparams.filters._type两处名字完全一致(含下划线)
组名 typeparams.filters.type少个下划线就取不到

对上之后 App 手里的值(用户选”玄幻”):

params.filters._type = "/xuanhuan/"

❌ 新手常踩的错(对着①②的键名看为什么错):

错误写法会怎样正确写法
定义写 _type,JS 取 params.filters.type名字对不上,取到 undefined两处名字逐字符一致
键名用了中文/空格如 "类 型"JS 里 params.filters.类 型 语法错键名用英文/下划线,别带空格
以为键名有固定规范照抄别人的别人叫 _fl 你也写 _fl,但你没这么定义键名是自己起的,定义叫啥取啥

看懂这四段的意义:筛选键名不是系统规定的,是作者在 requestFilters自己起的(①)→ JS 里必须用同一个名字params.filters 取(②)→ 才能拿到选中值(③)。记住”定义处叫什么、取值处就写什么”,对不上就是 undefined。


第 13 章 原生工具 nativeTool

params.nativeTool 是 App 用原生代码提供的”外挂能力”——JS 本身做不到的事(算 MD5、AES 解密、跨请求存数据)靠它。下面只列本批 296 个源里真实用到的方法及次数。

方法真实用到干什么
getCache / setCache16 / 15跨请求存取数据(token、bid)
log14打印调试
XPathParserWithSource13在 JS 里再用一次 XPath
md5Encode12算 MD5 签名
deviceId5取设备号
dataByAesDecryptWith...2AES 解密
stringByObject2对象转字符串
Base64DecodeToString1Base64+AES 解密
cookiesByUrl1取 Cookie

说明:参考资料里还列了 sha1Encodebase64EncodedeviceIdWithTemplateunzipFile 等方法,但本批源没用到,这里不展开,用到时查参考手册即可。

13.1 跨请求存取:setCache / getCache

真实案例(松鹤书评翻页):

if (params.pageIndex != 1) {
  hp.synckey = params.nativeTool.getCache("key")
}

解释:有些接口翻页需要上一页返回的一个 key。第一页请求时把它 setCache("key", 值) 存起来,翻到第 2 页再 getCache("key") 取出来用。params.pageIndex != 1 表示”不是第一页时才需要”。 怎么仿写:请求 B 需要请求 A 返回的某个值 → A 里 setCache 存,B 里 getCache 取。

🔍 第一页存 → 翻页取 → 请求带上(松鹤书评这类接口,翻页要带上一页给的 synckey,看缓存怎么接力):

第 1 页接口返回里带了一个 synckey(翻下一页必须带它,不带就翻不动):

{ "data": [ ...书评列表... ], "synckey": "a1b2c3d4e5" }

规则(第 1 页 setCache 存起来,第 2 页起 getCache 取出来放进请求参数):

// 第 1 页解析时:把 synckey 存进缓存
params.nativeTool.setCache("key", result.synckey)

// 下次请求(requestInfo):不是第一页就取出来带上
if (params.pageIndex != 1) {
  hp.synckey = params.nativeTool.getCache("key")
}

一步步接力

步骤发生什么
第 1 页请求不带 synckey,正常返回,解析时 setCache("key","a1b2c3d4e5")
第 2 页请求pageIndex=2 != 1 成立 → getCache("key") 取回 "a1b2c3d4e5" → 放进 hp.synckey
服务器看到带了正确 synckey → 返回第 2 页

❌ 新手常踩的错(对着②的时序看为什么错):

错误写法会怎样正确写法
只 getCache 不 setCache缓存里根本没存过,取到空先在第 1 页 setCache 存,后面才取得到
第 1 页也 getCache 当参数第一页时缓存还没值,请求带了空 synckey 被拒if(pageIndex!=1) 只在翻页时取
存取用了不同的键名setCache("key")getCache("synckey"),对不上存和取的键名必须一致

看懂这四段的意义:跨请求传值靠缓存接力——请求 A 拿到的值 setCache 存起来(①→②)→ 请求 B 用 getCache 取回来带上(③)。关键是时序:先存后取、键名一致、第一页通常还没得取。

13.2 AES 解密

真实案例x-猫眼看书 章节地址):

$.path ||@js:
let ids = params.nativeTool.dataByAesDecryptWithBase64StringWithKeyWithIv(result, "f041c49714d39908", "0123456789abcdef")
return params.nativeTool.stringByObject(ids)

解释dataByAesDecryptWithBase64StringWithKeyWithIv(密文, key, iv) 解密,参数二是 key、参数三是 iv。解密结果是对象,要再用 stringByObject 转成字符串才能用。

🔍 原响应 → 规则 → 解析后(x-猫眼看书,接口把真实章节地址加密了,直接取是乱码):

接口返回的 JSON(章节地址被 AES 加密成 base64 一串):

{ "path": "N2FhZTQ4MmY5YjE3ZDQwZQ==...(一串 base64 密文)" }

规则(x-猫眼看书真实源 chapterList.url):

$.path ||@js:
let ids = params.nativeTool.dataByAesDecryptWithBase64StringWithKeyWithIv(result, "f041c49714d39908", "0123456789abcdef")
return params.nativeTool.stringByObject(ids)

一步步变化

步骤
$.path 取到"N2FhZTQ4MmY5..."(base64 密文,没法用)
dataByAesDecrypt...(密文, key, iv) 解密后一个对象,如 { item_id: "7aae482f", ... }
stringByObject(ids) 转字符串"7aae482f9b17d40e"(真实章节 id,能用了)

❌ 新手常踩的错

错误写法后果正确写法
只写 $.path 不解密章节地址是 base64 乱码,打不开必须接 ||@js: 用 nativeTool 解密
key/iv 随便填解出来是乱码或报错key、iv 必须从网页 JS 里逆向找到,一字不差
解密后不 stringByObject拿到的是对象不是字符串,拼 URL 会变 [object Object]对象结果要转成字符串再用

看懂的意义:字段取到的值是 base64/乱码 → 基本就是加密了。key 和 iv 藏在网页的 JS 源码里(F12 搜 AESdecryptCryptoJS),找到后套进 nativeTool 的解密方法。这是高难度场景,新手先跳过,知道”乱码=加密、要找 key/iv”即可。

13.3 在 JS 里再用 XPath

真实案例

let xml = params.nativeTool.XPathParserWithSource(html)
let nodes = xml.queryWithXPath('//ul[@class="txt-list"]/li')
return nodes

解释:当你在 JS 里手上有一段 HTML 字符串,还想用 XPath 去抠——先 XPathParserWithSource(html) 把它变成可查询对象,再 queryWithXPath('路径') 查。相当于”在 JS 内部开一个 XPath”。

🔍 原响应 → 规则 → 解析后(拿一段 JS 里手动请求回来的 HTML 举例,看怎么在 JS 内部再用 XPath 抠字段):

JS 里手上的 HTML 字符串(比如用 WebView 或二次请求拿到的一段 html 变量):

<ul class="txt-list">
  <li><a href="/read/1.html">第一章 出山</a></li>
  <li><a href="/read/2.html">第二章 下山</a></li>
</ul>

规则(完整 JS 里,用 nativeTool 把 html 变成可查询对象再走 XPath):

let xml = params.nativeTool.XPathParserWithSource(html)          // ① 的字符串变成可查询对象
let nodes = xml.queryWithXPath('//ul[@class="txt-list"]/li')     // 在这段 html 里再跑 XPath
let list = []
nodes.forEach(n => {
  let a = n.queryWithXPath('.//a')[0]
  list.push({ title: a.content(), url: a.attributes().href })    // 取文字和 href
})
return { list: list }

App 解析后得到(和普通 XPath 一样拆成 list):

{ "list": [
  { "title": "第一章 出山", "url": "/read/1.html" },
  { "title": "第二章 下山", "url": "/read/2.html" }
]}

❌ 新手常踩的错

错误写法会怎样正确写法
直接对 html 字符串 .match 硬抠结构一复杂正则就崩XPathParserWithSource 转成对象再走 XPath,稳
忘了取节点值的 .content() / .attributes()拿到的是节点对象不是文字/属性文字用 .content()、属性用 .attributes().href
对没转换的普通字符串直接 .queryWithXPath字符串没有这个方法,报错必须先 XPathParserWithSource(html) 转换

看懂这四段的意义:当 HTML 不是 App 自动下载、而是你在 JS 里自己弄到手的一段字符串(对应①)→ 用 XPathParserWithSource 把它变成能查询的对象(②)→ 就能像平常那样用 XPath 抠字段(③)。它让”字段规则里的 XPath”能用在 JS 内部的任意 HTML 片段上。

13.4 打印调试

真实案例

params.nativeTool.log(url);

解释:把变量打印到调试日志,用来看”这一步的值对不对”。调试时的主力工具。


第 14 章 WebView 破反爬

有些站点有反爬(需要 token、要执行 JS 才出数据),普通请求拿不到。这时用 WebView——让 App 开个隐藏浏览器真的把页面跑一遍,在页面里执行 JS 取数据。

真实案例群小说网1 搜索):

@js:
let hp = { "keyword": params.keyWord, "page": params.pageIndex }
let js = `
    let key = document.URL.split("keyword=").pop()
    let token = document.body.innerHTML.toString().match(/token".*?"(.*?)"/)[1]
    let xhr = new XMLHttpRequest()
    xhr.open("POST", "/search/", false)
    xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded")
    xhr.send("_token=" + token + "&keyword=" + key)
    xhr.responseText
`
return {
    "url": config.host,
    "webView": 1,
    "webViewJs": js,
    "webViewJsDelay": 6,
    "httpParams": hp,
    "httpHeaders": config.httpHeaders,
}

逐行解释

  • webView: 1:告诉 App”用隐藏浏览器加载这个页面”。
  • webViewJs:页面加载后在页面里执行的 JS(写在反引号模板串里)。
    • document.URL 是当前页地址,document.body.innerHTML 是页面 HTML——这些是浏览器环境才有的。
    • 从页面里 matchtoken(反爬要的令牌)。
    • new XMLHttpRequest() 在页面内再发一个请求(同步,open 第三参 false 表示同步),带上 token 去真正的搜索接口。
    • 最后一行 xhr.responseText(不写 return)——WebView 里最后一个表达式的值就是返回给 App 的结果。
  • webViewJsDelay: 6:等页面加载 6(秒/单位)再执行 JS,给页面渲染留时间。 怎么仿写:站点有 token 校验、普通请求返回空/被拦 → 上 WebView,在 webViewJs 里取 token 再用 XHR 请求真接口。这是最后手段,比普通请求慢很多,能不用就不用。

🔍 反爬现场 → 规则 → 拿到数据(群小说网这类站,普通请求被 token 挡住,看 WebView 怎么绕):

普通请求直接打搜索接口会怎样(没带 token,被拒):

POST /search/  (直接请求,body 里没有 _token)
    ↓ 服务器返回
403 或一个空列表 / 一段"请开启 JS"的提示页——拿不到书

而用浏览器打开首页时,页面 HTML 里其实藏着 token

<script> window.__config = { "token":"a1b2c3d4e5" } </script>
<body> …首页内容… </body>

规则(群小说网真实源 searchBook.requestInfo):

@js:
let hp = { "keyword": params.keyWord, "page": params.pageIndex }
let js = `
    let key = document.URL.split("keyword=").pop()
    let token = document.body.innerHTML.toString().match(/token".*?"(.*?)"/)[1]
    let xhr = new XMLHttpRequest()
    xhr.open("POST", "/search/", false)
    xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded")
    xhr.send("_token=" + token + "&keyword=" + key)
    xhr.responseText
`
return { "url": config.host, "webView": 1, "webViewJs": js, "webViewJsDelay": 6,
         "httpParams": hp, "httpHeaders": config.httpHeaders }

一步步发生了什么(App 开隐藏浏览器跑这段):

步骤发生的事
App 打开 config.host隐藏浏览器加载首页,页面 HTML 到手(里面带 token)
等 6 秒(webViewJsDelay给页面 JS 渲染留时间,确保 token 已写进 HTML
match(/token".*?"(.*?)"/)[1]从页面 HTML 里抠出 token = "a1b2c3d4e5"
xhr.send("_token=...&keyword=...")在页面内带着 token 发真正的搜索 POST
最后一行 xhr.responseText这个值(真接口返回的 HTML/JSON)交回给 App,当作响应

❌ 新手常踩的错

错误写法后果正确写法
不用 WebView 直接 POST没 token,被 403 或拿到空列表需要 token 的站必须走 webView:1 先取 token
webViewJs 最后写了 return xhr.responseTextWebView 里 return 无效,App 拿不到结果最后只写表达式 xhr.responseText,不加 return
webViewJsDelay 设 0 或太小页面还没渲染出 token 就去抠,match 报错留足延迟(如 6),等 token 出现

看懂的意义:普通请求被拦(403/空/提示开 JS)→ 说明站点要在浏览器环境里执行 JS 才给数据。WebView 就是”让 App 假装成真浏览器跑一遍”,在页面里取到 token/cookie 再发真请求。注意它和普通 JS 的最大区别:结尾是裸表达式,不是 return。慢且重,是最后手段。


第 15 章 完整示例 A:用 XPath 写一个笔趣阁

把前面所有东西串起来,从零写一个笔趣阁类站点。目标:https://www.xbiquwx.la

15.1 建源,填骨架

"sourceName": "笔趣阁-v0701",
"sourceUrl": "https://www.xbiquwx.la",
"sourceType": "text",
"enable": 1,
"weight": "9999",

解释:这些第 2 章讲过——weight 记得加引号,enable 用整数。

15.2 搜索模块 searchBook

requestInfo: https://www.xbiquwx.la/modules/article/search.php?searchkey=%@keyWord
list:        //table[@class="grid"]//tr
bookName:    //td[1]/a
author:      //td[3]
detailUrl:   //td[1]/a/@href
lastChapterTitle: //td[2]/a
status:      //td[6]
wordCount:   //td[4]

解释

  • 请求:%@keyWord 代搜索词。
  • list 定位到搜索结果表格的每一行 <tr>
  • 后面每个字段都是相对每行去取——//td[1]/a 是这行第 1 格里的链接(书名),//td[3] 是第 3 格(作者)。第 0 章讲过 [1] 是第几个、/@href 取属性。

🔍 原响应 → 规则 → 解析后(笔趣阁经典表格结构,看这套 //td[n] 怎么对上每一格):

服务器返回的 HTML(搜”斗破”,结果是一张表,每本书一行 <tr>):

<table class="grid">
  <tr><td>书名</td><td>最新章节</td><td>作者</td><td>字数</td>...</tr>
  <tr>
    <td><a href="/book/斗破苍穹/">斗破苍穹</a></td>
    <td><a href="/book/斗破苍穹/last.html">第1648章 大结局</a></td>
    <td>天蚕土豆</td>
    <td>530万字</td>
    <td>2024-01</td>
    <td>连载</td>
  </tr>
  <tr> …第二本书… </tr>
</table>

规则(每格 <td> 从 1 数,对上一个字段):

字段规则在啃第几格
list//table[@class="grid"]//tr表格里每个 <tr> = 一行 = 一本书
bookName//td[1]/a第 1 格里的 <a> 文字
detailUrl//td[1]/a/@href第 1 格 <a>href
lastChapterTitle//td[2]/a第 2 格(最新章)
author//td[3]第 3 格(作者)
wordCount//td[4]第 4 格(字数)
status//td[6]第 6 格(状态)

App 解析后得到(表头那行 <tr> 没有 <a>,字段取空会被自动跳过):

{ "list": [
  { "bookName": "斗破苍穹", "detailUrl": "/book/斗破苍穹/", "lastChapterTitle": "第1648章 大结局", "author": "天蚕土豆", "wordCount": "530万字", "status": "连载" }
]}

❌ 新手常踩的错

错误写法后果正确写法
list//tr[1] 只取第一行只出 1 本书,或只出表头list 要取所有 <tr>,不加序号
//td 从 0 数写 //td[0]取空(XPath 序号从 1 开始)第一格是 //td[1]
数错格子(把作者写成 //td[2]作者显示成最新章节对照页面一格格数准 <td> 的序号

看懂的意义:表格结构的站没 class 时,就靠数 <td> 的序号对字段。F12 点开一行 <tr> 数清楚每一格是什么(对应①),按序号写 //td[n](对应②),表头那种缺 <a> 的行 App 会自动滤掉(对应③)。

15.3 详情模块 bookDetail

requestInfo: 留空(自动用搜索给的 detailUrl)
bookName: //meta[@property="og:novel:book_name"]/@content
author:   //meta[@property="og:novel:author"]/@content
cover:    //meta[@property="og:image"]/@content
desc:     //meta[@property="og:description"]/@content

解释:详情页请求信息留空——App 自动拿搜索结果里的 detailUrl 去请求(第 3 章)。字段都取 <meta property="og:...">@content,这是绝大多数小说站都有的标准标签,最稳。

🔍 原响应 → 规则 → 解析后(笔趣阁详情页 <head>,看 og 标签怎么补全简介封面):

服务器返回的 HTML(App 拿搜索给的 detailUrl 请求详情页,<head> 里躺着 og 元数据):

<head>
  <meta property="og:novel:book_name" content="斗破苍穹">
  <meta property="og:novel:author" content="天蚕土豆">
  <meta property="og:image" content="https://www.xbiquwx.la/cover/斗破.jpg">
  <meta property="og:description" content="这里是斗气大陆,没有花俏艳丽的魔法……">
</head>

规则(笔趣阁示例 bookDetail,请求信息留空 + og 取字段):

字段规则在啃 HTML 的哪一块
requestInfo留空App 自动用搜索给的 detailUrl 去请求
bookName//meta[@property="og:novel:book_name"]/@contentbook_name 那个 meta 的 content
author//meta[@property="og:novel:author"]/@contentauthor meta 的 content
cover//meta[@property="og:image"]/@contentog
的 content
desc//meta[@property="og:description"]/@content简介 meta 的 content

App 解析后得到(详情是单本书信息,补上了搜索没有的简介、封面):

{ "bookName": "斗破苍穹", "author": "天蚕土豆", "cover": "https://www.xbiquwx.la/cover/斗破.jpg", "desc": "这里是斗气大陆,没有花俏艳丽的魔法……" }

❌ 新手常踩的错(对着①的 head 看为什么错):

错误写法会怎样正确写法
requestInfo 又写一遍搜索地址详情页请求错网址,取到搜索结果页留空,让 App 用 detailUrl
忘了 /@content取到整个 <meta> 标签而非那串值末尾一定加 /@content
详情能拿的字段搜索已有(如书名),又重复配不算错,但没必要详情只补搜索缺的(简介、封面)

看懂这四段的意义:详情模块干的是”补搜索时缺的字段”。请求信息留空吃 detailUrl(②)→ 去 <head> 找 og 标签取简介封面(对应①)→ 拼成单本书信息(③)。详情最稳的数据在 og meta 里,别去正文里一层层抠。

15.4 目录模块 chapterList

list:  //div[@id="list"]/dl/dd
title: //a/text()
url:   //a/@href || @js: return params.queryInfo.detailUrl + result;

解释

  • list 定位每个 <dd>(每章一个)。
  • title<a> 的文字,url<a> 的 href。
  • url 后面接 ||@js: 是因为 href 可能是相对的(如 /book/123/1.html),用 params.queryInfo.detailUrl + result 补成完整地址。(很多站点其实不用补,App 自动补 host——这里演示补法。)

🔍 原响应 → 规则 → 解析后(笔趣阁目录页,看每个 <dd> 怎么变成一章):

服务器返回的目录页 HTML<div id="list"> 里一堆 <dd>,每章一个):

<div id="list">
  <dl>
    <dd><a href="/book/斗破苍穹/1.html">第一章 陨落的天才</a></dd>
    <dd><a href="/book/斗破苍穹/2.html">第二章 斗气大陆</a></dd>
    <dd><a href="/book/斗破苍穹/3.html">第三章 客卿</a></dd>
  </dl>
</div>

规则(笔趣阁示例 chapterList):

字段规则在啃 HTML 的哪一块
list//div[@id="list"]/dl/ddid 是 list 的 div 里每个 <dd> = 一章
title//a/text()单元内 <a> 的文字(章节名)
url//a/@href || @js: return params.queryInfo.detailUrl + result;<a> 的 href,相对地址补上书详情地址前缀

App 解析后得到(有几个 <dd> 就几条,url 已补全):

{ "list": [
  { "title": "第一章 陨落的天才", "url": "https://www.xbiquwx.la/book/斗破苍穹/1.html" },
  { "title": "第二章 斗气大陆", "url": "https://www.xbiquwx.la/book/斗破苍穹/2.html" }
]}

❌ 新手常踩的错(对着①的结构看为什么错):

错误写法会怎样正确写法
list 定位到 <a> 而非 <dd>章节 title/url 取值单元错乱list 圈住每章的外框 <dd>
相对 href 不补前缀、App 也没自动补点章节请求 1.html 这种残缺地址失败相对地址接 params.queryInfo.detailUrl 或靠 App 自动补 host
目录是倒序(最新在前)没处理章节从大结局往前排,观感差需正序时在 `

看懂这四段的意义:目录页就是”一堆 <dd><a>”(对应①),list 圈住每个 <dd>title/url 在单元内取 <a>(②)→ 拼出章节列表(③)。遇到任何目录页,F12 看章节外层是什么标签(dd/li/p),写进 list,剩下照套。

15.5 正文模块 chapterContent

content: //div[@id="content"]/text()

解释:正文就在 id 是 content 的 div 里,/text() 取纯文字。若有广告,按第 10 章加 ||@js: 清洗。

🔍 原响应 → 规则 → 解析后(笔趣阁正文页,看一条 XPath 怎么把正文取出来):

服务器返回的正文 HTML(正文老实躺在 <div id="content"> 里,用 <br> 分行):

<div id="content">
  萧炎缓缓睁开双眼,眸中精光一闪而逝。<br>
  他站起身,望向远方的天际。<br>
</div>

规则(笔趣阁示例源 chapterContent.content,就一行):

字段规则在啃 HTML 的哪一块
content//div[@id="content"]/text()id 是 content 的 div 里的纯文字

App 解析后得到(正文本来就干净,取到即成品):

  萧炎缓缓睁开双眼,眸中精光一闪而逝。
  他站起身,望向远方的天际。

❌ 新手常踩的错

错误写法会怎样正确写法
//div/text() 不加 id页面里 div 一大堆,取到导航/广告 div带上 [@id="content"] 锁定正文那个 div
正文夹广告却不清洗广告混进正文,观感差按第 10 章接 ||@js: 清洗
正文是 <p> 段落却用 /text()有的站丢了段落结构<p> 结构可直接取 //div[@id="content"](不加 text)

看懂这四段的意义:正文取值是四类里最简单的一步——F12 找到正文那个容器的 id(对应①)→ 一行 //div[@id="xxx"]/text()(②)→ 干净就是成品(③),脏了再按第 10 章清洗。先看正文脏不脏,别上来就套清洗代码。

15.6 分类模块 bookWorld

moreKeys: {"pageSize":"30","requestFilters":{"玄幻":"1","都市":"3"}}
requestInfo: https://www.xbiquwx.la/list/%@filter_%@pageIndex.html
list:      //div[@id="newscontent"]/div[1]/ul/li
bookName:  //span[2]/a
author:    //span[4]
detailUrl: //span[2]/a/@href

解释requestFilters 定义分类选项,%@filter 代选中值、%@pageIndex 代页码。列表和字段写法同搜索。

🔍 输入 → 规则 → 拼出的请求(用户在分类页选”玄幻”翻到第 2 页,看 %@filter/%@pageIndex 怎么代入):

App 给你的输入(用户在发现页点了”玄幻”,翻到第 2 页):

requestFilters 里 "玄幻":"1"  → 选中值 = 1
params.pageIndex = 2

规则(笔趣阁示例源 bookWorld.分类 的 requestInfo,URL 模板):

https://www.xbiquwx.la/list/%@filter_%@pageIndex.html

App 替换占位符后发出的真实请求

GET https://www.xbiquwx.la/list/1_2.html
                                 └┘ └┘
                          %@filter  %@pageIndex

❌ 新手常踩的错

错误写法会怎样正确写法
requestFilters 用数组 [...]分类必须是字典 {显示名:值},数组会闪退{"玄幻":"1","都市":"3"}
URL 写死分类值 list/1_%@pageIndex用户选别的分类还是查玄幻分类位置用 %@filter
moreKeyspageSize 写成数字 30部分字段要字符串,数字可能异常稳妥写 "pageSize":"30"

看懂这四段的意义:分类页 = requestFilters 定义”显示名→值”(对应①)→ requestInfo 里分类位置用 %@filter、页码用 %@pageIndex(②)→ 用户选中后 App 代入拼出真实 URL(③)。列表和字段的取法与搜索完全一样,只是多了个筛选。

到这一个能搜、能看详情、能翻目录、能读正文、能逛分类的完整书源就写好了。


第 16 章 完整示例 B:同一个站用纯 JS 写

同一个笔趣阁,如果 XPath 不好使(页面结构乱),可以整个用 JS 正则解析。解析方式选”普通字符串”,写 functionName 函数。

16.1 搜索请求(JS)

@js:
return 'https://www.xbiquwx.la/modules/article/search.php?searchkey=' + encodeURI(params.keyWord);

解释:直接返回拼好的 URL。encodeURI 把中文关键词编码(第 3 章)。

🔍 原输入 → 规则 → 发出的请求(用户搜”斗破”,看这行 JS 怎么拼出真实搜索地址):

App 给你的输入(用户在搜索框搜了”斗破”):

params.keyWord = "斗破"

规则(笔趣阁 searchBook.requestInfo,纯 JS 返回 URL 字符串):

@js:
return 'https://www.xbiquwx.la/modules/article/search.php?searchkey=' + encodeURI(params.keyWord);

App 据此发出的真实请求encodeURI 把中文编码后拼进 URL):

GET https://www.xbiquwx.la/modules/article/search.php?searchkey=%E6%96%97%E7%A0%B4

❌ 新手常踩的错

错误写法会怎样正确写法
直接 + params.keyWord 不编码中文进 URL 变乱码,服务器搜不到encodeURIencodeURIComponent 编码
返回时忘了 returnrequestInfo 拿到 undefined,请求发不出去JS 里一定要 return 出拼好的 URL
URL 只写 search.php?searchkey=(漏域名)相对地址,App 不一定补对返回完整含 https:// 的绝对地址最稳

看懂这四段的意义:搜索请求最简单的 JS 写法就是”拼一条 URL 字符串 return 出去”——把 params.keyWord(①)编码后接到搜索接口后面(②)→ App 拿去发请求(③)。能用 URL 模板(第 3.2 节)就别写 JS,模板搞不定拼接时才用这招。

16.2 搜索解析(JS 函数)

function functionName(config, params, result) {
  let list = [];
  let reg = /<td.*?odd.*?href="(.*?)">(.*?)<[\s\S]*?even.*?<a.*?>(.*?)<[\s\S]*?odd.*?>(.*?)</gim;
  let tem;
  while (tem = reg.exec(result)) {
    list.push({
      detailUrl: tem[1],
      bookName: tem[2],
      lastChapterTitle: tem[3],
      author: tem[4]
    });
  }
  return { 'list': list, 'more': false };
}

逐行解释

  • function functionName(config, params, result):解析函数必须叫 functionName,App 才认。result 是服务器返回的整页 HTML。
  • let list = []:准备一个空数组装结果。
  • let reg = /.../gim:一条正则,用 (.*?) 捕获书链接、书名、最新章、作者四段。[\s\S]*? 表示”任意字符(含换行)尽量少”。gim = 全局+忽略大小写+多行。
  • while (tem = reg.exec(result)):反复执行正则,每次 exec 匹配到一本书,tem[1]~tem[4] 是四个捕获组。while 一直循环到抓完所有书(第 0 章讲过)。
  • list.push({...}):把这本书的字段组成对象塞进数组。
  • return:返回含 list 的对象(搜索/列表的固定要求)。more:false 表示没有下一页。 怎么仿写:把结果页 HTML 里”一本书”的 HTML 片段找出来,写一条正则用 (.*?) 圈住要的字段,while + exec 循环抓,push 进 list。

🔍 原响应 → 规则 → 解析后(同一个笔趣阁搜索页,看正则的四个捕获组分别抓到什么):

服务器返回的 HTML(表格每行一本书,class 在 odd/even 之间交替):

<tr>
  <td class="odd"><a href="/book/6/6909/">斗破苍穹</a></td>
  <td class="even"><a href="/book/6/6909/12345.html">第1648章 大结局</a></td>
  <td class="odd">天蚕土豆</td>
  <td class="even">1500万字</td>
</tr>

规则(第 16.2 那条正则,四个 (.*?) 依次抓 链接/书名/最新章/作者):

/<td.*?odd.*?href="(.*?)">(.*?)<[\s\S]*?even.*?<a.*?>(.*?)<[\s\S]*?odd.*?>(.*?)</gim

每次 exec 抓到的捕获组

捕获组抓到的值塞进字段
tem[1]/book/6/6909/detailUrl
tem[2]斗破苍穹bookName
tem[3]第1648章 大结局lastChapterTitle
tem[4]天蚕土豆author

while 循环跑完,list 里每本书一条,最终 return { list:[...], more:false }

❌ 新手常踩的错

错误写法后果正确写法
正则用 .* 不加 ?贪婪匹配,一条吞掉整页多本书.*? 非贪婪,一次只吃一本
. 想跨行匹配. 默认不匹配换行,HTML 一换行就断跨行处用 [\s\S]*?
忘了 while,只写一次 exec只抓到第一本书while(tem=reg.exec(result)) 循环抓完

看懂的意义:纯 JS 正则解析的关键是”照着①的 HTML 片段写②的正则”——每个 (.*?) 对准你要的一段,顺序和 HTML 里出现的顺序一致。tem[1]tem[2]… 就是从左到右第几个括号。这是 XPath 实在啃不动时的兜底招。

16.3 正文解析(JS 截取)

function functionName(config, params, result) {
  let beginStr = '<div id="content">';
  let beginIndex = result.indexOf(beginStr);
  if (beginIndex > 0) {
    let subStr = result.substr(beginIndex + beginStr.length);
    let endIndex = subStr.indexOf('</div>');
    let content = subStr.substr(0, endIndex);
    return { 'response': content, 'removeHtmlKeys': 'response' };
  }
  return undefined;
}

逐行解释

  • indexOf('<div id="content">'):找正文起始标签的位置。
  • substr(起点):从正文开始截到末尾。
  • indexOf('</div>') 找结束标签,substr(0, endIndex) 截出正文那一段。
  • returnresponse 装正文,removeHtmlKeys 让 App 把里面的 HTML 标签清掉。
  • 找不到就 return undefined(解析失败)。 怎么仿写:知道正文在”某起始标签”和”某结束标签”之间时,用 indexOf 找两个位置,substr 截中间。

🔍 原响应 → 规则 → 解析后(笔趣阁正文页,看 indexOf + substr 怎么把正文从整页 HTML 里夹出来):

服务器返回的正文页 HTML(正文夹在 <div id="content"></div> 之间,前后是导航/广告):

<div class="nav">上一章 目录 下一章</div>
<div id="content">
  萧炎缓缓睁开双眼,眸中精光一闪而逝。<br>
  "三十年河东,三十年河西!"
</div>
<div class="footer">本站广告……</div>

规则(笔趣阁 chapterContent,纯 JS 用双 indexOf 夹取):

function functionName(config, params, result) {
  let beginStr = '<div id="content">';
  let beginIndex = result.indexOf(beginStr);
  let subStr = result.substr(beginIndex + beginStr.length);
  let endIndex = subStr.indexOf('</div>');
  return { 'response': subStr.substr(0, endIndex), 'removeHtmlKeys': 'response' };
}

一步步变化(看两个 indexOf 把范围一点点收窄到正文):

步骤结果
indexOf('<div id="content">')找到正文起始标签的位置
substr(起始+标签长)砍掉正文前面的导航,剩”正文 + 后面的 footer”
indexOf('</div>')在上一步基础上找正文结束标签
substr(0, endIndex)砍掉正文后面的 footer,只剩正文

得到:  萧炎缓缓睁开双眼……"三十年河东,三十年河西!"removeHtmlKeys 会把 <br> 等标签清掉)

❌ 新手常踩的错

错误写法会怎样正确写法
substr(beginIndex) 忘了加 beginStr.length正文前面粘着 <div id="content"> 标签起点要 + beginStr.length 跳过标签本身
不找 </div> 直接返回后半段正文后面拖着 footer 广告再用 indexOf('</div>') + substr(0,endIndex) 掐尾
找不到起始标签(返回 -1)不判断substr(-1+长度) 截出错乱内容if(beginIndex>0) 守卫,找不到 return undefined

看懂这四段的意义:正文被固定的起始/结束标签夹着时(对应①),用两个 indexOf 定位、两个 substr 掐头去尾(②③)就能精确夹出正文。这是 XPath 取不干净时的手动截取法,removeHtmlKeys:'response' 让 App 顺手清掉正文里的 HTML 标签。


第 17 章 防闪退规则 + 常见坑

17.1 防闪退(2.56.1 编辑器对类型敏感)

错误写法正确写法原因
"weight": 9999"weight": "9999"权重必须是字符串,数字会闪退
"enable": "1""enable": 1启用必须是整数
bookWorld 用数组 [...]用字典 {...}分类必须字典
requestFilters 乱格式规范 JSON 或字符串格式错会闪退

保存三连测(写完必做):① 不改任何字段保存一次 ② 改书名一个字保存 ③ 改一个规则保存。三次都不闪退才算过。

17.2 常见坑(来自真实源的习惯)

情境真实做法
取到”作者:张三”要去前缀`
正文广告很多正则数组 + for 循环(第 10.3 节)
链接相对/缺协议replace("//","https://"),但多数 App 自动补 host
正文翻页翻到下一章加守卫 if(result.indexOf('章')<0)(第 11.3 节)
繁体站”辰东”显示错result.replace("辰东","辰東")(多个源都这么改)
状态是 0/1/布尔三元 result==0?'连载':'完结'(第 8 章)
中文进 URLencodeURIComponent(第 3 章)
接口正文加密JSONPath 取密文 → nativeTool 解密(第 10.6 节)
有 token 校验WebView + XHR(第 14 章)
章节字段一律 // 开头,别用 ./(会污染整行文本)

🔍 原响应 → 规则 → 解析后(拿”繁体站辰东显示错”这个真实坑举例,看一条 replace 怎么救场):

服务器返回的作者名(繁体站,简体”辰东”在该站字体下显示成乱码/空白):

辰东

规则(多个真实源都这么改:取到作者后把简体换成繁体):

字段规则做了什么
author//div[@class="author"] ||@js: return result.replace("辰东","辰東")取到”辰东”,把简体”东”换成繁体”東”

App 解析后得到(换成繁体后该站正常显示):

{ "author": "辰東" }

❌ 新手常踩的错

错误写法会怎样正确写法
不处理直接显示繁体站下简体字乱码/缺字针对性 replace 成繁体
replace 全文替换常见字误伤正文里的正常字只在出问题的字段(如 author)上改
以为是编码 bug 去改请求头白折腾,问题在字形不在编码直接字符替换最省事

看懂这四段的意义:这张表里每一条都是真实源反复出现的”坑 + 解法”。原理都一样——取到的值不合心意(对应①)→ 接 ||@js: 用 replace/三元/编码函数加工(②)→ 得到能用的值(③)。遇到没见过的坑,先想”取到的值差在哪”,再挑表里对应的招。


第 18 章 可直接复制的 JSON 模板

18.1 带注释讲解版(看的,别直接导入——JSON 不允许注释)

{
  "myBookSource": {                          // 别名,全局唯一,随便起
    "sourceName": "站点名-v0701",             // 只写站点名
    "sourceUrl": "https://你的站点",           // 首页,JS 里用 config.host
    "sourceType": "text",                     // 小说固定 text
    "enable": 1,                              // 整数 1/0
    "weight": "9999",                         // 字符串!数字会闪退
    "miniAppVersion": "1.0.0",
    "lastModifyTime": "1772463417",

    "searchBook": {
      "actionID": "searchBook",
      "parserID": "DOM",                      // DOM=按 HTML 解析
      "requestInfo": "https://你的站点/search?key=%@keyWord&page=%@pageIndex",
      "list": "//div[@class='result']//li",
      "bookName": "//h3/a",
      "author": "//span[@class='author']",
      "detailUrl": "//h3/a/@href",
      "cover": "//img/@src"
    },
    "bookDetail": {
      "actionID": "bookDetail",
      "parserID": "DOM",
      "requestInfo": "",                      // 留空=用上层 detailUrl
      "bookName": "//meta[@property='og:novel:book_name']/@content",
      "author": "//meta[@property='og:novel:author']/@content",
      "cover": "//meta[@property='og:image']/@content",
      "desc": "//meta[@property='og:description']/@content",
      "removeHtmlKeys": "desc"
    },
    "chapterList": {
      "actionID": "chapterList",
      "parserID": "DOM",
      "requestInfo": "",
      "list": "//div[@id='list']/dl/dd/a",
      "title": "//text()",
      "url": "//@href"
    },
    "chapterContent": {
      "actionID": "chapterContent",
      "parserID": "DOM",
      "requestInfo": "",
      "content": "//div[@id='content']//text()"
    },
    "bookWorld": {
      "actionID": "bookWorld",
      "parserID": "DOM",
      "moreKeys": "{\"pageSize\":\"30\",\"requestFilters\":{\"玄幻\":\"1\",\"都市\":\"3\"}}",
      "requestInfo": "https://你的站点/list/%@filter_%@pageIndex.html",
      "list": "//div[@id='newscontent']//li",
      "bookName": "//span[2]/a",
      "author": "//span[4]",
      "detailUrl": "//span[2]/a/@href"
    }
  }
}

18.2 纯净可导入版(删了注释,替换占位后可直接用)

{
  "myBookSource": {
    "sourceName": "站点名-v0701",
    "sourceUrl": "https://你的站点",
    "sourceType": "text",
    "enable": 1,
    "weight": "9999",
    "miniAppVersion": "1.0.0",
    "lastModifyTime": "1772463417",
    "searchBook": {
      "actionID": "searchBook",
      "parserID": "DOM",
      "requestInfo": "https://你的站点/search?key=%@keyWord&page=%@pageIndex",
      "list": "//div[@class='result']//li",
      "bookName": "//h3/a",
      "author": "//span[@class='author']",
      "detailUrl": "//h3/a/@href"
    },
    "bookDetail": {
      "actionID": "bookDetail",
      "parserID": "DOM",
      "requestInfo": "",
      "bookName": "//meta[@property='og:novel:book_name']/@content",
      "author": "//meta[@property='og:novel:author']/@content",
      "cover": "//meta[@property='og:image']/@content",
      "desc": "//meta[@property='og:description']/@content",
      "removeHtmlKeys": "desc"
    },
    "chapterList": {
      "actionID": "chapterList",
      "parserID": "DOM",
      "requestInfo": "",
      "list": "//div[@id='list']/dl/dd/a",
      "title": "//text()",
      "url": "//@href"
    },
    "chapterContent": {
      "actionID": "chapterContent",
      "parserID": "DOM",
      "requestInfo": "",
      "content": "//div[@id='content']//text()"
    },
    "bookWorld": {
      "actionID": "bookWorld",
      "parserID": "DOM",
      "moreKeys": "{\"pageSize\":\"30\",\"requestFilters\":{\"玄幻\":\"1\",\"都市\":\"3\"}}",
      "requestInfo": "https://你的站点/list/%@filter_%@pageIndex.html",
      "list": "//div[@id='newscontent']//li",
      "bookName": "//span[2]/a",
      "author": "//span[4]",
      "detailUrl": "//span[2]/a/@href"
    }
  }
}

第 19 章 函数/对象逐个详解(速查)

看不懂某个名字时回这里查。分四类:你写的函数、系统对象、JS 自带方法、nativeTool 方法

19.1 系统对象(只读,别当自己变量名)

名字是什么常用成员
config书源自身配置config.host(首页)、config.httpHeaders(全局请求头)
params本次请求上下文keyWord pageIndex filters queryInfo responseUrl nativeTool
result上一步产物选择器取到的值 / 服务器返回内容 / 上级传的 URL

params 细分:

成员含义
params.keyWord用户搜的词
params.pageIndex当前页码(从 1)
params.filters分类筛选选中值(params.filters.type 等)
params.queryInfo上一级传下来的信息(含 detailUrl、书名等)
params.responseUrl上次响应的真实网址(拼相对链接/当 Referer)
params.nativeTool原生工具入口

19.2 你写的函数:functionName

function functionName(config, params, result) { ... return 结果; }

名字不能改。解析方式选”普通字符串/完整JS”时 App 调它。搜索/列表返回 {list:[...]},正文返回 {response:正文},失败返回 undefined

19.3 JS 自带方法(语言本身的,非香色特有)

名字作用
encodeURIComponent(s)中文/符号转 URL 安全编码(编参数值)
encodeURI(s)同上,但保留 URL 结构符号(编整条 URL)
str.replace(/正则/g,"")替换/删除匹配内容
str.split("分隔")[n]按分隔切成数组取第 n 段
str.match(/正则/)[1]正则捕获,取第 1 个括号组
str.indexOf("子串")找位置,找不到 -1
str.substr(起, 长) / substring(起,终)截取子串
str.trim()去首尾空白
arr.push(x)往数组尾加元素
arr.join("\n")数组连成字符串
正则.exec(str)匹配一次,配 while 逐条抓
JSON.stringify(obj)对象转 JSON 字符串(拼 POST body)

19.4 nativeTool 方法(本批源真实用到的)

名字作用
setCache(k,v) / getCache(k)跨请求存/取
log(x)打印调试
md5Encode(s)算 MD5 签名
deviceId()取设备号
XPathParserWithSource(html) + .queryWithXPath(xp)JS 内再用 XPath
dataByAesDecryptWithBase64StringWithKeyWithIv(密文,key,iv)AES 解密
Base64DecodeToString(密文,key,算法,iv)Base64+AES 解密
stringByObject(obj)对象转字符串(解密后转文本)
cookiesByUrl(url)取站点 Cookie 列表

XPath 解析结果节点的取值:.content(文本)、.attributes(属性字典)、.tagName(标签名)、.raw(原始HTML)。


第 20 章 漫画源:正文返回图片数组

前 19 章讲的都是小说sourceTypetext 或不写)。但香色同一套结构也能写漫画/听书/视频——搜索、详情、目录三个模块的写法几乎和小说一样,唯一大改的是”正文”模块:小说正文返回文字,漫画返回一串图片地址,听书/视频返回一个媒体播放地址。这三章就专讲各自的正文怎么写。本章的案例全部取自新解密的 347 个真实漫画源。

20.1 先认字段:漫画和小说差在哪

顶层多一个类型标记(可选,但推荐写,App 用它切换阅读器):

"sourceType": "comic"

searchBook / bookDetail / chapterList 三个模块和小说写法完全一致——照第 5~9 章写即可。漫画目录甚至常见”倒序”,直接在 list 后面接一句 JS:

//ul[@id='ul_chapter1']/li || @js:
return result.reverse();

||@js: 混用(第 8 章):先用 XPath 取到章节 <li> 列表,再 reverse() 把倒序的目录翻正。JSONPath 站同理:data/comics||@js: return result.reverse();

真正不同的是 chapterContentcontent 字段:它不返回文字,而是返回这一话所有图片的 URL

20.2 写法一:HTML 站——直接取 <img> 的 src 列表

最简单的漫画站,一话的图片就是页面里一串 <img>

//article[@class="article-content"]/p/img/@src

和小说取正文一样是一条 XPath,只不过取的是 @src 属性。App 认出这是漫画源后,会把取到的多张图片依次纵向排列显示。关键:结尾是 /@src 且能命中多张图,App 就当漫画渲染。

🔍 原响应 → 规则 → 解析后(拿一个 HTML 漫画站的看图页举例,看一条 /@src 怎么变成一话多图):

服务器返回的看图页 HTML(一话的每张图就是一个 <img>,全在正文容器里):

<article class="article-content">
  <p><img src="https://img.mh.com/1/001.jpg"></p>
  <p><img src="https://img.mh.com/1/002.jpg"></p>
  <p><img src="https://img.mh.com/1/003.jpg"></p>
</article>

规则(漫画源 chapterContent.content,就一条 XPath 取 @src):

字段规则在啃 HTML 的哪一块
content//article[@class="article-content"]/p/img/@src正文容器里每个 <img>src,命中多张

App 解析后得到(取到一个图片地址数组,App 按顺序纵向铺开当漫画看):

{ "content": [
  "https://img.mh.com/1/001.jpg",
  "https://img.mh.com/1/002.jpg",
  "https://img.mh.com/1/003.jpg"
]}

❌ 新手常踩的错(对着①的 HTML 看为什么错):

错误写法会怎样正确写法
content: //article[...]/p/img(漏 /@src取到 <img> 标签本身、不是图片地址,App 渲染不出图结尾一定要 /@src 取地址
//img/@src(不限定容器)页面里 logo、广告图的 <img> 也被取进来,混入杂图带上父容器 //article[@class="article-content"]//img/@src
图床校验 Referer 却没带取到地址了但一片裂图图裂时改用 20.3 的 JSON 包装法补 Referer

看懂这四段的意义:漫画看图页最简单的形态就是”一串 <img>”(对应①)→ 一条 XPath 定位容器内的 img@src(②)→ App 拿到多张图地址纵向铺开(③)。和小说取正文是同一套 XPath 手法,区别只在结尾取 @src、且要命中多张。

20.3 写法二:接口站——取图片数组再包成 urls

接口返回 JSON、图片在某个数组里时,用 JSONPath 取数组,再用 JS 包成 App 认识的格式:

data/images || @js:
return JSON.stringify({'urls':result, 'httpHeaders':{'Referer':params.responseUrl}});
  • data/images:JSONPath 取到图片 URL 数组(result 现在是个数组)。
  • ||@js: 收尾:包成 结构——漫画的多图必须放在 urls(复数)键里,这是和听书/视频(用单数 url)最大的区别。
  • 'httpHeaders':漫画防盗链的关键。很多图床校验 Referer,不带就是一片裂图;params.responseUrl 正好是当前话的页面地址,拿它当 Referer 最稳。

🔍 原响应 → 规则 → 解析后(拿快看类接口举例,看图片数组怎么包成 App 认识的 urls):

服务器返回的 JSON(看图接口,图片地址在一个数组里):

{
  "data": {
    "images": [
      "https://f.kkmh.com/1/001.webp",
      "https://f.kkmh.com/1/002.webp"
    ]
  }
}

规则(漫画接口源 chapterContent.content,JSONPath 取数组 + JS 包装):

data/images || @js:
return JSON.stringify({'urls':result, 'httpHeaders':{'Referer':params.responseUrl}});

App 解析后得到result 是那个图片数组,包进 urls 复数键,附上防盗链头):

{ "urls": ["https://f.kkmh.com/1/001.webp","https://f.kkmh.com/1/002.webp"],
  "httpHeaders": { "Referer": "https://www.kkmh.com/comic/1" } }

❌ 新手常踩的错(对着②的包装看为什么错):

错误写法会怎样正确写法
用单数 {'url':result}漫画多图必须放复数 urls,单数 App 只当一张/不认多图一律 {'urls':result}
data/images 直接返回不 JSON.stringifyApp 拿到裸数组,识别不了多图+请求头结构JSON.stringify({...}) 包成字符串
不带 Referer图床防盗链,全是裂图'httpHeaders':{'Referer':params.responseUrl}

看懂这四段的意义:接口把图片放在数组里(对应①)→ JSONPath 取到数组、||@js: 包成 {'urls':[...],'httpHeaders':{...}}(②)→ App 按 urls 渲染多图、按 Referer 过防盗链(③)。记住漫画多图是复数 urls、且几乎都要补 Referer——这是和听书/视频单数 url 的分水岭。

深度搜索版(不确定图片数组在第几层时,用 $..,见第 7.3):

$..url||@js:
return JSON.stringify({'urls':result});

20.4 写法三:图片地址被 JS 混淆——eval 解出来

有些漫画站把图片列表藏在一段混淆 JS 里(页面源码里是 eval(...) 打包的),要先执行它拿到变量:

@js:
try{
  eval(result.match(/(eval\([\s\S]+?)var jPicList/)[1]);
}catch(e){}
return JSON.stringify({'url':picTree, 'httpHeaders':{'Referer':params.responseUrl}});

逐句拆解:

  1. result 是整段网页源码。result.match(/(eval\([\s\S]+?)var jPicList/)[1]:用正则截出从 eval( 开始、到 var jPicList 之前的那段打包代码([\s\S]+? 是”任意字符非贪婪”,. 默认不匹配换行,所以用 [\s\S])。
  2. eval(...):执行这段代码,它会在当前作用域里定义出 picTree(图片树变量,名字由站点决定)。
  3. try/catch 兜底:万一站点改版正则没匹配上,不至于整个源崩掉。
  4. 最后把 picTree 包成结果返回,同样带上 Referer 防盗链。

⚠️ eval 只在你信任且看懂这段代码时用;它的原理见第 9.4/9.5(用 JS 手动建表)。

🔍 原响应 → 规则 → 解析后(拿大树漫画类站举例,看 eval 怎么从混淆 JS 里解出图片树):

服务器返回的 HTML(图片列表不是明文,而是打包在一段 eval(...) 混淆 JS 里):

<script>
  eval(function(p,a,c,k,e,d){...}('...压缩后的代码...'));
  var jPicList = ...;   // 上面 eval 执行后才会定义出 picTree
</script>

规则(漫画源 chapterContent.content,截出 eval 段并执行):

@js:
try{
  eval(result.match(/(eval\([\s\S]+?)var jPicList/)[1]);
}catch(e){}
return JSON.stringify({'url':picTree, 'httpHeaders':{'Referer':params.responseUrl}});

一步步变化

步骤发生了什么
result.match(/(eval\([\s\S]+?)var jPicList/)[1]从整页源码里截出 eval(...)var jPicList 之前那段打包代码
eval(那段代码)执行它,当前作用域里冒出 picTree(站点定义的图片树变量)
JSON.stringify({'url':picTree,...})picTree 包成结果,附 Referer 返回

❌ 新手常踩的错(对着②看为什么错):

错误写法会怎样正确写法
. 想匹配那段跨行代码. 不匹配换行,一换行就截断,match 失败跨行用 [\s\S]+?
不加 try/catch站点改版正则没命中,[1] 报错整源崩try{...}catch(e){} 兜底
eval 后写错图片变量名picTree 名字由站点决定,抄错取到 undefinedF12 看 eval 执行后真实的变量名再填

看懂这四段的意义:图片列表被 eval 打包藏起来时(对应①)→ 正则截出那段 eval 代码、eval() 执行让隐藏变量现形(②③)→ 再包成 urls/url 返回。这是漫画最硬的一档,eval 有风险,只在看懂来源时用,新手认得出”图片被混淆了”即可。

20.5 漫画正文小结

场景content 写法关键点
HTML 站,图片是 <img>//...img/@src结尾取 @src,命中多张
接口站,图片在数组里data/images || @js: return JSON.stringify({'urls':result,...})多图放 urls 复数键
图片藏在混淆 JS@js: + eval(...) 解出变量eval 再包装

三条铁律:① 多图用 urls(复数);② 几乎都要 Referer 防盗链;③ 搜索/详情/目录照小说写,只有正文特殊。


第 21 章 听书源:正文嗅探音频地址

听书(sourceType": "audio")的搜索/详情/目录也和小说一样,差异同样只在正文:正文要返回的是一个音频文件地址(mp3/m4a)。难点在于——音频地址往往不在页面 HTML 里,而是播放器用 JS 动态请求的,得靠 WebView 嗅探捞出来。案例取自 194 个真实听书源。

21.1 类型标记与目录

"sourceType": "audio"

目录 chapterList 里每条就是”一集”,title 取集名、url 取该集播放页地址,写法与小说完全相同。

21.2 写法一:地址就在接口里(最省事)

接口直接返回音频 URL 时,chapterContentcontent 用 JSONPath 取即可:

$.data.content

番茄听书这类 API 站,请求某集接口就直接回 {data:{content:"http://xxx.mp3"}},一条 JSONPath 搞定。这是最理想的情况。

21.3 写法二:WebView 嗅探音频(最常见)

多数网页听书站,音频是播放器 JS 加载的,源码里没有。这时 chapterContentrequestInfo(注意是请求信息,不是 content)用 WebView 打开页面,让 App 自动嗅探出媒体流:

@js:
return {'url':result, 'webView':'', 'sourceRegex':'.*\\.(mp3|m4a).*',
        'webViewSkipUrls':['hm.baidu.com', 'doubleclick.net', 'googlesyndication.com']};

逐个键解释(这是听书的核心套路):

  • 'url':result:上级目录传下来的这一集播放页地址。
  • 'webView':'':开启 WebView 加载模式(空字符串即”开”,见第 14 章)。App 会真的用浏览器内核跑这个页面,执行它的 JS。
  • 'sourceRegex':'.*\\.(mp3|m4a).*'嗅探规则。WebView 加载过程中会发出很多请求,App 用这个正则去匹配请求 URL,命中的那条(.mp3/.m4a)就是音频地址。注意 JSON 里反斜杠要写两条 \\.
  • 'webViewSkipUrls':[...]:黑名单,跳过广告/统计域名,别让它们拖慢或干扰嗅探。

配了 sourceRegex 后,content 字段通常留一个占位(比如 |)或直接不依赖它——因为地址是嗅探自动填的。

🔍 播放页 → 规则 → 嗅探到音频(拿全民听书这类网页站举例,看 WebView 怎么把藏起来的 mp3 嗅出来):

服务器返回的播放页 HTML(源码里根本没有 mp3 地址,音频是播放器 JS 后续动态请求的):

<div id="player"></div>
<script src="/static/player.js"></script>
<!-- 真正的 https://cdn.xxx.com/audio/9527.mp3 由 player.js 运行时才请求,源码里看不到 -->

规则chapterContent.requestInfo,开 WebView + 嗅探正则):

@js:
return {'url':result, 'webView':'', 'sourceRegex':'.*\\.(mp3|m4a).*',
        'webViewSkipUrls':['hm.baidu.com', 'doubleclick.net']};

App 加载 WebView 后嗅探到的结果(播放器 JS 一跑,发出音频请求,被 sourceRegex 命中):

WebView 发出的请求里,命中 .*\.(mp3|m4a).* 的那条:
https://cdn.xxx.com/audio/9527.mp3   ← 自动当成 content 交给播放器

❌ 新手常踩的错(对着①的”源码没地址”看为什么错):

错误写法会怎样正确写法
直接 XPath 取 //audio/@src源码里压根没有 audio 标签,取空音频是 JS 动态加载的,必须开 webView 嗅探
sourceRegex.*\.(mp3) 漏了 m4a只认 mp3,m4a 站嗅不到后缀写全 (mp3|m4a)
JSON 里反斜杠只写一条 \.转义不对,正则失效JSON 里写两条 \\.

看懂这四段的意义:网页听书站的音频地址源码里没有(对应①)→ 开 webView 让 App 真跑一遍播放器 JS → 用 sourceRegex 盯住所有请求、命中 mp3/m4a 的就是音频(②→③)。源码里找不到地址 = 该上 WebView 嗅探了。

21.4 写法三:从已发生的请求里回捞(requestUrls)

有的站 WebView 已经把音频请求发过了,可以直接从 params.requestUrls(本页所有已发请求列表)里倒序找出媒体链接:

@js:
for(let i = params.requestUrls.length-1; i>=0; i--) {
  let url = params.requestUrls[i];
  if(url.match(/.*\.(mp3|m4a).*/) != undefined) {
    let obj = {'url':url, 'httpHeaders':{
      'User-Agent':'Mozilla/5.0 ...Safari/605.1.15',
      'Referer':url, 'Range':'bytes=0-'}};
    return JSON.stringify(obj);
  }
}
return undefined;
  • params.requestUrls:WebView 期间发生过的所有请求 URL(App 提供)。倒序(从后往前)遍历,因为音频请求通常在最后才发。
  • 用正则挑出 mp3/m4a,包成 返回。
  • 'Range':'bytes=0-'音频/视频流常需要的头,表示”从第 0 字节开始要整个文件”,不少服务器不带 Range 会拒绝或只给一段。
  • 'Referer':url:防盗链,同漫画。
  • 找不到就 return undefined(第 4.3:失败必须返回 undefined,别返回空字符串)。

🔍 已发请求 → 规则 → 捞出地址(WebView 已经放过音频,直接从历史请求里回捞,不用再嗅探):

App 记录的 params.requestUrls(这一集页面加载过程中,App 已发出的所有请求,按发生顺序排):

[0] https://tsw.com/player.js
[1] https://tsw.com/api/track?id=99
[2] https://hm.baidu.com/hm.js          ← 统计,无关
[3] https://cdn.tsw.com/audio/99_01.m4a ← 真正的音频,排在靠后

规则chapterContent.content,倒序遍历 requestUrls 挑出媒体链接):

@js:
for(let i = params.requestUrls.length-1; i>=0; i--){
  let url = params.requestUrls[i];
  if(url.match(/.*\.(mp3|m4a).*/) != undefined){
    return JSON.stringify({'url':url, 'httpHeaders':{'Referer':url, 'Range':'bytes=0-'}});
  }
}
return undefined;

App 解析后得到(倒着找,第一个命中 m4a 的就是它):

{ "url": "https://cdn.tsw.com/audio/99_01.m4a", "httpHeaders": { "Referer": "...", "Range": "bytes=0-" } }

❌ 新手常踩的错(对着①的请求列表看为什么错):

错误写法会怎样正确写法
正着遍历(从 [0] 往后)先撞上 player.js、统计等无关请求,或取到不是最终的那条倒序 length-1 往前,音频通常在靠后
匹配到就返回不带 Range部分音频服务器不给 Range 就只返回一段或拒绝带上 'Range':'bytes=0-'
找不到时 return ""空字符串会被当成”有内容但为空”,播放器卡住找不到一律 return undefined

看懂这四段的意义:如果音频请求在之前步骤已经发生过(对应①的 requestUrls 里就有),不必再开 WebView 嗅探 → 直接倒序遍历这个列表,正则挑出 mp3/m4a(②)→ 包上 Referer/Range 返回(③)。这招比 21.3 省一次 WebView,前提是音频请求确实已经发过。

21.5 听书正文小结

场景怎么写关键点
接口直接给地址content 一条 JSONPath最省事
页面 JS 动态加载requestInfowebView + sourceRegex 嗅探正则匹 .mp3|.m4a
请求已发生遍历 params.requestUrls 回捞倒序找、带 Range

和漫画的区别:听书是单个 url(单数),漫画是 urls(复数)。听书特别依赖 webView 嗅探和 Range 头。


第 22 章 视频源:多线路建表 + 嗅探播放地址

视频(sourceType": "video")是四类里最复杂的,因为它有两个特有难点:① 一部片子常有多个”播放线路”(不同清晰度/不同源站),目录要把”线路+剧集”拉平成一张表;② 播放地址是 m3u8/mp4,同样要嗅探或解签名。案例取自 131 个真实视频源。

22.1 类型标记与”发现”分类

"sourceType": "video"

视频源的 bookWorld(发现页)分类词和小说完全不同,真实源里高频的是:电影电视剧动漫综艺短剧连续剧日韩剧国漫纪录片……写法仍是第 12 章的 requestFilters 那套,只是分类名换成影视词。

22.2 难点一:用 JSParser 把”多线路”拉平成目录

视频详情页常是”线路1: 第1集/第2集… 线路2: 第1集…”的嵌套结构。目录模块用完整 JS 解析JSParser,见第 4.2/第 16 章)遍历线路,把每条拼成 线路X-第N集

function functionName(config, params, result) {
    let list = [];
    let xpath = params.nativeTool.XPathParserWithSource(result);   // JS 里再用 XPath(第 13.3)
    let res = xpath.queryWithXPath("//*[@class=\"nav nav-tabs active\"]/li/a");
    res.forEach((x) => {
        let sadas = x.attributes();
        if (String(sadas.href).indexOf('#playlist') != -1) {
            let playlist = xpath.queryWithXPath("//*[@id='" + sadas.href.substring(1) + "']/ul/li/a");
            playlist.forEach((pl) => {
                let attr = pl.attributes();
                list.push({
                    "title": '线路:' + x.content() + '- ' + pl.content(),
                    "url": String(attr.href).startsWith('/') ? config.host + attr.href : attr.href,
                });
            });
        }
    });
    return { 'list': list };   // 目录模块必须返回 {list:[...]}(第 4.3)
}

思路:外层遍历”线路”标签页,内层遍历每个线路下的剧集 <a>,把每一集 push 进 listtitle 带上线路名方便用户区分。XPathParserWithSource 让你在 JS 里对同一段 HTML 反复做 XPath 查询。

接口站更简单,遍历数组建表即可:

function functionName(config, params, result) {
    let list = [];
    let chapterInfo = {};
    chapterInfo.title = result.data.title;
    chapterInfo.url = 'https://api.bilibili.com/x/player/playurl?cid=' + result.data.cid + '&bvid=' + result.data.bvid;
    list.push(chapterInfo);
    return {'list':list};
}

🔍 原响应 → 规则 → 解析后(拿一个”多线路”影视详情页举例,看 JSParser 怎么把嵌套线路拉平成一条条剧集):

服务器返回的 HTML(一部剧有”线路1/线路2”两个标签页,每个线路下挂着一排集数):

<ul class="nav nav-tabs active">
  <li><a href="#playlist1">线路1</a></li>
  <li><a href="#playlist2">线路2</a></li>
</ul>
<div id="playlist1"><ul>
  <li><a href="/play/9527-1-1.html">第01集</a></li>
  <li><a href="/play/9527-1-2.html">第02集</a></li>
</ul></div>
<div id="playlist2"><ul>
  <li><a href="/play/9527-2-1.html">第01集</a></li>
</ul></div>

规则大师兄影视 真实源 chapterList.JSParser,外层遍历线路、内层遍历剧集):

步骤代码在啃 HTML 的哪一块
找所有线路queryWithXPath("//*[@class=\"nav nav-tabs active\"]/li/a")顶部 线路1/线路2 两个标签
顺线路 id 找剧集queryWithXPath("//*[@id='"+href.substring(1)+"']/ul/li/a")该线路对应 <div id="playlistN"> 里的每集 <a>
拼一条list.push({title:'线路:'+线路名+'-'+集名, url:补全后的href})每集拼成一行,title 带线路名

App 解析后得到(两个线路的所有集被拉平成一张目录表):

{ "list": [
  { "title": "线路:线路1- 第01集", "url": "https://host/play/9527-1-1.html" },
  { "title": "线路:线路1- 第02集", "url": "https://host/play/9527-1-2.html" },
  { "title": "线路:线路2- 第01集", "url": "https://host/play/9527-2-1.html" }
]}

❌ 新手常踩的错(对着①的嵌套结构看为什么错):

错误写法会怎样正确写法
只取一个线路的剧集就 return用户只能看到线路1,线路2 整个丢失外层 forEach 遍历每个线路,内层再遍历剧集
title 不带线路名两个线路都叫”第01集”,用户分不清是哪条线路title 拼上 线路名+'-'+集名
剧集 href 是相对路径直接用点进去请求 /play/... 残缺地址失败startsWith('/') 时补 config.host

看懂这四段的意义:视频详情页是”线路套剧集”的两层结构(对应①)→ JSParser 外层遍历线路标签、内层顺着线路 id 遍历剧集(②)→ 拉平成一条条带线路名的目录(③)。多线路的核心就是”双层循环 + title 标线路”,别只取第一条线路。

22.3 多线路的 url 用 # 拼线路标记

当一集在不同平台有不同播放地址时,常把”地址#平台标记”拼在一起传给正文,正文再拆开分别处理:

// 目录里:url = 播放地址 + "#" + 平台名
chapterInfo.url = temp[1] + "#" + platforms[flag_platform-1];
// 正文里:拆回来
let urls = params.queryInfo.url.split("#");
let realUrl = urls[0];      // 真实地址
let platform = urls[1];     // 平台标记,决定用哪套解析

22.4 难点二:正文嗅探 m3u8/mp4

和听书一样,视频播放地址多半要 WebView 嗅探,只是正则换成视频后缀:

@js:
return {'url':result, 'httpHeaders':config.httpHeaders,
        'webView':true, 'sourceRegex':"(?:\\.m3u8|\\.mp4)",
        cacheKey: params.nativeTool.md5Encode(String((new Date).getTime()))};
  • 'sourceRegex':"(?:\\.m3u8|\\.mp4)":嗅探规则,命中 m3u8 或 mp4 就是视频流。(?:...) 是非捕获分组。
  • 'webView':true:布尔 true 也可开 WebView(和听书的 '' 等效)。
  • cacheKey:用时间戳 MD5 当缓存键,强制每次都重新嗅探(视频地址常带时效签名,缓存了会失效)。

从已发请求里回捞的写法(同听书 21.4,换成视频后缀):

@js:
let urls=params.requestUrls;
for(let i=urls.length-1;i>=0;i--){
   if(/m3u8|mp4/.test(urls[i])){ return urls[i]; }
}
return "获取";

22.5 难点三:签名接口——md5/base64/AES 解播放地址

正规平台(腾讯、B站、以及大量 CMS 采集站)的播放接口要签名。视频源里大量用到 nativeTool 的加密函数(第 13 章)现算 sign:

// 请求信息里:拼签名参数(大师兄/Banner 影视这类 CMS 站的典型套路)
let mark = "xianye50";
let url = params.nativeTool.base64Encode(realUrl);              // 地址先 base64
let sign = params.nativeTool.md5Encode(platform + mark + "App.JX.VipJX" + url);  // 再 md5 签名
let hp = {"form":platform, "mark":mark, "sign":sign, "url":url, "service":"App.JX.VipJX"};
return {'url': config.host+"/wxApi/public/", 'POST':false, 'httpParams':hp, "httpHeaders":config.httpHeaders};

有的接口返回的播放地址本身是 AES 密文,用 CryptoJS 解(源把 CryptoJS 塞在 httpHeaders 里 eval 出来):

eval(config.httpHeaders['CryptoJS']);
let AesDecrypt = (data, key) => {
    let wordKey = CryptoJS.enc.Utf8.parse(key);
    let decrypt = CryptoJS.AES.decrypt(data, wordKey, {mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7});
    return decrypt.toString(CryptoJS.enc.Utf8);
};
let url = AesDecrypt(result.data.videoplayurl, '3749671d801554cd6e4480f59956b69f');

这类 AES/MD5 解密的原理见第 13.2;这里的区别是密钥/模式(ECB + Pkcs7)由目标接口决定,只能对着站点抓包填。

🔍 原响应 → 规则 → 解析后(拿 CMS 采集站举例,看播放地址密文怎么用 CryptoJS 解成真实 m3u8):

接口返回的 JSONvideoplayurl 不是明文地址,而是一串 AES 密文):

{ "data": { "videoplayurl": "U2FsdGVkX1/aQ8v...(AES 密文)" } }

规则(真实源 chapterContent,用 httpHeaders 里塞的 CryptoJS 解密):

eval(config.httpHeaders['CryptoJS']);   // 先把 CryptoJS 库 eval 出来
let AesDecrypt = (data, key) => {
    let wordKey = CryptoJS.enc.Utf8.parse(key);
    let decrypt = CryptoJS.AES.decrypt(data, wordKey, {mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7});
    return decrypt.toString(CryptoJS.enc.Utf8);
};
let url = AesDecrypt(result.data.videoplayurl, '3749671d801554cd6e4480f59956b69f');

App 解析后得到(密文用固定 key 解开,还原成真实播放地址):

https://cdn.xxx.com/hls/20260717/abc123/index.m3u8

❌ 新手常踩的错(对着②的参数看为什么错):

错误写法会怎样正确写法
直接把 videoplayurl 当地址播它是 AES 密文,播放器打不开AesDecrypt 解开再用
忘了 eval(config.httpHeaders['CryptoJS'])CryptoJS 未定义、脚本报错用库前先 eval 出来(源把库塞在请求头里)
模式/填充填错(如用 CBC 而非 ECB)解出乱码模式、padding 照站点原样(这里是 ECB+Pkcs7)
sign 参数拼接顺序错接口返回签名校验失败严格按站点 JS 里的顺序拼 platform+mark+service+url 再 md5

看懂这四段的意义:正规平台/CMS 站的播放地址要么要签名换取(md5/base64 现算 sign,对应上半段)、要么返回的是密文(AES 解开,对应①③)。两种都得对着站点抓包、把 key/模式/拼接顺序抠准(②)。这是视频源里最硬的骨头,key 和签名规则全靠逆向,新手先跳过,认得出”要签名/是密文”就够了。

22.6 视频正文小结

难点解法章节参照
多播放线路JSParser 遍历线路+剧集建表第 4.2 / 16
线路标记传递url 用 #地址#平台本章 22.3
播放地址嗅探webView + sourceRegex: m3u8|mp4第 14 章
地址带时效cacheKey 用时间戳强制不缓存本章 22.4
接口要签名nativeTool.md5Encode/base64Encode 现算第 13 章
地址是密文eval(CryptoJS) + AES 解密第 13.2

第 23 章 四种类型对照速查(小说/漫画/听书/视频)

同一套香色结构,四种内容类型只有正文(chapterContent)差别大,其余模块通用。一张表看懂:

维度小说 text漫画 comic听书 audio视频 video
sourceTypetext/不写comicaudiovideo
搜索/详情/目录—— 四类写法基本一致,照第 5~9 章 ——
正文要拿什么文字一话的多张图一个音频地址一个视频流地址
正文返回键content 文本urls(复数,数组)url(单数)url(单数)
主要手段选择器 + 清洗@src / 取数组 / evalwebView 嗅探 mp3/m4awebView 嗅探 m3u8/mp4
防盗链一般不用常需 Referer常需 Referer + Range常需 Referer + 签名
特有难点去广告水印(第 10 章)图片被 JS 混淆地址靠嗅探/回捞多线路建表 + 签名
目录常见操作——reverse() 倒序每条=一集JSParser 拉平多线路

一句话记忆

  • 漫画 = 正文给图片数组urls 复数 + Referer)。
  • 听书 = 正文嗅探音频webView + sourceRegex: mp3\|m4a)。
  • 视频 = 正文嗅探视频 + 多线路 + 签名webView + m3u8\|mp4JSParser 建表)。
  • 其余照小说写就行。

本三章案例取自新解密的 sourceModelList(1).xbs(4779 源,含漫画 347 / 听书 194 / 视频 131),未编造。


第 24 章 进阶技法大全(4779 源实战深挖)

这一章把两轮从 sourceModelList(1).xbs(4779 个真实书源)里挖出的高级写法汇总整理,按「请求 → 解析 → 目录章节 → 源级配置」的逻辑顺序排列。全是前 23 章没讲过的老作者真实套路,每个都按四段式讲清「长啥样 → 怎么写 → 出什么 → 别踩坑」。

本章技法横跨搜索/详情/目录/正文/发现各模块,括号里标注的是在 4779 源中的出现次数,可据此判断常用程度。

A. 请求怎么构造(发请求时的高级写法)

24.1 字符串里内嵌取值:{{$.x}} / {{java.xx()}}

规则字符串里可以直接嵌 {{...}},花括号里写 JSONPath($.x)或函数(java.xx()),App 求值后填回原位——不用写 @js: 就能拼字段。这是接口站最省事的字段写法,本批源 5440 次。

真实案例番茄小说searchBook.cat):

{{$.category}},{{$.score}}分,连载{{$.creation_status}}完结,{{$.source}} ||@js:
result.replace(/连载0完结/g,'完结').replace(/连载1完结/g,'连载')

解释{{$.category}} 从接口 JSON 取 category 字段填进来,几个 {{}} 拼成一句话,再用 ||@js: 把”连载0完结”这种翻译成”完结”。{{}} 负责取值拼接,JS 只做最后清洗。

🔍 原响应 → 规则 → 解析后(番茄搜索接口,看 {{$.x}} 怎么把多个字段拼成一句话):

接口返回的 JSON(搜到的一本书,字段分散):

{ "category": "都市", "score": "8.5", "creation_status": "0", "source": "番茄" }

规则(番茄小说真实源 searchBook.cat):

片段取到什么
{{$.category}}都市
{{$.score}}分8.5分
连载{{$.creation_status}}完结连载0完结(待 JS 翻译)
{{$.source}}番茄

App 解析后得到{{}} 全部填值,再经 ||@js: 把”连载0完结”→“完结”):

都市,8.5分,完结,番茄

❌ 新手常踩的错

错误写法会怎样正确写法
%@category%@ 只认 keyWord/pageIndex 等固定几个)取不到接口字段接口字段用 {{$.字段}}
{{category}} 漏了 $.不是合法 JSONPath,取空花括号里写完整 {{$.category}}
以为 {{}} 里能写任意多行 JS它只求单个表达式复杂逻辑还得走 `

看懂这四段的意义:接口站要把几个字段拼成一句(分类/评分/状态)时,别急着写 @js:——{{$.字段}} 直接嵌进字符串就取到了(②→③),最后有需要再 ||@js: 清洗。{{}} 是”字符串里的取值窗口”,%@ 管框架变量、{{$.}} 管接口字段。


24.2 免写 JS 的存取:@put:{键:值} / @get:{键}

java.put/get 更简的写法:直接在规则里用 字段@put:{键:取值路径} 存、用 @get:{键} 取——一个 @js: 都不用写。本批源 200 次,接口站尤其常见。

真实案例妙阅小说 / 熬夜看书):

// bookDetail.author:取作者的同时,把 book_id 存进键 bid
book_author@put:{bid:book_id}

// chapterList.url:把存的 bid 填进目录地址
https://api.whhaxfjc.com/chapter2_@get:{bid}_{{$.link}}.html

解释book_author@put:{bid:book_id} = 字段值取 book_author顺手把同级的 book_id 存进键 bid。后面 @get:{bid} 把它填进 URL。取值和存值一步完成,无需 JS。

🔍 数据流 → 规则 → 结果(妙阅小说的 book_id 怎么免 JS 传递):

详情接口返回

{ "book_author": "唐家三少", "book_id": "88231", "link": "abc" }

规则@put 存、@get 取,都不写 JS):

模块写法效果
bookDetail.authorbook_author@put:{bid:book_id}author 取到”唐家三少”,同时把 book_id=88231 存进 bid
chapterList.url...chapter2_@get:{bid}_{{$.link}}.html@get:{bid} 填 88231

App 解析后

author = 唐家三少
目录 url = https://api.whhaxfjc.com/chapter2_88231_abc.html

❌ 新手常踩的错

错误写法会怎样正确写法
@put:{bid:book_id}book_id 写成不存在的字段存进空值存值路径要对准接口里真实字段
@get:{bid} 前没人 @putbid填空先在上游 @put
键名 bid 两处不一致取不到@put@get 键名一致

看懂这四段的意义:只是”取个字段顺手存个 id”这种小活,不必写 @js:——字段@put:{键:路径} 一步搞定(②),下游 @get:{键} 填回来(③)。@put/@getjava.put/get 的免 JS 版,适合纯拼接场景。


24.3 跨模块传值:java.put / java.get

搜索/详情取到的值(书 id、章节 id),想在后面的目录/正文模块用——用 java.put("键", 值) 存、java.get("键") 取。和第 13 章的 nativeTool.setCache 类似,但 java.put/get在规则字符串里直接调、更轻量,本批源 620 次。

真实案例番茄小说 详情存 id,目录取用):

// bookDetail.tocUrl:把 book_id 存起来,同时拼出目录地址
book_id ||@js:
java.put("id",result);
"https://novel.snssdk.com/api/novel/book/directory/list/v1/?book_id="+result

解释:详情页取到 book_idjava.put("id", result) 存进内存,后面正文模块再 java.get("id") 取出来拼接口。跨模块共享数据靠它。

🔍 数据流 → 规则 → 结果(番茄的 book_id 怎么从详情传到目录/正文):

详情模块拿到的值

result = "7143038718021782" (book_id)

规则(三个模块接力):

模块写法干什么
bookDetailjava.put("id",result)把 book_id 存进键 id
chapterList...?book_id="+java.get("id")取出 book_id 拼目录接口
chapterContentjava.get("id")正文再用同一个 id

App 解析后(目录/正文都能拿到详情存的 id):

chapterList 请求 → ...?book_id=7143038718021782

❌ 新手常踩的错

错误写法会怎样正确写法
详情没 put,目录直接 java.get("id")取到空,接口缺参数先在上游模块 java.put
putget 的键名写得不一致取到 undefined两处键名必须完全一样
用它存超大文本(整页 HTML)占内存、易出错只存 id/token 这种小值

看懂这四段的意义:一个 id 在详情取到、目录和正文都要用(①)→ 上游 java.put("id",值) 存、下游 java.get("id") 取(②)→ 数据就跨模块流动起来了(③)。上游存、下游取,键名对齐是关键。


24.4 JS 里再发请求:java.ajax(url)

@js: 里可以用 java.ajax(地址) 当场再发一个请求拿数据,不用等 App 走模块流程。用于”一个模块里要拼多个接口的结果”(番茄目录分批取、详情补字段),本批源 387 次。

真实案例番茄小说searchBook.lastChapterTitle):

https://api5-normal.fqnovel.com/reading/bookapi/multi-detail/v/?book_id={{$.book_id}} ||@js:
let data = JSON.parse(java.ajax(result)).data[0];
data["last_chapter_title"] + " • " + java.timeFormat(data["last_chapter_update_time"]*1000);

解释:搜索列表里没有”最新章节”,源就用 {{$.book_id}} 拼出一个详情接口地址,java.ajax(result) 当场请求它,从返回里取最新章标题。相当于”搜索时顺便多问一次接口补全字段”。

🔍 原响应 → 规则 → 解析后(番茄搜索时怎么用 ajax 补最新章):

搜索列表本身没有最新章,但有 book_id

{ "book_id": "7143" }   (列表项,缺 last_chapter_title)

规则(先拼接口地址,再 ajax 请求它):

步骤写法动作
拼地址...multi-detail/v/?book_id={{$.book_id}}得到详情接口 URL
再请求java.ajax(result)当场请求这个 URL
取字段JSON.parse(...).data[0].last_chapter_title从返回里取最新章

App 解析后得到

最新章:第1648章 大结局 • 2024-01-15

❌ 新手常踩的错

错误写法会怎样正确写法
列表里每项都 ajax(几十次)请求爆炸、很慢能一次接口拿全就别逐项 ajax
java.ajax 忘了 JSON.parse返回是字符串,直接点属性取空接口返回 JSON 要先 parse
ajax 的地址是相对路径请求失败传给 ajax 的要是完整 URL

看懂这四段的意义:当前模块的数据不够、需要再问一个接口时(①)→ 拼出那个接口地址、java.ajax() 当场请求(②)→ 从返回里取要的字段(③)。java.ajax 是”规则里的临时请求”,但别滥用,逐项调会拖慢。


24.5 请求参数 / 响应编码:requestParamsEncode / responseEncode

有些老站用的不是 UTF-8,而是 GBK/GB2312 编码。搜索中文时如果不按站点编码处理,服务器就搜不到。requestParamsEncode(请求参数编码)和 responseEncode(响应编码)这两个字段,告诉 App 用哪种编码处理请求参数和返回内容。

真实案例海洋听书松语文学网 等 GBK 站):

{
  "searchBook": {
    "requestParamsEncode": "2147485234",
    "responseEncode": "2147485234"
  }
}

解释

  • 那串数字(2147485234 等)是 App 内部的编码枚举值,代表 GBK/GB2312 之类的字符集(具体值由 App 定义,作者是从别的同类源里抄来的)。
  • requestParamsEncode:把搜索关键词按这个编码转成 URL 参数(GBK 站搜”斗破”要编码成 GBK 的 %B6%B7%C6%C6,不是 UTF-8 的 %E6%96%97...)。
  • responseEncode:把服务器返回的 GBK 字节按这个编码解码成中文,不然全是乱码。 怎么仿写:站点是 GBK 编码(搜中文搜不到、或正文乱码)→ 从同类 GBK 源里抄这两个字段的编码值填上。

🔍 现象 → 字段 → 结果(拿一个 GBK 老站举例,看编码字段怎么救乱码):

不设编码字段时的现象(GBK 站,UTF-8 编码搜中文):

搜"斗破" → App 默认按 UTF-8 编码成 ?q=%E6%96%97%E7%A0%B4
GBK 服务器看不懂这个编码 → 返回"无结果"
就算搜到,返回的 GBK 字节被当 UTF-8 解 → 正文全是"锟斤拷"乱码

规则(在模块里加这两个字段):

字段作用
requestParamsEncode2147485234(GBK 枚举)关键词按 GBK 编码进 URL
responseEncode2147485234(GBK 枚举)返回内容按 GBK 解码成中文

设置后的结果

搜"斗破" → 按 GBK 编码成 ?q=%B6%B7%C6%C6 → GBK 服务器认得 → 正常返回结果
返回的 GBK 字节按 GBK 解码 → 中文正常显示,不再乱码

❌ 新手常踩的错

错误写法会怎样正确写法
GBK 站不设编码字段搜中文搜不到,或正文”锟斤拷”乱码requestParamsEncode/responseEncode
UTF-8 站也画蛇添足加 GBK 编码本来正常的反被编码搞乱只有 GBK/GB2312 老站才需要
编码枚举值乱填编码对不上,照样乱码从同类 GBK 源里抄准那个数字值

看懂这四段的意义:这两个字段专治 GBK 老站的中文乱码。搜不到中文、或正文”锟斤拷”(对应①)→ 加 requestParamsEncode(编码请求)和 responseEncode(解码响应)(②)→ 中文恢复正常(③)。编码值是 App 定义的枚举数字,从同类老站抄,别自己瞎编。


B. 解析取值增强(从返回里抠数据的高级写法)

24.6 内置指令:@json: 直接取、@css: 用 CSS 选择器

第 6~8 章讲了 //(XPath)、$(JSONPath)、@replace:。其实还有两个常用指令:@json: 明确按 JSONPath 取(接口站,94 次)、@css: 用 CSS 选择器代替 XPath(10 次,配合 java.getElements)。

真实案例

// @json:爱看书的接口字段
@json:$.categoryName
@json:$..bookVo.introduction

// @css:阅读论坛正文,用 CSS 选择器
@js:
p=java.getElement("@css:.t_f,.savephotop");
String(p.html())

解释@json:$.x 和直接写 $.x 类似,但 @json: 更明确”这就是 JSONPath”,混合规则里不易被误判。@css:.t_f = CSS 选择器”class 是 t_f 的元素”,等价 XPath 的 //*[@class="t_f"],写惯前端的人更顺手。

🔍 原响应 → 规则 → 解析后(对比 @json:/@css: 各在什么场景):

两种响应

接口 JSON:{ "categoryName": "玄幻", "bookVo": { "introduction": "简介文字…" } }
网页 HTML:<div class="t_f">正文段落…</div>

规则

指令写法相当于
@json:@json:$.categoryNameJSONPath 取 categoryName
@json: 深搜@json:$..bookVo.introduction任意层找 bookVo.introduction
@css:@css:.t_fXPath //*[contains(@class,"t_f")]

App 解析后得到

@json:$.categoryName          → 玄幻
@css:.t_f(取 html)          → 正文段落…

❌ 新手常踩的错

错误写法会怎样正确写法
网页 HTML 用 @json:JSON 指令解析 HTML 取空HTML 用 //@css:,接口用 @json:/$
@css: 单独用当字段规则@css: 多配合 java.getElement(s)CSS 选择器一般写在 @js: 里的 getElements("@css:…")
@json: 后忘了 $路径不合法@json: 后跟标准 $. 路径

看懂这四段的意义:取值指令不止 //$——接口字段可用 @json:$.x 更明确(②),网页元素在 JS 里可用 @css: 选择器(写前端的人更顺手)。认得这两个指令,读别人的源就不会卡壳。


24.7 稳健的 class 匹配:normalize-space(concat())

真实源里大量 class 匹配不写 [@class="x"],而写 [contains(concat(' ',normalize-space(@class),' '),' x ')]——这是**精确匹配某个 class(多 class 时也不误伤)**的稳健写法,本批源 224 次。

真实案例SF轻小说 / 潇湘书院 的目录 list):

//div[contains(concat(' ', normalize-space(@class), ' '), ' wrap ')]

解释:一个元素常有多个 class(class="wrap active clearfix")。直接 [@class="wrap"] 要求 class 正好只有 wrap,匹配不到;[contains(@class,"wrap")] 又会误中 wrapperconcat(' ',...,' ') 给 class 串前后加空格,再找 wrap(带空格),正好卡住”独立的 wrap 这个词”,不多不少。

🔍 原响应 → 规则 → 解析后(看这套写法为什么比 [@class="x"] 稳):

服务器返回的 HTML(元素带多个 class):

<div class="wrap active clearfix">…书列表…</div>
<div class="wrapper">…别的东西…</div>

三种写法对比

写法匹配 class="wrap active"?误中 wrapper?
[@class="wrap"]❌ 不中(class 不止 wrap)
[contains(@class,"wrap")]✅ 中⚠️ 会误中 wrapper
[contains(concat(' ',normalize-space(@class),' '),' wrap ')]✅ 中✅ 不误中

App 解析后得到(只精确命中”含独立 wrap 类”的元素):

命中:<div class="wrap active clearfix">
不中:<div class="wrapper">

❌ 新手常踩的错

错误写法会怎样正确写法
多 class 元素用 [@class="wrap"]要求 class 完全等于 wrap,多一个词就落空用 concat 那套,或 contains
图省事全用 contains(@class,"wrap")误中 wrapper/wrap2要精确用 concat(' ',…,' '),' wrap '
concat 里忘了前后空格退化成普通 contains' wrap ' 两边空格不能省

看懂这四段的意义:元素有多个 class 时,[@class="x"] 太死、contains 太松——concat(' ',normalize-space(@class),' ') 加空格卡边界(②)才能精确命中(③)。看到这一长串别慌,它就是”精确匹配一个 class”,照抄改 class 名即可。


24.8 元素数组方法链:.toArray().sort().map()

java.getElements(...)Jsoup.parse(...).select(...) 取到一组元素后,可以直接用 JS 数组方法 .map()(逐个变形)、.sort()(排序)、.filter()(筛选)加工成列表。本批源 300+ 次,比 for 循环简洁。

真实案例星空小说网chapterList.list):

//* ||@js:
org.jsoup.Jsoup.parse(result)
  .select('[data-id]>li>a')
  .toArray()
  .sort()
  .map(x => ({ n: x.text(), u: x.attr('href') }))

解释select('[data-id]>li>a') 取到所有章节 <a>.toArray() 转成 JS 数组,.sort() 排序,.map(x=>({n:..., u:...})) 把每个元素变成 {n:标题, u:链接} 对象。一条链把”取元素→排序→建列表”全做完。

🔍 原响应 → 规则 → 解析后(星空小说网怎么用方法链建目录):

服务器返回的 HTML

<ul data-id="1"><li><a href="/read/1.html">第一章</a></li></ul>
<ul data-id="2"><li><a href="/read/2.html">第二章</a></li></ul>

规则(方法链逐步加工):

步骤方法结果
取元素.select('[data-id]>li>a')所有章节 <a>
转数组.toArray()JS 能操作的数组
排序.sort()章节按序排
变形.map(x=>({n:x.text(),u:x.attr('href')}))每个变 {n:标题,u:链接}

App 解析后得到

[ { "n": "第一章", "u": "/read/1.html" }, { "n": "第二章", "u": "/read/2.html" } ]

❌ 新手常踩的错

错误写法会怎样正确写法
select(...) 后不 .toArray() 直接 .mapjsoup 返回的不是 JS 数组,没有 map.toArray() 转成 JS 数组
.map 里返回 {n,u} 却没加外层括号 ({...})箭头函数把 {} 当代码块,返回 undefined返回对象要写 x=>({...})
字段名和 list 后续规则对不上App 取不到 title/urlmap 出的键名要和字段规则一致(或用 n/u 再映射)

看懂这四段的意义:取到一组元素要建列表,不必写 for 循环——.toArray().map(x=>({...})) 一条链把每个元素变成对象(②→③),.sort()/.filter() 按需插进链里。方法链 = 取元素后的流水线加工,map 变形、sort 排序、filter 筛选。


C. 目录与章节进阶(chapterList/bookDetail 的高级字段)

24.9 目录也能翻页:chapterList.nextPageUrl

第 11 章讲的 nextPageUrl正文分页。其实目录也能翻页——章节特别多的站,目录分成好几页,靠 chapterList 里的 nextPageUrl 一页页翻,App 自动把所有页的章节拼成完整目录。

真实案例43看书 / 笔趣阁03):

nextPageUrl: //a[@class='index-container-btn' and text()='下一页']/@href

另一种(下拉框翻页,第九中文):

nextPageUrl: //option/@value

解释

  • 和正文翻页一模一样,只是写在 chapterList 模块里。App 抓完第 1 页目录,执行 nextPageUrl 拿到第 2 页地址,继续抓,直到没有下一页。
  • //option/@value 那种是给”选择页码的下拉框”用的——取所有 <option> 的值当各页地址。 怎么仿写:目录只显示了一部分章节、底部有”下一页”或页码下拉 → 在 chapterListnextPageUrl,取下一页链接。

🔍 原响应 → 规则 → 解析后(拿 43看书 目录举例,看目录怎么自动翻页拼全):

服务器返回的目录第 1 页(底部有”下一页”按钮):

<div class="chapter-list">
  <a href="/book/9527/1.html">第一章</a>
  <a href="/book/9527/2.html">第二章</a>
  ... (本页只有前 100 章)
</div>
<a class="index-container-btn" href="/book/9527/index_2.html">下一页</a>

规则43看书 真实源 chapterList):

字段规则作用
list//div[@class="chapter-list"]/a本页章节
title//a//text()章节名
url//a/@href章节地址
nextPageUrl//a[@class='index-container-btn' and text()='下一页']/@href下一页目录地址

App 解析后得到(自动翻页,把所有页章节拼成完整目录):

第 1 页 100 章 + 第 2 页 100 章 + …… → 合并成完整目录(比如 800 章)
App 顺着 nextPageUrl 一页页抓,直到某页没有"下一页"按钮为止

❌ 新手常踩的错

错误写法会怎样正确写法
只写 list 不写 nextPageUrl目录只有第 1 页那 100 章,后面全丢分页目录必须加 nextPageUrl
nextPageUrl 取到的是相对地址没补全翻页请求失败相对地址靠 App 补 host,或 `
最后一页还能取到”下一页”(指向自己)死循环翻页确保最后一页取不到下一页(返空),或加守卫

看懂这四段的意义nextPageUrl 不止正文能用,目录分页也靠它(写在 chapterList 里)。目录底部有”下一页”或页码下拉(对应①)→ 取下一页地址(②)→ App 自动翻完所有页拼成全本目录(③)。章节数明显比目录显示的少时,多半就是目录分页了,记得加它。


24.10 详情页单独给目录地址:bookDetail.tocUrl

正常流程是「详情页地址 = 目录页地址」,App 拿 detailUrl 直接又当详情又当目录。但有些站详情和目录是两个不同的接口——这时在 bookDetail 里用 tocUrl 字段单独算出目录地址,交给 chapterList 用。

真实案例起点中文 详情页算目录接口):

tocUrl: @js:
var id = baseUrl.match(/info\/(\d+)/)[1];
java.put('id', id);
'https://druid.if.qidian.com/argus/api/v1/chapterlist/chapterlist?bookId='+id;

解释

  • tocUrlbookDetail 模块的字段。它算出的地址会被 chapterList 当作请求地址(chapterListrequestInfo 里可以用 %@resultparams.queryInfo.tocUrl 取到)。
  • 起点 的详情页 URL 里有 info/12345 这种书 id,用正则抠出 id,拼成完全不同的目录接口地址。 怎么仿写:站点详情页和目录页不是同一个地址(尤其接口站)→ 在 bookDetailtocUrl 单独算目录地址。

🔍 原响应 → 规则 → 解析后(拿 起点中文 举例,看详情页怎么算出另一个目录接口):

App 手上的详情页地址baseUrl 里含书 id):

baseUrl = "https://www.qidian.com/book/info/1004608738"
详情页和目录页是两套接口,光有详情地址请求不到章节

规则起点中文 真实源 bookDetail.tocUrl):

@js:
var id = baseUrl.match(/info\/(\d+)/)[1];   // 抠出 1004608738
java.put('id', id);
'https://druid.if.qidian.com/argus/api/v1/chapterlist/chapterlist?bookId=' + id;

算出的目录地址(交给 chapterList 用):

https://druid.if.qidian.com/argus/api/v1/chapterlist/chapterlist?bookId=1004608738
chapterList 模块拿这个地址去请求,就能得到章节列表

❌ 新手常踩的错

错误写法会怎样正确写法
详情、目录同接口的站也硬写 tocUrl多此一举,可能算错只有详情/目录不同地址时才用 tocUrl
算出 tocUrlchapterList 不去用它目录还是请求详情地址,拿不到章节chapterList.requestInfo 里用 params.queryInfo.tocUrl%@result 接住
正则抠 id 写死下标 [1] 不判空匹配不中时 [1] 报错抠关键参数前先确认 URL 结构稳定

看懂这四段的意义tocUrl 解决「详情页和目录页不是一个地址」的问题(接口站常见)。在 bookDetail 里从详情地址抠出书 id、拼出目录接口(对应②)→ 存进 tocUrl(③)→ chapterList 接住它去请求。详情和目录同址时不用它;不同址时它是连接两个模块的桥。


24.11 VIP 章节标记:chapterList.isVip

付费站的目录里,有些章节是收费的(VIP),前面挂着小锁图标。chapterList 模块的 isVip 字段就是告诉 App「这一章是不是 VIP」,App 据此在目录里显示锁标记。

真实案例三优书网 / 木叶小说):

list:  //a
isVip: //a//style || @js:
/Gray/.test(result)?1:0;

解释

  • isViptitle/url 一样是章节级字段,相对 list 定位的每个章节取值。
  • 三优书网 的思路:VIP 章的 <a> 带了灰色样式(style 里有 Gray)。用 //a//style 取到样式字符串,||@js: 里用 /Gray/.test(result) 判断——含 Gray 返回 1(是 VIP),否则 0。
  • 另一种(木叶小说)直接看有没有 islock 类://*[contains(concat(' ',normalize-space(@class),' '),' islock ')]怎么仿写:看 VIP 章在 HTML 里有什么特征(灰色、锁图标、islock/is_free 类),用规则取到那个特征,返回真/1 表示 VIP。

🔍 原响应 → 规则 → 解析后(拿 三优书网 目录举例,看 VIP 章怎么被标记出来):

服务器返回的目录 HTML(免费章正常,VIP 章的 <a> 带灰色 style):

<a href="/read/1.html">第一章 免费试读</a>
<a href="/read/2.html">第二章 免费试读</a>
<a href="/read/50.html" style="color:Gray">第五十章 VIP专享</a>
<a href="/read/51.html" style="color:Gray">第五十一章 VIP专享</a>

规则三优书网 真实源 chapterList):

字段规则判断逻辑
list//a每个 <a> = 一章
title//a//text()章节名
url//a/@href章节地址
isVip//a//style ||@js: /Gray/.test(result)?1:0;style 里含 Gray → 返回 1(VIP),否则 0

App 解析后得到(每章多一个 isVip 标记):

{ "list": [
  { "title": "第一章 免费试读", "url": "/read/1.html", "isVip": 0 },
  { "title": "第五十章 VIP专享", "url": "/read/50.html", "isVip": 1 }
]}

App 看到 isVip:1 的章节,会在目录里给它加个🔒锁图标。

❌ 新手常踩的错

错误写法会怎样正确写法
isVip 返回 "Gray" 这种字符串App 要的是真/假或 1/0,给字符串判断不准/Gray/.test(result)?1:0 转成 1/0
免费章没返回 isVip(只 VIP 章返回)有的 App 会把没标记的当默认值,不一致每章都走同一条规则,免费章自然得 0
拿整章内容判断 VIP目录阶段还没正文,判断不了只用目录页 <a> 上的特征(class/style/图标)

看懂这四段的意义isVip章节级字段,和 title/url 并列写在 chapterList 里。找出 VIP 章在目录 HTML 里的视觉特征(灰色/锁/islock 类,对应①)→ 用规则取到并转成 1/0(②)→ App 就能给付费章加锁标记(③)。关键是”返回 1/0 而非原始字符串”。


24.12 分卷标记:chapterList.isVolume

长篇小说常分「第一卷 / 第二卷」,目录里卷标题和普通章节混在一起。isVolume 字段告诉 App「这一条是卷标题而不是可点击的章节」,App 会把它显示成分隔标题、不可点。

真实案例有度中文):

isVolume: //a//text() || @js:
result=result.match(/★/)?true:false

解释有度中文 的卷标题前面带个 符号。用 //a//text() 取到每条文字,||@js:result.match(/★/) 判断有没有星号——有就 return true(这是卷标题),没有就 false(普通章节)。 怎么仿写:看卷标题和普通章节在 HTML 里差在哪(特殊符号、不同 class、不同标签如 <h3>),据此返回真/假。

🔍 原响应 → 规则 → 解析后(拿 有度中文 目录举例,看卷标题怎么和章节区分开):

服务器返回的目录(卷标题带 ,普通章节不带):

<a>★ 第一卷 少年篇</a>
<a href="/1.html">第一章 出山</a>
<a href="/2.html">第二章 下山</a>
<a>★ 第二卷 江湖篇</a>
<a href="/3.html">第三章 入城</a>

规则有度中文 真实源 chapterList):

字段规则判断逻辑
list//a每个 <a> 一条(含卷标题和章节)
title//a//text()文字
isVolume//a//text() ||@js: result=result.match(/★/)?true:false含 ★ → true(卷标题),否则 false

App 解析后得到(卷标题被标出,显示成分隔栏):

{ "list": [
  { "title": "★ 第一卷 少年篇", "isVolume": true },
  { "title": "第一章 出山", "url": "/1.html", "isVolume": false },
  { "title": "第二章 下山", "url": "/2.html", "isVolume": false }
]}

❌ 新手常踩的错

错误写法会怎样正确写法
卷标题当普通章节,不标 isVolume用户点卷标题(没 url)报错或空白isVolume 把卷标题标出来,App 不让点
return "true"(字符串)App 要布尔值,字符串判断可能失效result.match(/★/)?true:false 返回真布尔
卷标题和章节特征分不清就硬猜标错,目录乱套先在网页里对比卷标题和章节的 HTML 差异

看懂这四段的意义isVolume 也是章节级字段,专门标「这条是卷标题不是章节」。找出卷标题的特征(★符号、catalog-title 类、<h3> 标签,对应①)→ 规则返回 true/false(②)→ App 把卷标题渲染成不可点的分隔栏(③)。isVip 一样,返回布尔值,别返回原始文字。


24.13 多次请求:params.lastResponse 判断第几次

有些内容一次请求拿不全(漫画要先取”图片列表地址”再取图、正文要翻内页)。App 支持同一模块多次请求:返回 {more:true, autoRequestMore:true, url:下一个地址} 触发下一次,靠 params.lastResponse(上一次的响应)判断当前是第几次。本批源 154 次,漫画正文最常见。

真实案例付梓漫画chapterContent.JSParser):

function functionName(config, params, result) {
  if(!params.lastResponse){           // 第一次:还没拿到图片
    let url = result.match(/txt_url="(.*)"/)[1];
    return {'more':true, 'autoRequestMore':true, 'url':url};   // 触发第二次请求
  }
  // 第二次:这次的 result 才是真正的图片数据
  let res = result.match(/((http|https).*?jpg)/g);
  return JSON.stringify({'urls':res});
}

解释:第一次 params.lastResponse 是空(还没有上次响应),此时只从页面抠出”图片列表地址”,返回 more:true 让 App 再请求一次;第二次 lastResponse 有值了,这次的 result 是图片数据,正式解析。

🔍 两次请求 → 规则 → 结果(付梓漫画怎么分两步取图):

两次请求的 result

第 1 次:result = 章节页 HTML,里面有 txt_url="https://x.com/imglist.txt"
第 2 次:result = imglist.txt 的内容,一堆 .jpg 地址

规则(用 !params.lastResponse 分叉):

判断分支动作
!params.lastResponse(第一次)抠出图片列表地址return {more:true, autoRequestMore:true, url:…}
lastResponse 有值(第二次)解析图片数组return {urls:[...]}

App 解析后

第1次返回 more:true → App 自动再请求 url
第2次返回 urls → 漫画图片显示

❌ 新手常踩的错

错误写法会怎样正确写法
不判断 lastResponse,一次就想取图第一次页面里根本没有图,取空!params.lastResponse 分两步
第一次忘了 autoRequestMore:trueApp 不会自动发第二次请求需要续请求时带上它
两次都返回同结构App 分不清进度,死循环第一次返 more:true+url,第二次返最终数据

看懂这四段的意义:内容要”先取地址、再取内容”分两步时(①)→ 用 !params.lastResponse 判断当前第几次(②)→ 第一次触发续请求、第二次正式解析(③)。lastResponse 空 = 第一次,有值 = 后续,这是多步抓取的开关。


D. 源级配置与工程化(整源级别的字段与写法)

24.14 登录源 / 密码源:loginUrl + password

有些站点要登录才能看全本,或者书源作者给源加了访问密码(防止白嫖倒卖)。这靠两个顶层字段loginUrl(登录页地址)和 password(访问密码)。

真实案例x-69书吧星空小说 等):

{
  "loginUrl": "https://www.69shuba.com/modules/article/search.php",
  "password": "cjpig@aiyueshuxiang"
}

解释

  • loginUrl:填站点的登录页网址。App 会给这个源显示一个「登录」入口,用户点进去在 WebView 里输账号密码,登录后的 Cookie 自动带进后续请求。
  • password源本身的访问密码(不是网站账号密码)。填了它,别人拿到你的源文件想用,得先输对这个密码——源作者用来防倒卖。本批源里 249 个源设了 password。 怎么仿写:站点要登录才能看 → 填 loginUrl 指向登录页;想给自己的源加锁 → 填 password

🔍 字段 → 行为 → 效果(拿 x-69书吧 举例,看这两个顶层字段各干什么):

源文件顶层写了这两个字段

{
  "sourceName": "x-69书吧(先登陆)",
  "loginUrl": "https://www.69shuba.com/modules/article/search.php",
  "password": "cjpig@aiyueshuxiang",
  "searchBook": { "requestInfo": "@js:..." }
}

App 读到后的两个行为

字段App 的行为用户看到什么
loginUrl源详情页多出一个「登录」按钮,点击用 WebView 打开该网址能在 App 内登录,登录态(Cookie)自动用于抓取
password导入/使用这个源时先校验密码弹窗要求输入访问密码,错了不给用

实际效果

用户导入源 → App 提示"请输入访问密码" → 输入 cjpig@aiyueshuxiang → 通过
用户搜书发现要登录 → 点源里的"登录"按钮 → WebView 打开 69shuba 登录页 → 登录后正常看全本

❌ 新手常踩的错

错误写法会怎样正确写法
把网站账号密码填进 passwordpassword源访问密码,不是网站账号,填错导致别人要输你的网站密码网站登录靠 loginUrl + 用户自己的账号;password 是给源加的锁
loginUrl 填成搜索页用户点登录跳到搜索页,登录不了loginUrl 指向真正的登录页(/login.php 等)
以为设了 loginUrl 就自动登录App 只是提供入口,账号还得用户自己在 WebView 里输登录是用户手动完成的,源只负责给入口

看懂这四段的意义loginUrl/password顶层字段(和 sourceName 同级,不在某个模块里)。要登录的站填 loginUrl 给个登录入口(①→②);想防倒卖就设 password 给源加锁(③)。这俩是”源级别”的配置,不影响具体的搜索/正文规则。


24.15 请求与响应分离:requestJavascript / responseJavascript

前面讲的 @js: 是把「请求」和「解析」混在一个字段里。碰到接口站(请求要拼 header、响应是纯 JSON 要转换),老源作者常用一套更清晰的写法:请求逻辑和响应逻辑拆成两个独立函数——requestJavascript(怎么发请求)和 responseJavascript(怎么解析返回)。

真实案例看点小说网searchBook,整套):

// requestJavascript:只管发请求,不接 result 参数
function functionName(config, params) {
    let url = 'https://kandian.yuewen.xyz/search?count=100&resourcename=' + encodeURI(params.keyWord);
    let httpHeaders = {'User-Agent':'PSP (PlayStation Portable); 6.61'};
    return {'url':url, 'httpHeaders':httpHeaders, 'forbidCookie':true};
}
// responseJavascript:接服务器返回的 resObj,转成 App 要的 list
function functionName(config, params, resObj) {
    let oldList = resObj.rows || [];
    let list = [];
    for (let i = 0; i < oldList.length; i++) {
        let o = oldList[i];
        list.push({
            bookId: o.resourceid, bookName: o.resourcename, author: o.author,
            cover: o.picurl, cat: o.subtype, status: o.isfinish,
            desc: o.summary, wordCount: o.sourcesize
        });
    }
    return {'list': list};
}

解释

  • requestJavascript 的函数只有 (config, params) 两个参数(没有 result)——因为这是”发请求前”,还没有返回内容。它 return 请求信息对象(url/headers 等)。
  • responseJavascript 的函数有三个参数 (config, params, resObj)——resObj 是服务器返回、已经 JSON.parse 好的对象。它负责把接口的原始字段名(resourcename)映射成 App 的标准字段(bookName)。
  • 两个函数配套时,模块里还要有 requestFunction: "functionName"responseFunction: "functionName" 指明入口函数名。 怎么仿写:接口站、请求和解析都复杂时,用这套分离写法——请求逻辑写 requestJavascript,字段映射写 responseJavascript,比挤在一个 @js: 里清爽。

🔍 请求函数 → 接口返回 → 响应函数(拿 看点小说网 搜索举例,看两个函数怎么接力):

App 给 requestJavascript 的输入(用户搜”斗破”):

params.keyWord = "斗破"
(此时还没有 result——请求都没发呢)

两个函数分工(requestJavascript 发请求 → 服务器返回 JSON → responseJavascript 解析):

阶段函数参数干什么
发请求requestJavascript(config, params)拼 url、加 PSP 的 UA、禁 Cookie,return 请求信息
服务器返回————{"rows":[{"resourcename":"斗破苍穹","author":"天蚕土豆",...}]}
解析responseJavascript(config, params, resObj)resObj.rows 里的 resourcenamebookName 等映射成标准字段

最终 App 得到的 list

{ "list": [
  { "bookName": "斗破苍穹", "author": "天蚕土豆", "bookId": "12345", "cover": "...", "status": "完结" }
]}

❌ 新手常踩的错

错误写法会怎样正确写法
requestJavascript 的函数写了 result 参数还去用它发请求阶段没有 result,值是 undefined请求函数只用 (config, params)
写了 requestJavascript 但没写 requestFunction: "functionName"App 不知道入口函数叫什么,不执行配套写上 requestFunction/responseFunction 指明函数名
responseJavascript 里又发请求职责错乱响应函数只管把 resObj 转成 list/字段

看懂这四段的意义:这是 @js: 的”专业版”——把发请求(requestJavascript,只有 config/params)和解析响应(responseJavascript,多个 resObj)拆成两个函数(对应②的分工),再用 requestFunction/responseFunction 指明入口。接口站字段映射多时,分开写比挤在一个 @js: 里清爽好维护。


本章 15 个技法均取自 sourceModelList(1).xbs(4779 源)的真实高频写法,是前 23 章未覆盖的进阶字段与套路,未编造。按请求构造→解析取值→目录章节→源级配置分组,方便按需查阅。


附:写一个书源的最短路径(背下来)

  1. 填骨架:sourceName / sourceUrl / weight("9999") / enable(1)
  2. searchBook:请求信息 + list + bookName + author + detailUrl,测。
  3. bookDetail:请求信息留空,映射简介/封面。
  4. chapterListlist + title(//text()) + url(//@href)
  5. chapterContentcontent(有广告按第 10 章清洗)。
  6. bookWorldrequestFilters + requestInfo + 列表字段。
  7. 保存三连测 → 交付。

本教程所有案例均取自 test/shuyuan/ 下 296 个真实书源,未编造。每条旁边就地解释了原理和仿写方法。