这节我们来看看Spiderman的Parser的设计和实现。
对于爬虫而言,网页内容的多样性直接决定了解析方式的多样性和复杂性,所以在设计上必须要将不变和变进行仔细总结和分离,一方面要达到稳定的内在架构能适应多种不同的解析方式,另一方面还要具备良好的扩展性从而支持为单个网页的针对其不同特性进行解析的借口。这一节首先从总体设计上来描述spiderman的parser模块设计,然后再对具体的解析流程进行分析。
Parser的设计:
在模块设计上Parser的角色和Fetcher一样都是通过扩展点引入到系统中的,即通过实现ParsePoint借口提供一个Parser的中介模块,通过这个中介模块和具体的Parser类交互来实现页面的解析,在源码中提供的中介者Parser类是ParsePointImpl。并且提供了两个执行具体解析内容的类:DefaultModelParser,WebDriverModelParser,这两个类都实现了ModelParser接口,这点很重要,这是实现ParsePointImpl和具体parser类解耦的关键,你或许或问,那么用户如何提供自己实现的parser类,这个可以在:配置文件中找到,即:每个Model节点都有一个parser属性,这个属性其实就是提供用户解析类的class path。
Spiderman支持三种网页类型的解析:json,html和xml。内容提取上,支持三种主流的提取方式:xpath,regex和el,可以说,这三种方法结合几乎可以抽取任意形式(只要是json,html和xml的一种)的信息,这也是Spiderman强大之处的体现之一吧。(哦!对了 ,是没有支持css选择器,不过有这三种也足够了)。
下面来结合配置文件来介绍一下parser的主要思想。
<model>
<!--
| 目标网页的命名空间配置,一般用于xml页面
| prefix: 前缀
| uri: 关联的URI
<namespaces>
<namespace prefix="" uri="" />
</namespaces>
-->
<!--
| 属性的配置
| name:属性名称
| isArray:0|1 是否是多值
| isMergeArray:0|1 是否将多值合并,搭配isArray使用
| isParam:0|1 是否作为参数提供给别的field节点使用,如果是,则生命周期不会保持到最后
| isFinal:0|1 是否是不可变的参数,搭配isParam使用,如果是,第一次赋值之后不会再被改变
| isAlsoParseInNextPage:0|1 是否在分页的下一页里继续解析,用于目标网页有分页的情况
| isTrim:0|1 是否去掉前后空格
-->
<field name="title">
<parsers>
<!--
| xpath: XPath规则,如果目标页面是XML,则可以使用2.0语法,否则HTML的话暂时只能1.0
| attribute:当使用XPath解析后的内容不是文本而是一个Node节点对象的时候,可以给定一个属性名获取其属性值例如<img src="" />
| regex:当使用XPath(包括attribute)规则获取到的文本内容不满足需求时,可以继续设置regex正则表达式进行解析
| exp:当使用XPath获取的文本(如果获取的不是文本则会先执行exp而不是regex否则先执行regex)不满足需求时,可以继续这是exp表达式进行解析
| exp表达式有几个内置对象和方法:
| $output(Node): 这个是内置的output函数,作用是输出某个XML节点的结构内容。参数是一个XML节点对象,可以通过XPath获得
| $this: 当使用XPath获取到的是Node节点时,这个表示节点对象,否则表示Java的字符串对象,可以调用Java字符串API进行处理
| $Tags: 这个是内置的用于过滤标签的工具类
| $Tags.xml($output($this)).rm('p').ok()
| $Tags.xml($this).rm('p').empty().ok()
| $Attrs: 这个是内置的用于过滤属性的工具类
| $Attrs.xml($this).rm('style').ok()
| $Attrs.xml($this).tag('img').rm('src').ok()
|
| $Tags和$Attrs可以一起使用:
| $Tags.xml($this).rm('p').Attrs().rm('style').ok()
| $Attrs.xml($this).rm('style').Tags().rm('p').ok()
| skipErr:0|1 是否忽略错误消息
| skipRgxFail:0|1 是否忽略正则匹配失败,如果是,则会取失败前的值
-->
<parser xpath="//div[@class='QTitle']/h1/text()"/>
</parsers>
</field>
<field name="content">
<parsers>
<parser xpath="//div[@class='Content']//div[@class='detail']" exp="$output($this)" />
<!--attribute 黑名单-->
<parser exp="$Attrs.xml($this).rm('class').rm('style').rm('width').rm('height').rm('usemap').rm('align').rm('border').rm('title').rm('alt').ok()" />
<!--tag 黑名单,去掉内嵌内容-->
<parser exp="$Tags.xml($this).rm('map').rm('iframe').rm('object').empty().ok()" />
<!--tag 白名单,保留的标签,除此之外都要删除(不删除其他标签内嵌内容)-->
<parser exp="$Tags.xml($this).kp('br').kp('h1').kp('h2').kp('h3').kp('h4').kp('h5').kp('h6').kp('table').kp('th').kp('tr').kp('td').kp('img').kp('p').kp('a').kp('ul').kp('ol').kp('li').kp('td').kp('em').kp('i').kp('u').kp('er').kp('b').kp('strong').ok()" />
<!--其他-->
</parsers>
</field>
<field name="author">
<parsers>
<parser xpath="//div[@class='stat']//a[@target='_blank']/text()"/>
</parsers>
</field>
<field name="tags" isArray="1">
<parsers>
<parser xpath="//div[@class='Tags']//a/text()"/>
</parsers>
</field>
<field name="answers" isArray="1">
<parsers>
<parser xpath="//li[@class='Answer']//div[@class='detail']/text()" />
</parsers>
</field>
</model>
一个Model即一个解析单元,即当前Task的URL对应页面上需要抽取的所有内容的一个模型,每个field即一个要抽取的字段,这里的field解析出来的最终结果都是文本,但有些元素或者节点的中目标信息的存在方式比较隐蔽或者复杂,因此就需要综合采用三种方式来逐步解析,就像源码中的解释,一般都是先用xpah获取到当前节点(Node或Element),然后再用正则表达式进一步抽取,最后用EL来计算最终结果,因此可以看到每个field一般都存在多个parser,这些parser组成了一个解析链。上面这个是抽取具体内容的配置。下面再看看从当前网页抽取新的链接和存在分页的情况下的配置。
<sourceRules policy="and">
<rule type="regex" value="http://www\.oschina\.net/question\?catalog=1&show=&p=\d+">
<!--
| 定义如何在来源页面上挖掘新的 URL
| 这个节点跟 <model> 节点是一样的结构,只不过名称不叫model而是叫做digUrls而已
-->
<digUrls>
<field name="page_url" isArray="1">
<parsers>
<parser xpath="//div[@class='QuestionList']//ul[@class='pager']//li[@class='page']//a[@href]" attribute="href" />
<parser exp="'http://www.oschina.net/question'+$this" />
</parsers>
</field>
<field name="target_url" isArray="1">
<parsers>
<parser xpath="//div[@class='QuestionList']//ul//li[@class='question']//div[@class='qbody']/h2[1]//a[@href]" attribute="href" />
</parsers>
</field>
</digUrls>
</rule>
</sourceRules>
<urlRules policy="and">
<rule type="regex" value="http://travel\.163\.com/\d{2}/\d{4}/\d{2}/\w[^_]+\.html">
<!--
| 递归抓取详细页的分页,单篇文章的分页会按顺序抓取保证内容抓取的顺序跟页码一致
-->
<nextPage>
<field name="next_url">
<parsers>
<!--
| 正如field的name=next_url意思一样,这里的规则主要是来解析"当前"页的下一页url是什么,我们都知道分页页面里面肯定都有"下一页"入口的,抓到这个,然后递归即可
-->
<parser xpath="//div[@class='ep-pages']//a[@class='ep-pages-ctrl']" attribute="href" />
</parsers>
</field>
</nextPage>
</rule>
</urlRules> <!--
| 另外还需要在<model>下的<field>多添加一个参数 isAlsoParseInNextPage="1" 告诉爬虫该字段需要在分页里继续解析的,比如下面这个content字段,是需要在“下一页”里继续解析的
-->
<model name="travel-article">
<field name="content" isArray="1" isAlsoParseInNextPage="1">
</model>
digUrls和nextPage也是Model,只不过是两个专用的Model,第一个用来抽取新的url,第二个用来处理分页的情形,从上面配置可以看到,遇到分页的情形,就需要在当前Target节点的urlRules中增加分页的处理模型即:nextPage,这个模型告诉解析器,当前页面有分页,因此解析器就会从field中提取出下一个页面,然后再继续解析next page,当然了,这个时候,还需要在内容解析model中把需要在分页中继续抽取的field加上isAlsoParseInNextPage属性。
新的url的抽取作为单独的扩展点出现,这个在前面已经简单描述过,这节我们主要来看看parser的逻辑。下面来看看中介者类ParsePointImpl的逻辑。
总调用函数
public List<Map<String, Object>> parse(Task task, Target target, Page page, List<Map<String, Object>> models) throws Exception {
ModelParser parser = null;//如果用户提供了自己的parser类,则创建对应实例
if (!CommonUtil.isBlank(target.getModel().getParser())) {
try {
Class<?> parserCls = Thread.currentThread().getContextClassLoader().loadClass(target.getModel().getParser());
parser = (ModelParser)parserCls.newInstance();
parser.init(task, target, listener);
} catch (Throwable e) {
e.printStackTrace();
}
}//没有提供,则创建缺省的解析类(spiderman内置的两个解析器之一)
if (parser == null)
parser = new DefaultModelParser(task, target, listener);
//解析当前页面
List<Map<String, Object>> results = parser.parse(page);
//用来记录分页里已经解析的url
Set<String> visitedUrls = new HashSet<String>();
visitedUrls.add(task.url);
//处理分页
List<Rule> rules = target.getUrlRules().getRule();
for (Rule rule : rules) {
//顺序递归解析下一页的内容
Map<String, Object> finalFields = new HashMap<String, Object>();
parseNextPage(rule, target, task, page, results, visitedUrls, finalFields);
}
return results;
}
分页处理
public void parseNextPage(Rule rule, Target target, Task task, Page page, List<Map<String, Object>> results, Set<String> visitedUrls, Map<String,Object> finalFields) throws Exception{
Model mdl = rule.getNextPage();
if (mdl == null)//如果当前target没有定义分页模型(nextPage,这个在每个target的urlRules中定义
return ;
Target tgt = new Target();
tgt.setName(target.getName());
tgt.setModel(mdl);
//解析Model获得next URL
Collection<String> nextUrls = UrlUtils.digUrls(page, task, rule, tgt, listener, finalFields);
if (nextUrls == null || nextUrls.isEmpty())
return ;
String nextUrl = new ArrayList<String>(nextUrls).get(0);//nextUrls是所有分页的链接,这里只需要下一页的链接
if (nextUrl == null || nextUrl.trim().length() == 0)
return ;//正常情况下,递归会在此结束
if (visitedUrls.contains(nextUrl)){
return ;
}//正常情况下,递归会在此结束
//构造请求继续fetch下一页
FetchRequest req = new FetchRequest();
req.setUrl(nextUrl);
req.setHttpMethod(rule.getHttpMethod());
FetchResult fr = task.site.fetcher.fetch(req);
if (fr == null || fr.getPage() == null)
return ;
//记录已经访问过该url,下次不要重复访问它
visitedUrls.add(nextUrl);
//解析nextPage,抽取下一页中需要抽取的field
List<Field> isAlsoParseInNextPageFields = target.getModel().getIsAlsoParseInNextPageFields();
if (isAlsoParseInNextPageFields == null || isAlsoParseInNextPageFields.isEmpty())
return ;
Task nextTask = new Task(nextUrl, rule.getHttpMethod(), task.url, task.site, 0);
//构造一个model
Model nextModel = new Model();
nextModel.getField().addAll(isAlsoParseInNextPageFields);
tgt.setModel(nextModel);
ModelParser parser = null;
if (!CommonUtil.isBlank(target.getModel().getParser())) {
try {
Class<?> parserCls = Thread.currentThread().getContextClassLoader().loadClass(target.getModel().getParser());
parser = (ModelParser)parserCls.newInstance();
parser.init(nextTask, tgt, listener);
} catch (Throwable e) {
e.printStackTrace();
}
}
if (parser == null)
parser = new DefaultModelParser(nextTask, tgt, listener);
Page nextPageResult = fr.getPage();
List<Map<String, Object>> nextMaps = parser.parse(nextPageResult);
if (nextMaps == null)
return ;
//合并下一页中抽取的结果到当前页的解析结果
for (Map<String, Object> nextMap : nextMaps){
for (Iterator<Entry<String, Object>> it = nextMap.entrySet().iterator(); it.hasNext();){
Entry<String, Object> e = it.next();
String key = e.getKey();
Object value = e.getValue();
for (Map<String, Object> result : results){
if (nextModel.isArrayField(key)){
List<Object> list = (List<Object>) result.get(key);
list.addAll((List<Object>)value);
}else{
StringBuilder sb = new StringBuilder();
sb.append(result.get(key)).append("_##_").append(value);
result.put(key, sb.toString());
}
}
}
}
parseNextPage(rule, target, nextTask, nextPageResult, results, visitedUrls, finalFields);
}
具体解析
下面我们来具体分析一下两个内置的解析类的解析过程,先来看看DefaultModelParser的解析。
DefaultModelParser在解析之前有两个准备阶段:第一阶段:初始化FEL对象;第二步:确定当前页面类型。第三步:根据具体的页面类型分别调用json/xml/html的解析流程,还有一点,如果用户配置了用XML来解析的话,那么即便当前 页面类型是html,也会用XML来解析,这样的好处是可以用XPATH2.0,否则只能用XPATH1.0来解析。
public List<Map<String, Object>> parse(Page page) throws Exception {
String contentType = this.target.getModel().getCType();
if (contentType == null || contentType.trim().length() == 0)
contentType = page.getContentType();
if (contentType == null)
contentType = "text/html";
boolean isXml = "xml".equalsIgnoreCase(contentType) || contentType.contains("text/xml") || contentType.contains("application/rss+xml") || contentType.contains("application/xml");
boolean isJson = "json".equalsIgnoreCase(contentType) || contentType.contains("text/json") || contentType.contains("application/json");
if (isXml)
return parseXml(page, false);
if (isJson)
return parseJson(page);
String isForceUseXmlParser = this.target.getModel().getIsForceUseXmlParser();
if (!"1".equals(isForceUseXmlParser))
return parseHtml(page);
HtmlCleaner cleaner = new HtmlCleaner();
cleaner.getProperties().setTreatDeprecatedTagsAsContent(true);
String isIgCom = this.target.getModel().getIsIgnoreComments();
if ("1".equals(isIgCom) || "true".equals(isIgCom))
//忽略注释
cleaner.getProperties().setOmitComments(true);
TagNode rootNode = cleaner.clean(page.getContent());
String xml = ParserUtil.xml(rootNode, true);
page.setContent(xml);
return parseXml(page, true);
}
初始化FEL对象就是为配置为配置文件中的parser节点提供的exp类型做支撑。因为不同类型的资源需要使用不同类型的解析库,对于xml/html来讲,主要区别是用xpath抽取node时的方式。从源码可以看出对于json的解析采用了fastjson,对xml采用了saxon,对Html用了htmlcleaner。
public void init(Task task, Target target, SpiderListener listener){
this.task = task;
this.target = target;
this.listener = listener;
fel.addFun(fun);
Tags $Tags = Tags.me();
Attrs $Attrs = Attrs.me();
fel.getContext().set("$Tags", $Tags);
fel.getContext().set("$Attrs", $Attrs);
fel.getContext().set("$Util", CommonUtil.class);
fel.getContext().set("$ParserUtil", ParserUtil.class);
fel.getContext().set("$target", this.target);
fel.getContext().set("$listener", this.listener);
fel.getContext().set("$task_url", this.task.url);
fel.getContext().set("$source_url", this.task.sourceUrl);
}
无论是JSON/HTML还是XML的解析,如果当前model是List,即页面上抽取到的model是节点集合,则逐一解析每个model,最后解析结果以List<Map<String, Object>>的形式返回,一个Model对应一个map。
XML和JSON的解析和HTML的解析过程类似,下面以html为例来分析
private List<Map<String, Object>> parseHtml(Page page) throws Exception{
HtmlCleaner cleaner = new HtmlCleaner();
// cleaner.getProperties().setTreatUnknownTagsAsContent(true);
String isIgCom = this.target.getModel().getIsIgnoreComments();
if ("1".equals(isIgCom) || "true".equals(isIgCom))
//忽略注释
cleaner.getProperties().setOmitComments(true);
cleaner.getProperties().setTreatDeprecatedTagsAsContent(true);
String html = page.getContent();
fel.getContext().set("$page_content", html);
TagNode rootNode = cleaner.clean(html);
final List<Field> fields = target.getModel().getField();
String isModelArray = target.getModel().getIsArray();
String modelXpath = target.getModel().getXpath();
List<Map<String, Object>> list = new ArrayList<Map<String, Object>>();
if ("1".equals(isModelArray) || "tre".equals(isModelArray)){//model在页面上以列表形式出现(多值)
Object[] nodeVals = rootNode.evaluateXPath(modelXpath);//此时model几点的xpath属性用来抽取model的list
if (nodeVals != null && nodeVals.length > 0){
for (int i = 0; i < nodeVals.length; i++) {
list.add(parseHtmlMap(nodeVals[i], fields));
}
}
}else{//model是单值,此时,整个页面的根节点可以看做model的根节点
list.add(parseHtmlMap(rootNode, fields));
}
return list;
}
parseHtmlMap的作用就是在给定的几点中抽取出fields中的每个field的值(或者值集合)。
private Map<String, Object> parseHtmlMap(Object item, final List<Field> fields){
Map<String, Object> map = new HashMap<String, Object>();
if (finalFields != null)
map.putAll(finalFields);
fel.getContext().set("$fields", map);
//依次解析每个field
for (Field field : fields){
String key = field.getName();//field name即为Map中的Key
String isArray = field.getIsArray();//当前field是否为多值,即list形式存在
String isMergeArray = field.getIsMergeArray();//为多值情况下是否对结果合并
String isTrim = field.getIsTrim();
String isParam = field.getIsParam();
String isFinal = field.getIsFinal();
String isForDigNewUrl = field.getIsForDigNewUrl();//当前field是否用来挖掘新的url
boolean isFinalParam = ("1".equals(isParam) || "true".equals(isParam)) && ("1".equals(isFinal) || "true".equals(isFinal));
if (isFinalParam && finalFields != null && finalFields.containsKey(key))
continue;
//获取当前field的解析器集合(xpath/exp/regex)
Parsers parsers = field.getParsers();
if (parsers == null)
continue;
List<org.eweb4j.spiderman.xml.Parser> parserList = parsers.getParser();
if (parserList == null || parserList.isEmpty())
continue;
//field最终解析出来的结果
List<Object> values = new ArrayList<Object>();
for (int i = 0; i < parserList.size(); i++) {
org.eweb4j.spiderman.xml.Parser parser = parserList.get(i);
String skipErr = parser.getSkipErr();
String xpath = parser.getXpath();
String attribute = parser.getAttribute();//对于xpath形式的解析器,有时候会提供attribute属性
String exp = parser.getExp();
String regex = parser.getRegex();
String skipRgxFail = parser.getSkipRgxFail();//正则表达式解析出错时是否忽略
try {//一般第一个解析器都是xpath,首先提取出field所在node甚至直接提取出文本值
if (xpath != null && xpath.trim().length() > 0) {
TagNode tag = (TagNode)item;
Object[] nodeVals = tag.evaluateXPath(xpath);
if (nodeVals == null || nodeVals.length == 0)
continue;
//attribute不为空,说明需要进一步提取出attribute值
if (attribute != null && attribute.trim().length() > 0){
for (Object nodeVal : nodeVals){
TagNode node = (TagNode)nodeVal;
String attrVal = node.getAttributeByName(attribute);
values.add(attrVal);
}
//如果当前parser还提供了正则表达式,则进一步用正则表达式解析
parseByRegex(regex, skipRgxFail, values); //进一步用EXP提取
parseByExp(exp, values);
}else if (xpath.endsWith("/text()")){//带text()时提取出来的结果就是文本
for (Object nodeVal : nodeVals){
values.add(nodeVal.toString());
}
//正则
parseByRegex(regex, skipRgxFail, values);
// EXP表达式
parseByExp(exp, values);
}else {//此种情形,提取出来的一般都不是文本
for (Object nodeVal : nodeVals){
TagNode node = (TagNode)nodeVal;
values.add(node);
}
// 此种方式获取到的Node节点大部分都不是字符串,因此先执行表达式后执行正则
// EXP表达式
parseByExp(exp, values);
//因为正则是对字符串操作的,因此需要先执行表达式从节点中抽取出文本
//正则
parseByRegex(regex, skipRgxFail, values);
}
}else {//当前parser不是xpath,一般不是第一个解析器,此时已经有了上一步解析出来的结果
//第一步获得的是一个List<String>对象,交给下面的步骤进行解析
List<Object> newValues = new ArrayList<Object>();
for (Object nodeVal : values){
newValues.add(nodeVal.toString());
}
//正则
parseByRegex(regex, skipRgxFail, newValues);
// EXP表达式
parseByExp(exp, newValues);
if (!newValues.isEmpty()) {
values.clear();
values.addAll(newValues);
}
}
} catch (Throwable e) {
if ("1".equals(skipErr) || "true".equals(skipErr))
continue;
String parserInfo = CommonUtil.toJson(parser);
String err = "parser->" + parserInfo + " of field->" + key +" failed";
listener.onError(Thread.currentThread(), task, err, e);
}
}
//对当前field提取出来的结果集进行后处理
try {
if (values.isEmpty())
values.add("");
// 相同 key,若values不为空,继续沿用,如果想将两个field的结果合并,则可以起相同的name
if (map.containsKey(key)){
//将原来的值插入到前面
Object obj = map.get(key);
if (obj instanceof Collection) {
values.addAll(0, (Collection<?>) obj);
} else {
values.add(0, obj);
}
}
//数组的话,需要去除空元素
if (values.size() >= 2){
List<Object> noRepeatValues = new ArrayList<Object>();
for (Iterator<Object> it = values.iterator(); it.hasNext(); ){
Object obj = it.next();
if (obj instanceof String) {
if (((String)obj) == null || ((String)obj).trim().length() == 0)
continue;
}
noRepeatValues.add(obj);
}
values.clear();
values.addAll(noRepeatValues);
}
//如果设置了trim
if ("1".equals(isTrim) || "true".equals(isTrim)) {
List<String> results = new ArrayList<String>(values.size());
for (Object obj : values){
results.add(String.valueOf(obj).trim());
}
values.clear();
values.addAll(results);
}
//如果是DigNewUrl
if ("1".equals(isForDigNewUrl) || "true".equals(isForDigNewUrl)) {
if ("1".equals(isArray)){
for (Object val : values){
task.digNewUrls.add(String.valueOf(val));
}
}else{
if (!values.isEmpty())
task.digNewUrls.add(String.valueOf(values.get(0)));
}
}
//多值的field
Object value = null;
if ("1".equals(isArray) || "true".equals(isArray)){
List<Object> newValues = new ArrayList<Object>();
for (Object val : values){
if (values.size() == 1 && val.getClass().isArray()){
Object[] newVals = (Object[])val;
for (Object nv : newVals){
if (nv == null || String.valueOf(nv).trim().length() == 0)
continue;
newValues.add(nv);
}
}
}
if (!newValues.isEmpty()){
values.clear();
values.addAll(newValues);
}
value = values;
if ("1".equals(isMergeArray) || "true".equals(isMergeArray)){
StringBuilder sb = new StringBuilder();
for (Object val : values){
sb.append(String.valueOf(val));
}
value = sb.toString();
}else
value = values;
}else{
if (values.isEmpty())
value = "";
else
value = values.get(0);
}
if(isFinalParam){
finalFields.put(key, value);
}
//最终完成
map.put(key, value);
} catch (Throwable e) {
listener.onError(Thread.currentThread(), task, "field->"+key+" parse failed cause->"+e.toString(), e);
}
//返回结果
return map;
}
在WebDriverModelParser中对资源分成两种解析,JSON和其他(HTML/XML),即再对xml/html进行区分,JSON依然使用的fastjson,对HTML/XML的解析用的是selenium来实现,解析过程和DefaultModelParser基本类似,不再累述。
通过上面的分析,可以看出,使用spiderman来爬取,需要用户对所要抽取的信息在目标网页中的结构非常清晰,定义出准确的XPATH,如果XPATH不能一步抽取出结果,还要进一步定义出用来抽取的正则表达式和FEL表达式,为此,spiderman还内置了两个强大的标签操作类Tags和Attrs,还有一个自定义函数用于将目标节点转化成字符串,不过这些需要用户提前了解用法。另外,对于ajax页面的爬取,看到spiderman的fetcher模块中内置了htmlunit,htmlunit由于是对浏览器的模拟,本身是支持ajax页面的爬取的,但在spiderman中没有看到和ajax爬取相关的设置,应该在爬取ajax页面时会存在问题。
本节的分析就到此。