# qn_read_app **Repository Path**: qbrid/qn_read_app ## Basic Information - **Project Name**: qn_read_app - **Description**: 青鸟站点开发套件 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2023-07-06 - **Last Updated**: 2023-09-24 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 青鸟站点规则 >以下为青鸟站点的解析规则
>方便大家自行添加自己想适配的站点 >本手册分为 [前言]、[环境准备]、[站点规则]、[使用案例] 四个章节

## 前言

### 1、兼容阅读源了吗? 答案是并没有。 主要是下面几个原因 + 阅读的源规则其实比较混乱,略显复杂。 + 青鸟是致力于全平台(iOS、Android、MacOS、Windows、Linux、Web)的阅读工具。 阅读中部分高级源使用了Java平台本身的API,在其它平台无法兼容。 + jsoup的语法是真的看不出逻辑... ### 2、会一直维护吗? 会一直维护,类似站点规则、站点编辑APP、站点源汇总,目前都已开源。


## 环境准备 推荐使用 Flutter(3.X)版本 + VSCode编辑器 + 青鸟站点编辑工具(发布必备)。 其中 Flutter(3.X) 及 VSCode编辑器 非必须。如使用VSCode,VSCode需安装Flutter插件。 Flutter下载地址:https://flutter.cn/docs/get-started/install VSCode下载地址:https://code.visualstudio.com/Download 青鸟站点编辑工具下载地址:MacOS端(http://img.novel.onedayapp.cn/packages/qn_source_app.app.zip) Windows端(http://img.novel.onedayapp.cn/packages/qn_source_app.windows.zip)
### 站点规则 ______ 青鸟站点规则分为两类 + 文本站点规则 使用类似阅读的源规则进行声明的JSON文本规则,适用于简单站点 + 动态Dart站点规则 通过实现规则阅读接口,使用Dart语言进行编程的高级动态规则 ## 文本站点规则 文本站点规则,格式为Json,范例如下: ```json { "siteId": "站点ID,建议使用http地址", "info": { "type": "站点类型: book 书籍; listenBook 听书; comic 漫画", "showName": "站点显示的名称", "group": "站点分组", "desc": "站点简介", "comicShowType": "漫画目录显示类型,仅站点类型为漫画生效,只为 small 小; middle 中等; big 大。不传值默认为middle", "updateTime": "更新时间戳,", "versionNumb": 0, "supportSearch": true, "supportExplore": true, "vpnWebsite": "声明此站点需要VPN。如不需要,置空,如需要,填入需VPN转接的网址" }, "exploreUrl": "发现页URL,格式为 title::url\ntitle::url,多个Url以\n分割", "searchUrl": "搜索页URL,书名搜素" "ruleExplore": { "bookList": "获取发现列表", "bookName": "获取 书名", "author": "获取 作者名称", "cover": "获取 封面图", "desc": "获取 简介", "tags": "获取标签,格式为 List,多个标签使用\n分割", "newestChapter": "获取 最新章节", "wordCount": "获取字数,格式为 数字", "star": "获取评分,格式为 double,大小位于 0.0 ~10.0,默认为8.5", "detailUrl": "获取 详情页URL", "tocUrl": "获取 目录页URL" }, "ruleSearch": { "bookList": "获取搜索列表", "bookName": "获取 书名", "author": "获取 作者名称", "cover": "获取 封面图", "desc": "获取 简介", "tags": "获取标签,格式为 List,多个标签使用\n分割", "newestChapter": "获取 最新章节", "wordCount": "获取字数,格式为 数字", "star": "获取评分,格式为 double,大小位于 0.0 ~10.0,默认为8.5", "detailUrl": "获取 详情页URL", "tocUrl": "获取 目录页URL" }, "ruleDetail": { "bookName": "获取 书名", "author": "获取 作者名称", "cover": "获取 封面图", "desc": "获取 简介", "tags": "获取标签,格式为 List,多个标签使用\n分割", "newestChapter": "获取 最新章节", "wordCount": "获取字数,格式为 数字", "star": "获取评分,格式为 double,大小位于 0.0 ~10.0,默认为8.5", "detailUrl": "获取 详情页URL", "tocUrl": "获取 目录页URL" }, "ruleToc": { "tocList": "获取 目录列表", "nextTocUrl": "获取 下一页目录,如存在多个值,使用\n分割", "title": "获取 章节标题", "url": "获取 章节URL", "needVip": "获取 是否需要VIP", "wordCount": "获取 章节字数,格式为数字,默认为0" }, "ruleContent": { "content": "获取章节内容,如获取的是漫画,多个URL之间使用\n分割", "nextUrls": "获取 下一页内容,如存在多个值,使用\n分割", "nextJoin": "仅 获取书籍时且存在下一页内容时生效" }, "verify": { "searchKey": "验证搜索的书名,确保 搜索时搜到的第一个item的书名与searchKey相同,否则验证失败", "detailPrefix": "发现页的第一个Item的detailUrl 及 搜索结果的第一个item的detailUrl 必须以 detailPrefix为前缀,否则验证失败" } } ``` _____ `siteId`值为站点ID,一般为站点的网址URL _____ `info`内包含站点规则的基本信息,格式如下 ```json { "type": "站点类型: book 书籍; listenBook 听书; comic 漫画", "showName": "站点显示的名称", "group": "站点分组", "desc": "站点简介", "comicShowType": "漫画目录显示类型,仅站点类型为漫画生效,只为 small 小; middle 中等; big 大。不传值默认为middle", "updateTime": "更新时间戳,", "versionNumb": 0, "supportSearch": true, "supportExplore": true, "vpnWebsite": "声明此站点需要VPN。如不需要,置空,如需要,填入需VPN转接的网址" } ``` _____ `exploreUrl`值为发现页URL `searchUrl`值为搜索页URL,此为书名搜索 _____ `ruleExplore`描述了发现页列表的取值规则,格式如下: ```json { "bookList": "获取发现列表", "bookName": "获取 书名", "author": "获取 作者名称", "cover": "获取 封面图", "desc": "获取 简介", "tags": "获取标签,格式为 List,多个标签使用\n分割", "newestChapter": "获取 最新章节", "wordCount": "获取字数,格式为 数字", "star": "获取评分,格式为 double,大小位于 0.0 ~10.0,默认为8.5", "detailUrl": "获取 详情页URL", "tocUrl": "获取 目录页URL" } ``` ______ `ruleSearch`描述了搜索页列表的取值规则,格式如下: ```json { "bookList": "获取发现列表", "bookName": "获取 书名", "author": "获取 作者名称", "cover": "获取 封面图", "desc": "获取 简介", "tags": "获取标签,格式为 List,多个标签使用\n分割", "newestChapter": "获取 最新章节", "wordCount": "获取字数,格式为 数字", "star": "获取评分,格式为 double,大小位于 0.0 ~10.0,默认为8.5", "detailUrl": "获取 详情页URL", "tocUrl": "获取 目录页URL" } ``` _____ `ruleDetail`描述了详情页的取值规则,格式如下: ```json { "bookName": "获取 书名", "author": "获取 作者名称", "cover": "获取 封面图", "desc": "获取 简介", "tags": "获取标签,格式为 List,多个标签使用\n分割", "newestChapter": "获取 最新章节", "wordCount": "获取字数,格式为 数字", "star": "获取评分,格式为 double,大小位于 0.0 ~10.0,默认为8.5", "detailUrl": "获取 详情页URL", "tocUrl": "获取 目录页URL" } ``` _______ ### 注意,`ruleExplore`与`ruleSearch`的取值字段完全一样,`ruleDetail`相较于前者,少了一个`bookList`字段。 ______ `ruleToc`描述了目录列表的取值规则,格式如下: ```json { "tocList": "获取 目录列表", "nextTocUrl": "获取 下一页目录,如存在多个值,使用\n分割", "title": "获取 章节标题", "url": "获取 章节URL", "needVip": "获取 是否需要VIP", "wordCount": "获取 章节字数,格式为数字,默认为0" } ``` _______ `ruleContent`描述了内容页的取值规则,格式如下: ```json { "content": "获取章节内容,如获取的是漫画,多个URL之间使用\n分割", "nextUrls": "获取 下一页内容,如存在多个值,使用\n分割", "nextJoin": "仅 获取书籍时且存在下一页内容时生效" } ``` ________ `verify`描述了整体站点的验证规则,格式如下: ```json { "searchKey": "验证搜索的书名,确保 搜索时搜到的第一个item的书名与searchKey相同,否则验证失败", "detailPrefix": "发现页的第一个Item的detailUrl 及 搜索结果的第一个item的detailUrl 必须以 detailPrefix为前缀,否则验证失败" } ``` ________ ### 字段规则 文本规则中可划分为 4 类 + 网络调用 + 字段取值 #### **网络调用** 整体规则中, `exploreUrl`、`searchUrl`、`detailUrl`、`tocUrl`以及目录章节中的`ruleToc->url`会在特定时机进行网络调用。
网络调用的格式为: ``` http://www.baidu.com,{"method":"get","requestData":"xx=1&&yy=2" "headers":{"header1":"headerValue1","header2":"headerValue2"},"webview":"0","responseCharset":"gbk"} ``` 其中,`http://www.baidu.com`为url地址,`,`为分割符,后面接入的参数为Json字符串,描述了 url的调用规则。 参数详解: * `method`描述了http调用方法,可取值为 `get`、`post`、`put`、`delete`、`options`,默认为`get`,为默认值时,可省略。 * `requestData`描述了http请求体,只在调用方法为 `post`、`delete`、`put`时生效,默认为空,可省略。 * `headers`描述了http请求头,默认为空,可省略。 * `webview`描述了是否启用web容器进行网络请求,可取值为 `1`或者`0`,默认为`0`,可省略。 一般无需设置,当调用的网页需要让网页自身调用自身的JS逻辑时,可设置为`1` * `responseCharset`描述了请求体的解码规则,可选值为`utf8`或`gbk`,默认为`utf8`,可省略。 注意,`gbk`兼容`gb2312`,当文本编码格式为`gb2312`时,`responseCharset`设置为`gbk`即可解析。 因此 `https://www.baidu.com?xx=1`等价于`https://www.baidu.com?xx=1,{"method":"get","requestData":"" "headers":{},"webview":"0","responseCharset":"utf8"}` #### **字段取值** `{{xx}}`规则中使用`{{}}`进行取值操作。 取值操作支持 `XPath`、`JsonPath`、`App方法调用`、`书籍数据`、`章节数据`、`过程记录数据` 、`字符串取值` + `XPath`取值以`/`开头,如`{{/div[@class="test"]/p/text()}}` + `JsonPath`取值以`$`开头,如`$.id` + `App方法调用`取值以`QB.方法(参数1、参数2、参数3)`开头。 如 `{{QB.gbk($.key)}}`计算为 取得参数`key`,并对`key`进行`gbk`编码 + `书籍数据`取值以`book.`开头,如`{{book.bookId}}`取值为`书籍`的`bookId`,`book`对象拥有如下属性 > + bookId 书籍ID > + siteId 站点ID > + siteVersion 站点版本 > + bookName 书名 > + detailUrl 详情页URL > + tags 标签,数组,对个标签以`\n`分割 > + star 评分 > + wordCount 字数 > + newestChapter 最新章节 > + tocUrl 目录页URL > + author 作者 > + cover 封面图 > + desc 简介 + `章节数据`取值以`chapter.`开头,如`{{chapter.title}}`取值为`章节`的`title`,`chapter`对象拥有如下属性 > + title 标题 > + url 章节URL(也是内容页的URL) > + needVip 是否需要VIP > + wordCount 章节字数 **注意 `chapter`取值只在详情页可用。** + `过程记录数据`指取得计算过程中的记录值,关键字为 `preV`,例如 http://baidu.com,,,{{preV}} 计算值为 http://baidu.com 。 关于`,,,`的用法后续介绍。 ________ + `字符串取值`取值指,当取值中的字符,无法匹配上述规则时,则会被当成字符串处理。 如 `{{this is a data}}`计算值为`this is a data`。 > 为便于大家理解,以下为相关范例。 > 假定现在返回的字符串为 `{"id":"10086","pId":"10324","bookName":"圣墟","tag":"玄幻","tag1":"仙侠"}` > + 如需取值 书名,可写为 `"{{$.id}}"`,可简写为 `"$.id"` ,计算值为 `"10086"` > + 如需取标签,可写为 `"{{$.tag}},{{$.tag1}}"`,计算值为 `"玄幻,仙侠"` **注意,可以对对个取值进行拼接** > + 如需取详情页地址,可写为`"http://test.com/{{$.id}}/{{$.pId}}.html"`,计算值为`"http://test.com/10086/10324.html"` #### 赋值及取值操作 赋值操作以`@put(key,value)`格式执行。如进行赋值后,后续可通过`{{G_key}}`进行取值操作。 以下为范例 > 假定现在返回的字符串为 `{"id":"10086","pId":"10324","bookName":"圣墟","tag":"玄幻","tag1":"仙侠"}` > `@put(bookId,$.id),,,http://test.com/{{G_bookId}}`计算值为 http://test.com/10086 ,关于`,,,`分割后后续介绍 #### 正则替换操作 正则操作格式分为两种。 + #正则表达式#替换的值# ---单次匹配 + #正则表达式#替换的值## ---全部匹配 正则匹配的输入值为 `上一操作的过程值`,即`{{preV}}` 为便于大家理解,范例如下: (单次匹配)表达式`"http://test.com/10086/10086.html,,,#10086#-#"`取值为 http://test.com/-/10086.html (全部匹配)表达式`"http://test.com/10086/10086.html,,,#10086#-##"`取值为 http://test.com/-/-.html 以上述单次匹配表达式进行执行拆解。 `"http://test.com/10086/10086.html,,,#10086#-#"` 执行时被分拆为 `http://test.com/10086/10086.html` ,`,,,`,`#10086#-#`,其中 `http://test.com/10086/10086.html`的执行结果为 匹配为字符串http://test.com/10086/10086 , `,,,`为表达式分隔符,会将前一步骤的计算结果保存到`preV`变量中,然后执行第三部分正则替换`#10086#-#` **正则表达式取值关键词** `$0`只在正则表达式中生效。代表获取正则匹配的值。 为便于大家理解,以下为相关范例: + 范例一:返回值为`"这个是反馈的数据"`,表达式为`#.*#{{$0}}#`,取值为`这个是反馈的数据` + 范例二:返回值为`"这个是反馈的数据"`,表达式为`#这个是(.*)#{{$0}}`,取值为`这个是反馈的数据`
`$(index)`只在正则表达式中生效,类似`$1`、`$2`、`$3` ... ,代表取`第一个`、`第二个`、`第三个分组`、... 的数据。 以下为相关范例: + 范例一: 返回值为`"这个是反馈的数据"`,表达式为`#这个是反馈的(.*)#{{$1}}#`,取值为`数据` + 范例二: 返回值为`"这个是反馈的数据"`,表达式为`#这个是(.*)的(.*)#{{$0}}--{{$1}}--{{$2}}`,取值为`这个是反馈的数据--反馈--数据`。 _______________ #### 操作符详解 **列表取值** 列表取值不支持`{{}}`取值操作。 `ruleExplore -> bookList`、`ruleSearch -> bookList`及`ruleToc -> tocList`这三个字段为列表取值操作。目前只支持 `&&` 、`||`、`%%`操作。 + `&&` 为取合集,格式为 `A&&B&&C` + `||` 为取或集合,格式为 `A||B`,计算时,当`A`存在值,且不为空时,直接返回结果,不进行`B`的计算,否则返回`B`的结果 + `%%` 为轮询插入,格式为 `A%%B%%C`,计算是,组装列表,会先从 `A`取第一个元素,再取`B`的第一个元素,再取`C`的第一个元素。 为便于大家理解,范例如下: 假定当前返回值为 ```json { "dataList_1":["AAA","BBB","CCC"], "dataList_2":["111","222","333"], "dataList_3":["aaa","bbb","ccc"], } ``` 基于上述反馈值,计算结果如下: * `$.dataList_1&&$.dataList_2&&$.dataList_3`计算值为 `["AAA","BBB","CCC","111","222","333","aaa","bbb","ccc"]` * `$.dataList_1||$.dataList_2`计算值为 `["AAA","BBB","CCC"]` * `$.dataList_1%%$.dataList_2`计算值为 `["AAA","111","BBB","222","CCC","333"]` **字段取值** 字段取值支持 `{{}}`、`&&`、`||`、`,,,`、`#正则#替换#`、`@put(key,value)`、`字符拼接`等操作规范。 `{{}}`取值在上述章节已有描述,在此不进行重复描述。 `字符拼接` 格式为 `"字符串{{key1}}字符串{{key2}}字符串"`,相关范例如下:`http://test.com/{{$.id}}.html`,在计算过程中,会计算为 `http://test.com/` + `{{$.id}}` + `.html`
`,,,` 为多计算分隔符,顶级运算符,最高优线级运算符。并声明`preV`为前次计算的结果。 **为便于大家理解,以下为相关范例。** + 范例一:`http://test.com,,,{{preV}}`,取值结果为 `http://test.com`,这里注意,如想取值`上一次操作的计算结果`,使用`{{preV}}`,不能使用`preV`,单纯的`preV`会被当做字符串解析。
以下为解析过程:`http://test.com,,,{{preV}}`被`,,,`分割为`http://test.com`与`{{preV}}`两部分,从左到右执行,`http://test.com`的计算结果为 `http://test.com`,因被`,,,`分割,将`http://test.com`的结果赋值到`preV`局域计算变量中,后续的`{{preV}}`则执行`preV变量`的取值操作。 + 范例二:`http://test.com,,,abc`,取值结果为`abc`。 + 范例三: `http://test.com,,,@put(url,preV)`,取值结果为 `http://test.com`,并且插入自定义字段`url`,值为`http://test.com` + 范例四: `http://test.com,,,@put(url,abc)`,取值结果为 `http://test.com`,并且插入自定义字段`url`,值为`abc` + **`@put`** 操作不会对计算结果产生影响,只做值传递。并且`@put(key,value)`中的value取值不需要带`{{}}`。 + 范例五:`http://test.com/{{$.id}},,,@put(bId,$.id),,,#{{preV}}}#replace#` 取值为 `replace`,并且插入值`bId`为`$.id` + 范例六: 输入值为`{"id":"10086","pId":"1"}`,计算表达式为`http://test.com/{{$.id}}-new/{{$.pId}}-new.html,,,@put(bId,$.id),,,@put(pId,$.pId),,,#http://test.com/(.*)/(.*)\.html#{{$1}}&{{$2}}#`,计算值为 `10086-new&1-new` `&&`为字段组合运算符,类比`+`,以下为相关实例: * 范例一:返回结果为`{"id":1,"name":"元尊"}`,表达式`$.id&&$.name`取值为`1元尊`,也可类比为 `{{$.id}}{{$.name}}` * 范例二:返回结果为`{"id":1,"name":"元尊"}`,表达式`$.id&&---&&$.name`取值为`1元尊`,也可类比为 `{{$.id}}---{{$.name}}`,以及`{{$.id}}{{---}}{{$.name}}` * 范例三: 返回结果为`{"id":1,"name":"元尊"}`,表达式`$.id&&$.name,,,{{preV}}`取值为`1元尊`。 **`,,,`** 的优先级高于 **`&&`** `||`为字段组合运算符,类比`或运算`,以下为相关实例: * 范例一:返回结果为`{"id":1,"name":"元尊"}`,表达式`$.id||$.name`取值为`1` * 范例二:返回结果为`{"id":1,"name":"元尊"}`,表达式`http://test/{{$.id||$.name}}`此表达式无法计算,`{{}}`为取值操作,不支持`||`运算,正确写法应为`$.id||$.name,,,http://test/{{preV}}`或者`$.id||$.name,,,#.*#http://test/{{$0}}` _____________ **特殊字段处理** `发现页Url`-`exploreUrl`特殊规则 发现页在App操作时,会触发翻页,因此在取值时会注入一个Json字符串`{"page":"1"}`,因此`exploreUrl`需获取页码时,应写成 `http://explore.test/{{$.page}}`,页码默认为1。 `exploreUrl`支持App内置方法。 + 比如,站点的页码`从0开始`,那么可使用表达式`http://explore.test/{{QB.plus(page,-1)}}`,`QB.plus(page,-1)`会执行 `page` `+` `(-1)` + 比如,站点的间隔为`页码的10倍`,那么可使用表达式`http://explore.test/{{QB.muti(page,10)}}`,`QB.muti(page,10)`会执行`page` `*` `10` + 比如,站点的间隔为`页码的10倍+12的偏移量`,那么可使用表达式`http://explore/test/{{QB.plus(QB.muti(page,10),12)}}`,`QB.plus(QB.muti(page,10),12)`会执行`(page * 10) + 12` + 比如,站点的第一页为`http://explore/test/`,第二页为`http://explore/test/2`,那么可使用表达式`http://explore/test/{{QB.emptyOr(page,1)}}` `QB.emptyOr(A,B)`表达式的计算逻辑为:当`A == B`时,输出`空字符串`,否则输出`A`,等价于三目运算符 ` A == B ? "" : A`
`搜索页`-`searchUrl`特殊规则 App在进行搜索时,会将用户搜索的书名以Json形式`{"key":"元尊"}`的形式注入。因此`searchUrl`在获取书名进行搜索时,应写成: + 范例一: `http://example/search?bookName={{$.key}}` + 范例二: `http://example/search,{"method=post","requestData":"type=bookName&&searchKey={{$.key}}"}`
`内容页`-`content`特殊规则 因书源支持 `书籍(book)`、`听书(listenBook)`、`漫画(comic)`等多种类型。 + 其中 如果`info -> type`取值为`书籍(book)`时,`content`返回`内容文本`即可。 + 其中 如果`info -> type`取值为`听书(listenBook)`时,`content`返回`音频http地址`即可,格式如下: > 1. http://music.test/test.mp3 > 2. http://music.test/test.mp3,{"headers":{"refer":"http://music.test"},"method":"get"} + 其中 如果`info -> type`取值为`漫画(comic)`时,`content`返回`漫画http地址数组`即可,数组以`\n`分割,格式如下: > 1. `"http://comic.test/1.jpg\nhttp://comic.test/2.jpg\nhttp://comic.test/3.jpg"` > 2. `http://comic.test/1.jpg,{"headers":{"refer":"http://comic.test"}}\nhttp://comic.test/2.jpg,{"headers":{"refer":"http://comic.test"}}\nhttp://comic.test/3.jpg,{"headers":{"refer":"http://comic.test"}}` ### 书源范例 + 范例一: ```json { "siteId": "http://www.biqu5200.net", "info": { "type": "book", "showName": "B5200", "group": "青鸟", "desc": "笔趣阁@5200", "updateTime": "1234", "versionNumb": 100, "supportSearch": true, "supportExplore": true }, "exploreUrl": "玄幻小说::http://www.biqu5200.net/xuanhuanxiaoshuo/,{\"responseCharset\":\"gbk\"}\n修真小说::http://www.biqu5200.net/xiuzhenxiaoshuo/,{\"responseCharset\":\"gbk\"}\n都市小说::http://www.biqu5200.net/dushixiaoshuo/,{\"responseCharset\":\"gbk\"}\n穿越小说::http://www.biqu5200.net/chuanyuexiaoshuo/,{\"responseCharset\":\"gbk\"}", "searchUrl":"http://www.biqu5200.net/modules/article/search.php?searchkey={{$.key}},{\"responseCharset\":\"gbk\"}", "ruleExplore": { "bookList": "//div[@class=\"l\"]/ul/li", "bookName": "//span[@class=\"s2\"]/a/text()", "author": "//span[@class=\"s5\"]/text()", "newestChapter": "//span[@class=\"s3\"]/a/text()", "detailUrl": "http://www.biqu5200.net{{//span[@class=\"s2\"]/a/@href}},{\"responseCharset\":\"gbk\"}", "tocUrl": "http://www.biqu5200.net{{//span[@class=\"s2\"]/a/@href}},{\"responseCharset\":\"gbk\"}" }, "ruleSearch": { "bookList": "//table[@class=\"grid\"]/tbody/tr[position() > 1]", "bookName": "/td[1]/a/text()", "author": "/td[3]/text(),,,#作 者:##", "newestChapter": "/td[2]/text()", "wordCount": "/td[4]/text()", "detailUrl": "http://www.biqu5200.net{{/td[1]/a/@href}},{\"responseCharset\":\"gbk\"}", "tocUrl": "http://www.biqu5200.net{{/td[1]/a/@href}},{\"responseCharset\":\"gbk\"}" }, "ruleDetail": { "cover": "//*[@id=\"fmimg\"]/img/@src", "desc": "//*[@id=\"intro\"]/p/text()", "tags": "//*[@id=\"wrapper\"]/div[5]/div[1]/a[2]/text()" }, "ruleToc": { "tocList": "//*[@id=\"list\"]/dl/dd[position() > 9]", "title": "/a/text()", "url": "http://www.biqu5200.net{{/a/@href}},{\"responseCharset\":\"gbk\"}", "needVip": "0" }, "ruleContent": { "content": "//*[@id=\"content\"]/p/text()" }, "verify": { "searchKey": "圣墟", "detailPrefix": "http://www.biqu5200.net" } } ```
## 动态Dart站点规则 无法使用文本规则适配的站点可以通过动态Dart站点规则进行适配。 动态Dart站点规则,格式为Dart,需实现`BookSourceProvide`接口 `BookSourceProvide`设计如下: ```dart abstract class BookSourceProvide { BookSourceProvide(); /// 站点ID,必须,唯一 String siteId(); /// 当前站点版本 int currentSiteVersion(); /// 站点更新信息。 /// 格式为 版本号:更新信息 Map siteUpdateInfo(); /// 站点ID,必须,基础信息 QNBaseModel info(); /// 探索模型 /// 建议区分 男频 和 女频 QNExploreModel exploreModel(); /// 返回值为 0:不需要进行数据迁移,适用于 逻辑优化、去广告等 /// 返回值为 1:代表 书籍详情页地址发生变更,当用户 在书架打开书籍时,会 将旧书籍删除,使用新的规则 进行 搜索 -> 获取详情 -> 获取目录 -> 获取章节列表,重新生成完整的书籍数据。 /// 返回值为 2:代表 书籍的目录集合页地址发生变更,当用户 在书架打开书籍时,会通过 新的规则 ->获取目录 -> 获取章节列表,重新生成书籍数据。 /// 返回值为 3:代表 书籍的目录章节地址(正文地址)发生变更,当用户打开书籍时,会通过 新的规则 -> 获取章节列表,重新生成书籍数据。 int migrate(int oldVersion); /// 根据书名搜索 书籍 /// 入参为 key Future> queryExplore(String exploreUrl); /// 根据书名搜索 书籍 /// 入参为 key Future> searchBookName(String key); /// 获取书籍详情 /// 入参为 bookId 及 detailUrl Future queryBookDetail(QNBookModel model); /// 获取书籍目录 /// 入参为 bookId 及 tocUrl Future queryBookToc(QNBookModel model); /// 获取书籍内容 /// /// 入参为 bookId 及 contentKey Future queryBookContent(QNBookModel model, String contentKey); /// 包含两个字段 searchKey 及 bookDetailPrefix Map verifyBookConfig(); } ``` 具体实现及调用逻辑可看源码 **范例如下** ```dart import 'dart:async'; import 'dart:convert'; import 'package:qn_read_rule/book_provide/base.dart'; import 'package:qn_read_rule/book_provide/base_model.dart'; /// 书源 白鹤 class BookSource_Baihe extends BookSourceProvide { /// 站点信息 @override String siteId() => "https://apk-lb-play.fodexin.com"; /// 当前书源版本 @override int currentSiteVersion() => 120; @override Map siteUpdateInfo() => { 80: '初始化版本', 100: '修改正文正则', 110: '网站变更,旧数据无法兼容,需进行迁移', currentSiteVersion(): '站点信息无变更,去除正文内容的广告' }; /// 配置信息 @override QNBaseModel info() => QNBaseModel.init( /// 站点ID ,一般为站点网站 siteId: siteId(), /// 类型: book 书籍 listenBook听书 comic漫画 type: 'listenBook', /// 站点显示名称 showName: '🍉白鹤故事(推荐)', /// 站点分类 group: '搜狗', /// 站点描述 desc: '🍉白鹤故事', /// 漫画详情章节展示,仅漫画有效,分为 small 小,middle 中等 big大 comicShowType: '', /// 站点作者 maker: 'laobai', /// 站点更新时间戳 updateTime: DateTime.now().millisecondsSinceEpoch, /// 站点版本 versionNumb: currentSiteVersion(), vpnWebsite: '', /// 是否支持 探索 supportExplore: true, /// 是否支持 书名搜索 supportSearchBookName: true); /// 数据迁移模式 @override int migrate(int oldVersion) { /// 从 110版本,进行了网站地址变更,低于此版本的书籍数据均不可用,需要进行数据迁移 /// /// 数据迁移由 App进行处理,站点规则 需确认是否进行旧数据处理及迁移。 /// /// /// 返回值为 0:不需要进行数据迁移,适用于 逻辑优化、去广告等 /// 返回值为 1:代表 书籍详情页地址发生变更,当用户 在书架打开书籍时,会 将旧书籍删除,使用新的规则 进行 搜索 -> 获取详情 -> 获取目录 -> 获取章节列表,重新生成完整的书籍数据。 /// 返回值为 2:代表 书籍的目录集合页地址发生变更,当用户 在书架打开书籍时,会通过 新的规则 ->获取目录 -> 获取章节列表,重新生成书籍数据。 /// 返回值为 3:代表 书籍的目录章节地址(正文地址)发生变更,当用户打开书籍时,会通过 新的规则 -> 获取章节列表,重新生成书籍数据。 return 0; } /// 探索 模型 ,区分 男女生 @override QNExploreModel exploreModel() { /// 使用文本模型中{{$.page}}的方式进行页码注入。 const tag = "玄幻奇幻::https://apk-lb-json.fodexin.com/json/v1/cat_list/46/index/{{\$.page}}.json\n武侠小说::https://apk-lb-json.fodexin.com/json/v1/cat_list/11/index/{{\$.page}}.json\n言情通俗::https://apk-lb-json.fodexin.com/json/v1/cat_list/19/index/{{\$.page}}.json\n恐怖惊悚::https://apk-lb-json.fodexin.com/json/v1/cat_list/14/index/{{\$.page}}.json\n历史军事::https://apk-lb-json.fodexin.com/json/v1/cat_list/15/index/{{\$.page}}.json\n官场商战::https://apk-lb-json.fodexin.com/json/v1/cat_list/17/index/{{\$.page}}.json\n有声文学::https://apk-lb-json.fodexin.com/json/v1/cat_list/10/index/{{\$.page}}.json\n人物纪实::https://apk-lb-json.fodexin.com/json/v1/cat_list/18/index/{{\$.page}}.json\n刑侦反腐::https://apk-lb-json.fodexin.com/json/v1/cat_list/16/index/{{\$.page}}.json\n百家讲坛::https://apk-lb-json.fodexin.com/json/v1/cat_list/9/index/{{\$.page}}.json\n单田芳::https://apk-lb-json.fodexin.com/json/v1/cat_list/1/index/{{\$.page}}.json\n刘兰芳::https://apk-lb-json.fodexin.com/json/v1/cat_list/2/index/{{\$.page}}.json\n田连元::https://apk-lb-json.fodexin.com/json/v1/cat_list/3/index/{{\$.page}}.json\n袁阔成::https://apk-lb-json.fodexin.com/json/v1/cat_list/4/index/{{\$.page}}.json\n连丽如::https://apk-lb-json.fodexin.com/json/v1/cat_list/5/index/{{\$.page}}.json\n孙一::https://apk-lb-json.fodexin.com/json/v1/cat_list/8/index/{{\$.page}}.json\n王子封臣::https://apk-lb-json.fodexin.com/json/v1/cat_list/30/index/{{\$.page}}.json\n马长辉::https://apk-lb-json.fodexin.com/json/v1/cat_list/25/index/{{\$.page}}.json\n昊儒书场::https://apk-lb-json.fodexin.com/json/v1/cat_list/26/index/{{\$.page}}.json\n王军::https://apk-lb-json.fodexin.com/json/v1/cat_list/27/index/{{\$.page}}.json\n王玥波::https://apk-lb-json.fodexin.com/json/v1/cat_list/28/index/{{\$.page}}.json\n石连军::https://apk-lb-json.fodexin.com/json/v1/cat_list/29/index/{{\$.page}}.json\n粤语评书::https://apk-lb-json.fodexin.com/json/v1/cat_list/12/index/{{\$.page}}.json\n关永超::https://apk-lb-json.fodexin.com/json/v1/cat_list/35/index/{{\$.page}}.json\n张少佐::https://apk-lb-json.fodexin.com/json/v1/cat_list/6/index/{{\$.page}}.json\n田战义::https://apk-lb-json.fodexin.com/json/v1/cat_list/7/index/{{\$.page}}.json\n其他评书::https://apk-lb-json.fodexin.com/json/v1/cat_list/13/index/{{\$.page}}.json\n童话寓言::https://apk-lb-json.fodexin.com/json/v1/cat_list/20/index/{{\$.page}}.json\n教育培训::https://apk-lb-json.fodexin.com/json/v1/cat_list/44/index/{{\$.page}}.json\n亲子教育::https://apk-lb-json.fodexin.com/json/v1/cat_list/43/index/{{\$.page}}.json\n商业财经::https://apk-lb-json.fodexin.com/json/v1/cat_list/42/index/{{\$.page}}.json\n脱口秀::https://apk-lb-json.fodexin.com/json/v1/cat_list/41/index/{{\$.page}}.json\n戏曲::https://apk-lb-json.fodexin.com/json/v1/cat_list/38/index/{{\$.page}}.json\n头条::https://apk-lb-json.fodexin.com/json/v1/cat_list/40/index/{{\$.page}}.json\n综艺娱乐::https://apk-lb-json.fodexin.com/json/v1/cat_list/34/index/{{\$.page}}.json\n健康养生::https://apk-lb-json.fodexin.com/json/v1/cat_list/33/index/{{\$.page}}.json\n二人转::https://apk-lb-json.fodexin.com/json/v1/cat_list/31/index/{{\$.page}}.json\n广播剧::https://apk-lb-json.fodexin.com/json/v1/cat_list/36/index/{{\$.page}}.json\n轻音清心::https://apk-lb-json.fodexin.com/json/v1/cat_list/23/index/{{\$.page}}.json\n英文读物::https://apk-lb-json.fodexin.com/json/v1/cat_list/22/index/{{\$.page}}.json\n相声小品::https://apk-lb-json.fodexin.com/json/v1/cat_list/21/index/{{\$.page}}.json\n时尚生活::https://apk-lb-json.fodexin.com/json/v1/cat_list/45/index/{{\$.page}}.json"; final List boyExploreList = []; final List girlExploreList = []; var split = tag.split('\n'); int length = split.length; for (var i = 0; i < length; i++) { String s = split[i]; var _array = s.split("::"); final String tag = _array[0]; final String url = _array[1]; if (s.isNotEmpty) { if (tag.startsWith('女')) { girlExploreList.add(QNExploreItem(tag: tag, url: url)); } else { boyExploreList.add(QNExploreItem(tag: tag, url: url)); } } } return QNExploreModel.init( boyExploreList: boyExploreList, girlExploreList: girlExploreList); } /// 探索 获取详情 @override Future> queryExplore(String exploreUrl) async { var networkData = await BUtils.qn_Network(exploreUrl, headers: { 'User-Agent': 'laobai_tv/1.1.3(Mozilla/5.0 (Linux; Android 10; Redmi Note 8 Build/PKQ1.190616.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/74.0.3729.186 Mobile Safari/537.36)', 'Referer': "https://apk.jqkan.com/", }); var dataList = BUtils.qn_queryList(withRule: r'$.data.books[*]', node: networkData); List bookList = []; for (var s in dataList) { final data = json.decode(s); final bId = "${data['book_id']}"; final detailUrl = "https://apk-lb-json.fodexin.com/json/v1/cont/$bId.json"; var bookModel = QNBookModel.init( siteId: siteId(), bookName: data['name'], siteVersion: currentSiteVersion(), encode: '', author: data['teller'], tags: ["${data['type']}"], desc: data['synopsis'], cover: 'https://pic.iiszg.com/${data['pic']}', detailUrl: detailUrl, tocUrl: detailUrl); BUtils.qn_Put(bookModel.bookId, 'bId', bId); bookList.add(bookModel); } return bookList; } /// 通过书名搜索书籍 @override Future> searchBookName(String key) async { var requestPath = 'https://apk-lb-play.fodexin.com/api2/web/index.php/?r=api'; // 获取网络数据 var encodeSearchKey = ''; { var ti = DateTime.now().millisecondsSinceEpoch * 1.0 / 1000; var me = ti % 60; final _5 = BUtils.roundToInt(ti - me); var md_5 = '7d0526fa291e8baa4173afcf8e08acea1449682949$_5'; final t = BUtils.qn_UtilsSync('md5', [md_5]); final str = '{"m":"search","t":"$t","aid":"0","pid":"0","key":"$key"}'; List encryptData = BUtils.qn_UtilsSync('rsaEncrypt', [str, jmkey, 'PKCS1']); final String encodeStr = laobaiEncode(encryptData); encodeSearchKey = BUtils.qn_UtilsSync('urlEncode', [encodeStr]); } var formRequestData = 'params=$encodeSearchKey&version=1.1.3'; var result = await BUtils.qn_Network( requestPath, method: 'post', requestData: formRequestData, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'laobai_tv/1.1.3(Mozilla/5.0 (Linux; Android 10; Redmi Note 8 Build/PKQ1.190616.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/74.0.3729.186 Mobile Safari/537.36)', 'Referer': "https://apk.jqkan.com/", }, ); // xPath解析 final list = BUtils.qn_queryList(withRule: r"$..books[*]", node: result); final List bookList = []; for (var s in list) { final data = json.decode(s); final bId = "${data['book_id']}"; final detailUrl = "https://apk-lb-json.fodexin.com/json/v1/cont/$bId.json"; var bookModel = QNBookModel.init( siteId: siteId(), bookName: data['name'], siteVersion: currentSiteVersion(), encode: '', author: data['teller'], tags: ["${data['type']}"], desc: data['synopsis'], cover: 'https://pic.iiszg.com/${data['pic']}', detailUrl: detailUrl, tocUrl: detailUrl); BUtils.qn_Put(bookModel.bookId, 'bId', bId); bookList.add(bookModel); } return bookList; } /// 获取详情页 @override Future queryBookDetail(QNBookModel model) async { return model; } /// 获取章节目录 @override Future queryBookToc(QNBookModel model) async { final tocUrl = model.tocUrl; if (tocUrl.trimLeft().trimRight().isEmpty) { return QNTocModel.empty; } var networkData = await BUtils.qn_Network(tocUrl, headers: { 'User-Agent': 'laobai_tv/1.1.3(Mozilla/5.0 (Linux; Android 10; Redmi Note 8 Build/PKQ1.190616.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/74.0.3729.186 Mobile Safari/537.36)', 'Referer': "https://apk.jqkan.com/", }); final dataList = BUtils.qn_queryList( withRule: "\$.data.play_data[*]", node: networkData); final List innerList = []; // var bId = qnGet(model.bookId, 'bId'); for (var s in dataList) { final data = json.decode(s); var playId = data['play_id']; final url = 'https://apk-lb-play.fodexin.com/api2/web/index.php/?r=api,,,$playId'; innerList.add( QNTocInnerModel.init(title: data['name'], url: url, needVip: false)); } return QNTocModel.init( bookId: model.bookId, updateTime: DateTime.now().millisecondsSinceEpoch, dataList: innerList); } /// 获取正文内容(音乐、漫画) @override Future queryBookContent( QNBookModel model, String contentKey) async { String bId = BUtils.qn_Get(model.bookId, 'bId'); var split = contentKey.split(',,,'); String requestData = _laobai(split[1], bId ); var networkData = await BUtils.qn_Network( split[0], method: 'POST', requestData: requestData, headers: { "Referer": 'https://apk-lb.iiszg.com/', 'User-Agent': 'laobai_tv/1.1.3(Mozilla/5.0 (Linux; Android 10; Redmi Note 8 Build/PKQ1.190616.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/74.0.3729.186 Mobile Safari/537.36)', 'content-type': 'application/x-www-form-urlencoded' }, ); var content = BUtils.qn_querySingle(withRule: "\$.data.url", node: networkData); return QNContentModel.init( contentKey: contentKey, musicContent: MusicContentModel.init(url: content, headers: { 'Referer': 'https://apk-lb.iiszg.com/', 'User-Agent': 'laobai_tv/1.1.3(Mozilla/5.0 (Linux; Android 10; Redmi Note 8 Build/PKQ1.190616.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/74.0.3729.186 Mobile Safari/537.36)' })); } /// -------------------- 以下为 站点校验 ------------------------- /// /// /// 站点校验配置 @override Map verifyBookConfig() => { "searchKey": '元尊', "detailPrefix": 'https://apk-lb-json.fodexin.com/json/v1/cont' }; // ------------- static String laobaiEncode(List bArr) { var bArr2 = BUtils.qn_UtilsSync('utf8_bytes', ["ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"]); final length = bArr.length - (bArr.length % 3); List bArr3 = []; for(int i = 0 ;i < length * 3; i++ ){ bArr3.add(-1234); } var i = 0; var i2 = 0; while (i < length) { var i3 = i + 1; var b2 = bArr[i]; var i4 = i3 + 1; var b3 = bArr[i3]; i = i4 + 1; var b4 = bArr[i4]; var i5 = i2 + 1; bArr3[i2] = bArr2[(b2 & 255) >> 2]; var i6 = i5 + 1; bArr3[i5] = bArr2[((b2 & 3) << 4) | ((b3 & 255) >> 4)]; var i7 = i6 + 1; // LogUtil.debug(() => 'i6 ->$i6 i ->$i'); bArr3[i6] = bArr2[((b3 & 15) << 2) | ((b4 & 255) >> 6)]; i2 = i7 + 1; bArr3[i7] = bArr2[b4 & 63]; // LogUtil.debug(() => 'i7 ->$i7 i ->$i'); } var length2 = bArr.length - length; if (length2 == 1) { var b5 = bArr[i]; var i8 = i2 + 1; bArr3[i2] = bArr2[(b5 & 255) >> 2]; var i9 = i8 + 1; bArr3[i8] = bArr2[(b5 & 3) << 4]; var b6 = 61; bArr3[i9] = b6; bArr3[i9 + 1] = b6; } else if (length2 == 2) { var i10 = i + 1; var b7 = bArr[i]; var b8 = bArr[i10]; var i11 = i2 + 1; bArr3[i2] = bArr2[(b7 & 255) >> 2]; var i12 = i11 + 1; bArr3[i11] = bArr2[((b7 & 3) << 4) | ((b8 & 255) >> 4)]; bArr3[i12] = bArr2[(b8 & 15) << 2]; bArr3[i12 + 1] = 61; } final List newList = []; for (int x in bArr3) { if (x == -1234) { }else { newList.add(x); } } var decodeStr = BUtils.qn_UtilsSync("utf8_decode",[newList]); return decodeStr; } static final jmkey = [ 48, -126, 2, 34, 48, 13, 6, 9, 42, -122, 72, -122, -9, 13, 1, 1, 1, 5, 0, 3, -126, 2, 15, 0, 48, -126, 2, 10, 2, -126, 2, 1, 0, -40, 104, 121, -84, -28, -99, -13, -100, 21, 12, 95, 14, 1, 110, 44, 31, 75, -116, 107, 42, -39, 52, 59, -91, -80, -15, 28, 102, -4, -4, -91, -98, -92, -80, -37, -63, 68, 35, 45, -42, 75, 52, -54, 61, 40, -67, -36, 79, -56, 107, 2, 34, -20, 67, 123, -126, -107, 37, 46, -3, 51, 64, -17, 64, -126, -98, -45, -51, 28, -12, 78, -87, 38, -62, 87, -55, -102, 27, -27, -111, 1, 75, 86, 30, -20, -5, -44, -17, -87, -32, -107, 42, 77, 116, -71, -124, -109, -117, -28, -56, -107, 59, -106, -56, 118, 82, -76, -128, 56, 75, 15, 15, -9, -15, -101, 53, -47, -117, -71, -112, -43, -100, 61, 67, -113, 49, -82, 87, 104, -34, -74, 15, -24, -113, 37, 109, -90, -115, 52, 6, -95, 23, 111, 125, -106, 42, -122, 74, -15, 71, -23, -116, 32, 25, 83, -19, 8, 72, 99, -54, 47, -112, 22, -71, 76, 94, 122, -77, 99, 46, -74, -118, -100, 106, 58, -63, -106, 30, 23, -84, 90, 23, -45, -85, 124, -56, 8, -101, 42, -33, 90, -32, 29, -70, -103, 93, 28, 1, -45, -81, -98, 68, 69, -107, -75, 92, -95, -123, 11, 29, 76, -103, -15, -23, -4, -16, 107, 122, 52, 6, 50, -29, 52, -25, -123, -33, 59, -78, 94, 49, -2, 70, 18, 85, 37, 99, 21, -103, -12, 97, -11, 61, -28, 37, 100, 33, 119, 51, 94, -113, 127, 5, -49, -47, -60, 17, 122, -23, 117, 17, -100, -72, 54, -37, 16, -116, -99, 35, -122, -86, 85, 87, -58, 107, -13, -61, -93, -78, -88, 113, 14, -2, -66, 52, 105, -44, 24, 43, 79, -114, -19, 73, -97, -10, -20, -48, 90, 18, -59, -2, -20, 89, 2, 115, 45, -11, -47, -13, 69, -122, -2, -7, -30, -91, 14, -38, -29, 62, 41, -103, 107, 127, -77, -59, -83, 20, 118, -17, 123, -10, -119, 123, 32, 79, 104, -75, -112, -11, -88, 92, 0, -57, 116, -95, -26, 96, 115, 84, 43, -90, -84, 124, -104, 121, -116, -122, 106, 24, 112, -19, 120, 49, -26, -11, 66, 17, 60, -24, 69, -122, -113, -45, 9, 58, -83, -14, 57, 72, -67, 115, -34, -48, 32, -34, 53, 31, -59, -102, -102, -49, 100, 9, 18, -61, -35, 69, 18, -71, -97, -22, 77, -109, 22, -9, -75, -73, 104, -55, 77, 37, 16, 63, -101, 83, -59, 46, 93, 121, -124, 59, 120, 34, -10, 45, -92, 24, -78, 98, -3, 93, -82, 15, -5, 53, -17, -17, 127, 89, -76, -19, 53, 62, 112, 95, 102, 114, -82, 37, -68, -56, -40, -25, -22, -108, 41, 101, -94, 67, 110, 10, -119, 115, -85, -36, -103, 0, 14, 105, -60, 37, -127, -107, -2, 125, -3, 125, -113, -61, -25, -43, -8, 79, 13, 101, -103, 32, -54, -68, 121, 16, -79, 113, -118, -100, 39, -3, -90, -24, -51, -108, -78, 120, -109, -86, -116, 99, 0, 69, -72, 97, -65, -15, 2, 3, 1, 0, 1 ]; String _laobai(String pid, String bid) { double ti = DateTime.now().millisecondsSinceEpoch * 1.0 / 1000; double me = ti % 60; final int _5 = BUtils.roundToInt(ti - me); var md = BUtils.qn_UtilsSync('md5', ['play$bid$pid']); var md_5 = '${md}1449682949$_5'; final String t = BUtils.qn_UtilsSync('md5', [md_5]); final str = '{"m":"play","t":"$t","aid":"$bid","pid":"$pid"}'; final rsaEncrpyt = BUtils.qn_UtilsSync('rsaEncrypt', [str, jmkey, 'PKCS1']); var encodeStrr = laobaiEncode(rsaEncrpyt); final bm = BUtils.qn_UtilsSync('urlEncode', [encodeStrr]); return 'params=$bm&version=1.1.3'; } } ```