香色闺阁书源配置教程大全(小白版·带原理+仿写讲解)
本教程融合两部分:
- 参考指南《香色闺阁书源配置完全指南》的结构与规则(版本 StandarReader 2.56.1);
- 从
test/shuyuan/下解密的 296 个真实书源里扒出的实战写法(4102 条 XPath、682 条 JSONPath、631 条||@js:混用、1018 条纯@js:、237 条 URL 模板)。写给谁看:完全没写过书源、甚至没接触过 XPath/JSON/正则的新手。 本教程的规矩:每给一条规则或案例,当场就解释原理和怎么仿写,不把解释堆到最后。看到不懂的写法,往下一两行就有大白话说明。
目录
- 第 0 章 预备知识:看懂网页长什么样(不懂这个后面全看不懂)
- 第 1 章 一张图看懂书源怎么工作
- 第 2 章 顶层结构:一个书源文件的骨架
- 第 3 章 第一步 requestInfo:告诉 App 请求哪个网址
- 第 4 章 第二步 解析响应:从返回内容里抠出字段
- 第 5 章 五大功能模块
- 第 6 章 XPath 定位大全(配真实案例,逐条解释)
- 第 7 章 JSONPath:接口站怎么取字段
- 第 8 章
||@js:混用:选择器打底 + JS 收尾 - 第 9 章 纯 JS:请求改写 / 循环建表 / 伪造数据
- 第 10 章 正文清洗:去广告、去水印、解密
- 第 11 章 分页与翻页
- 第 12 章 分类与过滤器 filters
- 第 13 章 原生工具 nativeTool
- 第 14 章 WebView 破反爬
- 第 15 章 完整示例 A:用 XPath 写一个笔趣阁
- 第 16 章 完整示例 B:同一个站用纯 JS 写
- 第 17 章 防闪退规则 + 常见坑
- 第 18 章 可直接复制的 JSON 模板
- 第 19 章 函数/对象逐个详解(速查)
- 第 20 章 漫画源:正文返回图片数组
- 第 21 章 听书源:正文嗅探音频地址
- 第 22 章 视频源:多线路建表 + 嗅探播放地址
- 第 23 章 四种类型对照速查(小说/漫画/听书/视频)
- 第 24 章 进阶技法大全(4779 源实战深挖)
第 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">是另一个小盒子,里面写着”作者:天蚕土豆”。
关键概念:
- 标签名:
div、h3、a、span这些。 - 属性:写在标签里的
class="book"、href="...",格式是属性名="值"。 - 文本:标签中间夹的文字(“斗破苍穹”、“作者:天蚕土豆”)。
你要抠的东西无非三类:某个标签的文本、某个属性的值(如 href 链接)、一组重复的标签(如一页 20 本书)。怎么定位它们?用 XPath。
0.2 XPath 是什么?——网页里的”寻宝路线”
XPath 就是一句”路线指令”,告诉 App”顺着标签往下走,走到哪个元素”。还是上面那段 HTML:
| 我想要 | XPath 写法 | 读法(大白话) |
|---|---|---|
| 书名文字”斗破苍穹” | //h3/a/text() | 找任意位置的 h3,它下面的 a,取文字 |
链接 /book/123.html | //h3/a/@href | 同上,但取 a 的 href 属性 |
| 作者那段文字 | //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 章)。
三个保留字:
config、params、result是 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 章集中讲。
问题情境:模块下面的 actionID 和 parserID 是什么?
答案:
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 | 请求地址 | 去哪拿 |
POST | true 走 POST | 有些搜索必须 POST,参数放 body 而非网址 |
httpParams | 参数对象 | GET 时拼成 ?a=1&b=2,POST 时当表单 |
httpHeaders | 请求头 | 多数直接给 config.httpHeaders(用全局那份) |
forbidCookie | true 禁用 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 双斜杠,部分服务器 404 | config.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 ┘
④ ❌ 新手常踩的错(对着①的地址看为什么错):
| 错误写法 | 会怎样 | 正确写法 |
|---|---|---|
| 重新手拼整条目录 URL | book_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=%@keyWord | requestInfo |
问题情境:混用时 XPath 和 JS 谁先谁后?
答案:选择器在前,||@js: 在后。 选择器先取到值,作为 result 传进 JS,JS 里 return 的是最终结果。
真实案例(101言情小说.searchBook.author):
//div[@class="author"]|@js: return result.replace('作者:','');
拆开看:
//div[@class="author"]—— XPath 先取到节点文字,比如”作者:张三”。|@js:—— 分隔符,左边结果进入右边 JS 的result变量。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 | 章节点进去没地址,打不开正文 | 目录每项必须同时有 title 和 url |
详情返回 {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/url | App 拿不到章节名和地址,目录空白 | 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 desc | 291 源 |
bookDetail | 书籍详情 | bookName author cover desc status cat | 291 源 |
chapterList | 章节目录 | list title url | 291 源 |
chapterContent | 正文 | content | 291 源 |
bookWorld | 分类/排行 | requestFilters + list + 字段 | 291 源 |
这条链怎么走通(理解了这个才知道每个模块干嘛):
- 用户搜”斗破” →
searchBook返回一列书,每本带detailUrl。 - 用户点某本 → App 拿
detailUrl去请求 →bookDetail补全简介/封面。 - 用户点”目录” →
chapterList返回章节列表,每章带url。 - 用户点某章 → App 拿
url请求 →chapterContent返回正文。 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 是列表的”重复单元”,后面的bookName、author都是在单元内部再往下找。//div[2]/div[1]/span/a[1]:在单元里,第 2 个 div → 第 1 个 div → span → 第 1 个 a。[2]、[1]是序号(从 1 数),用来在多个同名标签里挑第几个。/@href:取<a>标签的href属性(也就是链接地址)。
怎么仿写:先在浏览器里右键”检查”,看清列表每本书外层是什么标签、有没有 id/class,先写 list;再看书名、作者分别在单元内哪个位置,一层层写下来。
🔍 原响应 → 规则 → 解析后(拿小说吧真实搜索页举例,这站结构没 class 可用,只能数序号):
① 服务器返回的 HTML(id="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 名一般不变。
怎么仿写:优先找元素有没有独特的 class 或 id,有就用它定位。/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/@src | bookimg 里 <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:image、og: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"]/@content | og: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> 的 onclick 和 href 两种来源。有的页面链接在 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万字"
}
}
}
想要的值埋在
data→book两层里面,得一层层点进去。
② 规则(七猫真实源 bookDetail 里的写法):
| 字段 | 规则 | 在 JSON 里怎么点 |
|---|---|---|
cat | $.data.book.category2_name | 最外层 → data → book → category2_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 //div | JSONPath 站点用 XPath 全部取空 | 接口站一律 $ 开头 |
list 指向的不是数组(如写成 $.data.book_name) | list 不是数组,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 埋在第几层,全部捞出来,一个 = 一本书 |
bookName | title(相对每个 bookInfo) | bookInfo 里的 title |
author | author | bookInfo 里的 author |
cover | cover | bookInfo 里的 cover |
detailUrl | bookId | bookInfo 里的 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 都捞来串味 | 字段相对 bookInfo 写 title 就行 |
看懂这四段的意义:遇到嵌套深、或结构可能变的接口 → 找到那个”装单本书信息的对象名”(这里是 bookInfo)→ 用 $..对象名 一把捞(②)→ 字段相对它写(③)→ 省得数层级还抗改版。$.. = 不数层数,按名字在整个 JSON 里全局找。
7.4 简单路径(不带 $)
真实案例(y-一纸(app) 正文):
data/chapterInfo[0]/chapterContent[0]
解释:这是简写形式,等价于 JS 的 result.data.chapterInfo[0].chapterContent[0]。看到这种没有 $、用 / 分隔的,就理解成”一层层的 key”。用 $ 开头的标准 JSONPath 更通用,推荐优先用它。
🔍 原响应 → 规则 → 解析后(拿 y-一纸(app) 正文接口举例,看不带 $ 的 / 路径怎么走):
① 服务器返回的 JSON(正文接口返回,chapterInfo、chapterContent 都是数组):
{ "data": { "chapterInfo": [ { "chapterName": "第1章", "chapterContent": [ "斗气大陆,天才辈出……" ] } ] } }
② 规则(y-一纸(app) 真实源 chapterContent.content):
| 字段 | 规则 | 这条路径怎么走 |
|---|---|---|
content | data/chapterInfo[0]/chapterContent[0] | data → chapterInfo 第 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 先取到的原始值(作者名前面粘着”作者:”):
作者:辰东
② 规则(天天书吧-电脑版 真实源 searchBook 的 author 字段):
| 字段 | 规则 | 拿到 result 后做什么 |
|---|---|---|
author | //span ||@js: return result.replace(/作者:/,"") | 取到”作者:辰东”,把”作者:“这几个字替换成空(删掉) |
③ App 解析后得到(前缀被擦掉):
{ "author": "辰东" }
④ ❌ 新手常踩的错:
| 错误写法 | 会怎样 | 正确写法 |
|---|---|---|
replace("作者:","") 用半角冒号 | 页面里是全角”:“,半角 : 匹配不到,前缀没删掉 | 复制页面里真实那个冒号(多半是全角”:“) |
忘了写 return | JS 不 return,字段拿到 undefined | replace 完的结果一定要 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 不中返回 null,null[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'。
🔍 原响应 → 规则 → 解析后(接口把”是否完结”存成数字,要翻译成中文):
① 接口返回的 JSON(finished 是个数字,光看数字用户不懂):
{ "book_name": "斗破苍穹", "finished": 0 }
{ "book_name": "遮天", "finished": 1 }
② 规则(JSONPath 先取到数字,||@js: 把数字翻译成中文):
| 字段 | 规则 | 拿到 result 后做什么 |
|---|---|---|
status | $.finished ||@js: return result == 0 ? '连载':'完结' | result 是 0 → 返回”连载”;是 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
② 规则(♡⃝.盘古小说网 真实源 chapterList 的 url 字段):
| 字段 | 规则 | 拿到 result 后做什么 |
|---|---|---|
url | //a/@href ||@js: return params.responseUrl + result | href 只有”1.html”,接到当前目录页地址后面,补成完整网址 |
③ App 解析后得到(假设当前目录页 responseUrl 是 https://www.pangu.com/book/12345/):
{ "url": "https://www.pangu.com/book/12345/1.html" }
④ ❌ 新手常踩的错:
| 错误写法 | 会怎样 | 正确写法 |
|---|---|---|
直接 //a/@href 不补 | 只有”1.html”,App 请求这个不完整地址会失败 | 用 params.responseUrl 或 config.host 补全 |
相对当前目录的链接却用 config.host + result | config.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 据此发出的真实请求(_type 和 pageIndex 代入模板串):
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)×10 | 0(从第 0 条开始,即第一页) |
| 2 | (2-1)×10 | 10(跳过前 10 条) |
| 3 | (3-1)×10 | 20(跳过前 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 标志:/.../i | while 死循环(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") 把它们换成换行。replaceAll 和 replace(/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> 只换掉第一个,正文还是挤成一坨 | 用 replaceAll 或 replace(/<\/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 把密文解开。key和iv要从网页的 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 开始,直接用 pageIndex | App 的 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 的筛选组里选中的值。
🔍 选中值 → 规则 → 发出的请求(用户选了”玄幻”,看 %@filter 和 params.filters.type 两种取法各自拼出什么):
① 用户选中后 App 手里的值(12.1 里定义的”玄幻”→ 值 “1”):
params.filters.type = "1" params.pageIndex = 1
② 两种取法的规则(URL 模板用 %@filter,JS 用 params.filters.组名):
| 写法 | 规则 | 谁来替换 |
|---|---|---|
| URL 模板 | /category/%@filter?page=%@pageIndex | App 自动把 %@filter 换成选中值 |
| JS 取值 | config.host + "/api/getTypeNovel?second_type=" + params.filters.type | JS 里手动取 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.type 里 type 和定义的组名不符 | 取到 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) | 必须满足 |
|---|---|---|
组名 _type | params.filters._type | 两处名字完全一致(含下划线) |
组名 type | params.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 / setCache | 16 / 15 | 跨请求存取数据(token、bid) |
log | 14 | 打印调试 |
XPathParserWithSource | 13 | 在 JS 里再用一次 XPath |
md5Encode | 12 | 算 MD5 签名 |
deviceId | 5 | 取设备号 |
dataByAesDecryptWith... | 2 | AES 解密 |
stringByObject | 2 | 对象转字符串 |
Base64DecodeToString | 1 | Base64+AES 解密 |
cookiesByUrl | 1 | 取 Cookie |
说明:参考资料里还列了
sha1Encode、base64Encode、deviceIdWithTemplate、unzipFile等方法,但本批源没用到,这里不展开,用到时查参考手册即可。
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 搜 AES、decrypt、CryptoJS),找到后套进 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——这些是浏览器环境才有的。- 从页面里
match出token(反爬要的令牌)。 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.responseText | WebView 里 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"]/@content | book_name 那个 meta 的 content |
author | //meta[@property="og:novel:author"]/@content | author meta 的 content |
cover | //meta[@property="og:image"]/@content | og 的 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/dd | id 是 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 |
moreKeys 里 pageSize 写成数字 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 变乱码,服务器搜不到 | 用 encodeURI 或 encodeURIComponent 编码 |
返回时忘了 return | requestInfo 拿到 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)截出正文那一段。 return:response装正文,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 章) |
| 中文进 URL | encodeURIComponent(第 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 章讲的都是小说(
sourceType为text或不写)。但香色同一套结构也能写漫画/听书/视频——搜索、详情、目录三个模块的写法几乎和小说一样,唯一大改的是”正文”模块:小说正文返回文字,漫画返回一串图片地址,听书/视频返回一个媒体播放地址。这三章就专讲各自的正文怎么写。本章的案例全部取自新解密的 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();
真正不同的是 chapterContent 的 content 字段:它不返回文字,而是返回这一话所有图片的 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.stringify | App 拿到裸数组,识别不了多图+请求头结构 | 用 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}});
逐句拆解:
result是整段网页源码。result.match(/(eval\([\s\S]+?)var jPicList/)[1]:用正则截出从eval(开始、到var jPicList之前的那段打包代码([\s\S]+?是”任意字符非贪婪”,.默认不匹配换行,所以用[\s\S])。eval(...):执行这段代码,它会在当前作用域里定义出picTree(图片树变量,名字由站点决定)。try/catch兜底:万一站点改版正则没匹配上,不至于整个源崩掉。- 最后把
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 名字由站点决定,抄错取到 undefined | F12 看 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 时,chapterContent 的 content 用 JSONPath 取即可:
$.data.content
番茄听书这类 API 站,请求某集接口就直接回
{data:{content:"http://xxx.mp3"}},一条 JSONPath 搞定。这是最理想的情况。
21.3 写法二:WebView 嗅探音频(最常见)
多数网页听书站,音频是播放器 JS 加载的,源码里没有。这时 chapterContent 的 requestInfo(注意是请求信息,不是 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 动态加载 | requestInfo 里 webView + 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 进list,title带上线路名方便用户区分。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):
① 接口返回的 JSON(videoplayurl 不是明文地址,而是一串 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 |
|---|---|---|---|---|
sourceType | text/不写 | comic | audio | video |
| 搜索/详情/目录 | —— 四类写法基本一致,照第 5~9 章 —— | |||
| 正文要拿什么 | 文字 | 一话的多张图 | 一个音频地址 | 一个视频流地址 |
| 正文返回键 | content 文本 | urls(复数,数组) | url(单数) | url(单数) |
| 主要手段 | 选择器 + 清洗 | 取 @src / 取数组 / eval | webView 嗅探 mp3/m4a | webView 嗅探 m3u8/mp4 |
| 防盗链 | 一般不用 | 常需 Referer | 常需 Referer + Range | 常需 Referer + 签名 |
| 特有难点 | 去广告水印(第 10 章) | 图片被 JS 混淆 | 地址靠嗅探/回捞 | 多线路建表 + 签名 |
| 目录常见操作 | —— | 常 reverse() 倒序 | 每条=一集 | JSParser 拉平多线路 |
一句话记忆:
- 漫画 = 正文给图片数组(
urls复数 + Referer)。 - 听书 = 正文嗅探音频(
webView+sourceRegex: mp3\|m4a)。 - 视频 = 正文嗅探视频 + 多线路 + 签名(
webView+m3u8\|mp4,JSParser建表)。 - 其余照小说写就行。
本三章案例取自新解密的
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.author | book_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} 前没人 @put 过 bid | 填空 | 先在上游 @put |
键名 bid 两处不一致 | 取不到 | @put 和 @get 键名一致 |
看懂这四段的意义:只是”取个字段顺手存个 id”这种小活,不必写 @js:——字段@put:{键:路径} 一步搞定(②),下游 @get:{键} 填回来(③)。@put/@get 是 java.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_id 后 java.put("id", result) 存进内存,后面正文模块再 java.get("id") 取出来拼接口。跨模块共享数据靠它。
🔍 数据流 → 规则 → 结果(番茄的 book_id 怎么从详情传到目录/正文):
① 详情模块拿到的值:
result = "7143038718021782" (book_id)
② 规则(三个模块接力):
| 模块 | 写法 | 干什么 |
|---|---|---|
| bookDetail | java.put("id",result) | 把 book_id 存进键 id |
| chapterList | ...?book_id="+java.get("id") | 取出 book_id 拼目录接口 |
| chapterContent | java.get("id") | 正文再用同一个 id |
③ App 解析后(目录/正文都能拿到详情存的 id):
chapterList 请求 → ...?book_id=7143038718021782
④ ❌ 新手常踩的错:
| 错误写法 | 会怎样 | 正确写法 |
|---|---|---|
详情没 put,目录直接 java.get("id") | 取到空,接口缺参数 | 先在上游模块 java.put 存 |
put 和 get 的键名写得不一致 | 取到 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 解 → 正文全是"锟斤拷"乱码
② 规则(在模块里加这两个字段):
| 字段 | 值 | 作用 |
|---|---|---|
requestParamsEncode | 2147485234(GBK 枚举) | 关键词按 GBK 编码进 URL |
responseEncode | 2147485234(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:$.categoryName | JSONPath 取 categoryName |
@json: 深搜 | @json:$..bookVo.introduction | 任意层找 bookVo.introduction |
@css: | @css:.t_f | XPath //*[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")] 又会误中 wrapper。concat(' ',...,' ') 给 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() 直接 .map | jsoup 返回的不是 JS 数组,没有 map | 先 .toArray() 转成 JS 数组 |
.map 里返回 {n,u} 却没加外层括号 ({...}) | 箭头函数把 {} 当代码块,返回 undefined | 返回对象要写 x=>({...}) |
| 字段名和 list 后续规则对不上 | App 取不到 title/url | map 出的键名要和字段规则一致(或用 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>的值当各页地址。 怎么仿写:目录只显示了一部分章节、底部有”下一页”或页码下拉 → 在chapterList加nextPageUrl,取下一页链接。
🔍 原响应 → 规则 → 解析后(拿 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;
解释:
tocUrl是bookDetail模块的字段。它算出的地址会被chapterList当作请求地址(chapterList的requestInfo里可以用%@result或params.queryInfo.tocUrl取到)。起点的详情页 URL 里有info/12345这种书 id,用正则抠出id,拼成完全不同的目录接口地址。 怎么仿写:站点详情页和目录页不是同一个地址(尤其接口站)→ 在bookDetail用tocUrl单独算目录地址。
🔍 原响应 → 规则 → 解析后(拿 起点中文 举例,看详情页怎么算出另一个目录接口):
① 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 |
算出 tocUrl 但 chapterList 不去用它 | 目录还是请求详情地址,拿不到章节 | 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;
解释:
isVip和title/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:true | App 不会自动发第二次请求 | 需要续请求时带上它 |
| 两次都返回同结构 | 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 登录页 → 登录后正常看全本
④ ❌ 新手常踩的错:
| 错误写法 | 会怎样 | 正确写法 |
|---|---|---|
把网站账号密码填进 password | password 是源访问密码,不是网站账号,填错导致别人要输你的网站密码 | 网站登录靠 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 里的 resourcename→bookName 等映射成标准字段 |
③ 最终 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 章未覆盖的进阶字段与套路,未编造。按请求构造→解析取值→目录章节→源级配置分组,方便按需查阅。
附:写一个书源的最短路径(背下来)
- 填骨架:
sourceName/sourceUrl/weight("9999")/enable(1)。 searchBook:请求信息 +list+bookName+author+detailUrl,测。bookDetail:请求信息留空,映射简介/封面。chapterList:list+title(//text())+url(//@href)。chapterContent:content(有广告按第 10 章清洗)。bookWorld:requestFilters+requestInfo+ 列表字段。- 保存三连测 → 交付。
本教程所有案例均取自
test/shuyuan/下 296 个真实书源,未编造。每条旁边就地解释了原理和仿写方法。