在开始前,先讲个故事:
在很久很久以前,一位懵懂的少年用着一款uwp的手机,他很喜欢看漫画,但是他望着这款装着软件都没几个的系统的手机,他很茫然。为此,他联系了很多开发者,希望有人能开发一个看漫画app,结果too young too simple。但是这位少年并没有因此而放弃,他产生了一个“大神由此而诞生”的想法,没错自己写一个。就这样,多多猫app的“前身”就这样诞生了。但是“前身”还只是对一个网站(KuKu漫画)做简单的抓取,这显然是不够的,需要抓取更多的网站,随着网站的增多,app内部也需要一个完善的体系来适配这些“抓取的方法”,这就是历经岁月的摧残而诞生的siteD引擎github。
故事讲完了,接下来我们就来大致了解下,这个siteD引擎到底是怎么运行插件的?
插件导入引擎后,siteD引擎会首先读取插件的xml节点,将它转成引擎内部的SdSource类,之后所有的运行都基于这个类。
<site ver="1" engine="30" schema="1">
<meta></meta>
<main></main>
<script></script>
</site>
对于最外层的site节点:因为他是第一个,引擎并没有通过Name来识别它,可以凭自己的喜好随意更改sitedd,loveyou等等。但是它附带的参数很重要,ver插件版本,engine引擎版本,schema则是引擎的更新中修改了节点name,当你把engine设置成25+时,必须添加schema="1",不然引擎会以旧版的NodeName取抓取子节点。
site中一共包含了三个子节点(meta、main、script):
1.meta
<meta>
<ua></ua>
<guid>xxxxxxxxxxxxxxxxxxx</guid>
<title>733漫画</title>
<intro>733动漫网_好看的动漫_日本动漫_动漫大全_最新动漫</intro>
<author>Seiko</author>
<url>http://www.733dm.net</url>
<expr>733dm\.net</expr>
<logo></logo>
<encode>gb18030</encode>
</meta>
meta里面的数据还是比较明了的,顺便说下这里的url主要起展示作用,这里就简述下guid、expr:
guid——将插件上传到服务器时的唯一凭证,因此写好插件后,需要添加guid才能上传到服务器。
expr——siteD引擎用来判断用哪个插件来解析当前的url。就像你的收藏里有来自57的、汗汗的,总不能拿汗汗的插件来解析57的url。
3.script(main比较复杂,先讲script)
<script>
<require>
<item url="http://sited.noear.org/addin/js/cheerio.js" lib="cheerio" />
<item url="http://sited.noear.org/addin/js/base64.js" />
</require>
<code>
<![CDATA[
var urla = (function() {
var host = "http://www.733dm.net";
return function(u) {
if (u.indexOf("http") < 0) {
u = host + u;
}
return encodeURI(u);
}
})();
function tg_burl(url, page) {
if (page > 1) {
url += "index_" + page + ".html";
}
return url;
}
function ht_parse(url, html) {
var $ = cheerio.load(html);
var list = [];
$('ul.scroll').find('img').each(function() {
var img = $(this);
var bm = {};
bm.name = img.attr('alt');
bm.url = urla(img.parent().attr('href'));
bm.logo = img.attr('src');
list.push(bm);
});
return JSON.stringify(list);
}
function up_parse(url, html) {
var $ = cheerio.load(html);
var list = [];
return JSON.stringify(list);
}
function tg_parse(url, html) {
var $ = cheerio.load(html);
var list = [];
return JSON.stringify(list);
}
function bk_parse(url, html) {
var $ = cheerio.load(html);
var data = {};
return JSON.stringify(data);
}
function sn_parse(url, html) {
return JSON.stringify(list);
}
]]>
</code>
</script>
很明显,这个节点就是放js代码的,code放自己写的代码,<require>放一些引用的库,如图上的cheerio和base64。至于lib的作用,siteD引擎里面已经包含了一些js库,lib就是用来识别的,不写也没事,因为第一次加载后,引擎就会缓存。
2.main
<main dtype="1">
<home>
<hots cache="1d" title="热门" method="get" parse="ht_parse" url="http://www.733dm.net" />
<updates cache="1d" title="更新" method="get" parse="up_parse" url="http://www.733dm.net/mh/update.html" />
<tags title="分类">
<item title="国产" url="http://www.733dm.net/mh/guochan/" group="地区" /><item title="日本" url="http://www.733dm.net/mh/riben/" />
<item title="欧美" url="http://www.733dm.net/mh/oumei/" />
<item title="韩国" url="http://www.733dm.net/mh/hanguo/" />
<item title="冒险" url="http://www.733dm.net/mh/maoxian/" group="分类" />
<item title="魔法" url="http://www.733dm.net/mh/mofa/" />
<item title="东方神鬼" url="http://www.733dm.net/mh/dongfangshengui/" />
</tags>
</home>
<search cache="1d" method="get" parse="tg_parse" url="http://www.733dm.net/e/search/index.php?searchget=1&keyboard=@key&myorder=1&orderby=1&show=title,player,playadmin,bieming,pinyin&tbname=mh&tempid=3" />
<tag cache="0" method="get" parse="tg_parse" buildUrl="tg_burl" />
<book cache="1d" method="get" parse="bk_parse" />
<section cache="1" method="get" parse="sn_parse" header="cookie;referer" />
main里面的结构就比较多了,因为插件的全部内容都在这。main的参数还有很多,具体的可以看开发文档,我这边就讲些我觉得比较重要的。开发文档
dtype:
插件类型,多多猫也是通过这个参数来判断给你漫画界面还是小说界面还是视频界面。注意:不同的插件类型你需要在js里返回的list也是不一样的,具体看开发文档。
home:你点开插件时跳转的界面(插件首页),用过多多猫的应该都知道,点开插件最多只有3种界面(热门hots、更新updates、分类tags)。
search:搜索界面
tag:分类界面
book:目录界面(当没有目录时(例:dtype=4),这个就变成了浏览界面)
section:浏览界面(看漫画or小说or视频)
以上5个界面就是多多猫的主要界面,对比app来看,大致的结构还是比较明了的。接下来统一讲下里面的参数。
cache:缓存。0不缓存、1永久缓存、1d缓存一天、60m缓存1小时。
title、url:标题、链接。毕竟有些链接是需要你自己指明的,像hots、updates、search。从这些界面中衍生的则不需要这些参数,像tag、book、section。
method:请求类型。无非get、post,为了应付某些情况引擎加了一个@null(不请求)。
parse:指明你用的哪个方法来解析该节点。比如js代码中function bk_parse(url, html) {}是用来解析目录的,就在book节点里写parse="bk_parse"。
parseUrl:这是一个非常重要的方法,返回一组urls或一个url,对这组url都进行parse。后面对这个做了加强,可以返回一个CALL::GET::+url。这样引擎会在请求后返回parseUrl,不停循环直到抓到你想要的url或urls。
剩下的buildUrl、header等就不细说了。这边就说下siteD的大致请求流程:(以section为例)
从xx传来url->
expr@选择用哪个插件->
开始请求html(每次请求前都会buildUrl,重新生成header、cookie)->
parseUrl@是否需要进入parseUrl(每次parseUrl都会请求html)->
parse@解析当前页面,返回最终结果
插件的简介说的差不多了,下面说下引擎是怎么处理上面的数据的。
里面的xml就是插件。
doInit() # 解析xml
doLoad() # 将解析后的数据转换成能用的类,怎么转换的就不细说了,去看siteD开源代码吧。(SdNodeSet、SdNode、SdJscript)
1.SdNodeSet是下面的爸爸,例如上面的mian、script、meta。
2.SdNode每个节点,也是解析时需要用的的对象,例如book、section。
3.Sdscript这个是处理js代码的,需要注意的是这个类只起到处理作用,最后的代码都会导入到SdEngine(伟大的J2V8引擎在这个类里)。
插件转换成SdSource后,引用下面的方法,就能开始解析了。(由于siteD代码这块非常复杂,不适合讲解,这边就贴出我的只含有部分功能的简版了,挑战自己的可以去github看源码)
以首页热门为例,上面说到了,
<hots cache="1d" title="热门" method="get" parse="ht_parse" url="http://www.733dm.net" />
被转成了SdNode类,加上url两个参数,对应解析方法为:
public Flowable<YhPair> doGetNodeViewModel(final SdNode cfg, final String url) {
return Observable.create(new ObservableOnSubscribe<String>() {
@Override
public void subscribe(@NonNull ObservableEmitter<String> e) throws Exception {
String html = getHtml(cfg, url); //这里url已经是处理好的,直接请求html(每次请求都会重新判断类型,添加header、cookie)。
if (!TextUtils.isEmpty(cfg.parseUrl)) { //是否进入parseUrl循环
String parseUrl = rxParseUrl(cfg, url, html).blockingFirst(); //导入v8引擎跑出结果。
while (parseUrl.startsWith(Util.NEXT_CALL)) { //是否是以"CALL::"开头的,进入循环(尚未测试,可能无效)
parseUrl = parseUrl.replace(Util.NEXT_CALL, "");
log("doGetNodeViewModel-isNextUrl", parseUrl);
String html2 = getHtml(cfg, parseUrl); //请求html
parseUrl = rxParseUrl(cfg, url, html2).blockingFirst(); //导入v8引擎跑出结果。
}
String[] urls = parseUrl.split(";"); //将urls转换成数组,并对每个url请求处理
for (String u1 : urls) {
String html3 = getHtml(cfg, u1);
log("doGetNodeViewModel-isParseUrl", html3);
e.onNext(html3);
}
} else {
e.onNext(html); //不需要做啥,直接发送html,直接进行parse处理
}
e.onComplete();
}
})
.flatMap(new Function<String, ObservableSource<YhPair>>() {
@Override
public ObservableSource<YhPair> apply(@NonNull String html) throws Exception {
String json = rxParse(cfg, url, html); //进行parse解析
log("rxPrase-json", json);
return Observable.just(new YhPair(cfg, json)); //将json结果和cfg打包
}
}).subscribeOn(Schedulers.io()).toFlowable(BackpressureStrategy.BUFFER);
}
引用上面的方法,接着进行处理:
SourceApi.getInstance().rxgetSource(source) //获得插件对应的SdSource
// .delay(200, TimeUnit.MILLISECONDS)
.flatMap(new Function<YhSource, Publisher<YhPair>>() {
@Override
public Publisher<YhPair> apply(@NonNull YhSource sd) throws Exception {
return sd.doGetNodeViewModel(sd.Hots(), sd.Hots().url); //由于代码没有全贴,这里简写了。
}
})
.flatMap(new Function<YhPair, Publisher<List<HotsBean>>>() {
@Override
public Publisher<List<HotsBean>> apply(@NonNull YhPair pair) throws Exception {
List<HotsBean> list = new ArrayList<>();
JsonArray array = new JsonParser().parse(pair.getJson()).getAsJsonArray(); //处理v8返回的json数据,这里用的gson工具。
for (JsonElement el : array) {
JsonObject n = el.getAsJsonObject();
String name = getString(n, "name");
String logo = getString(n, "logo");
String url = getString(n, "url");
HotsBean bean = new HotsBean();
bean.setName(name);
bean.setLogo(logo);
bean.setUrl(url);
bean.setSource(source);
list.add(bean);
}
return Flowable.just(list);
}
})
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new Consumer<List<HotsBean>>() {
@Override
public void accept(@NonNull List<HotsBean> list) throws Exception {
if (list.size() > 0) {
mView.onSuccess(list); //全部解析好了,发送给界面。
} else {
mView.onFailed();
}
}
}, new Consumer<Throwable>() {
@Override
public void accept(@NonNull Throwable throwable) throws Exception {
mView.onFailed();
}
});