爬虫框架WebMagic源码分析之Downloader
发布日期:2021-08-27 07:03:27 浏览次数:1 分类:技术文章

本文共 14445 字,大约阅读时间需要 48 分钟。

Downloader是负责请求url获取返回值(html、json、jsonp等)的一个组件。当然会同时处理POST重定向、Https验证、ip代理、判断失败重试等。

接口:Downloader 定义了download方法返回Page,定义了setThread方法来请求的设置线程数。

抽象类:AbstractDownloader。 定义了重载的download方法返回Html,同时定义了onSuccess、onError状态方法,并定义了addToCycleRetry来判断是否需要进行重试。
实现类:HttpClientDownloader。负责通过HttpClient下载页面
辅助类:HttpClientGenerator。负责生成HttpClient实例。

1、AbstractDownloader

public Html download(String url, String charset) {        Page page = download(new Request(url), Site.me().setCharset(charset).toTask());        return (Html) page.getHtml();    }

这里download逻辑很简单,就是调用子类实现的download下载。

protected Page addToCycleRetry(Request request, Site site) {        Page page = new Page();        Object cycleTriedTimesObject = request.getExtra(Request.CYCLE_TRIED_TIMES);        if (cycleTriedTimesObject == null) {            page.addTargetRequest(request.setPriority(0).putExtra(Request.CYCLE_TRIED_TIMES, 1));        } else {            int cycleTriedTimes = (Integer) cycleTriedTimesObject;            cycleTriedTimes++;            if (cycleTriedTimes >= site.getCycleRetryTimes()) {                return null;            }            page.addTargetRequest(request.setPriority(0).putExtra(Request.CYCLE_TRIED_TIMES, cycleTriedTimes));        }        page.setNeedCycleRetry(true);        return page;    }

判断重试逻辑:先判断CYCLE_TRIED_TIMES是否为null,如果不为null,循环重试次数+1,判断是否超过最大允许值(默认为3次),然后设置needCycleRetry标志说明需要被重试。这在我们Spider分析篇提到过这个,我们再来看看Spider中的代码片段加深理解

// for cycle retry        if (page.isNeedCycleRetry()) {            extractAndAddRequests(page, true);            sleep(site.getRetrySleepTime());            return;        }

2、HttpClientDownloader

继承了AbstractDownloader.负责通过HttpClient下载页面.
实例变量
httpClients:是一个Map型的变量,用来保存根据站点域名生成的HttpClient实例,以便重用。

httpClientGenerator:HttpClientGenerator实例,用来生成HttpClient

主要方法:

a、获取HttpClient实例。

private CloseableHttpClient getHttpClient(Site site, Proxy proxy) {        if (site == null) {            return httpClientGenerator.getClient(null, proxy);        }        String domain = site.getDomain();        CloseableHttpClient httpClient = httpClients.get(domain);        if (httpClient == null) {            synchronized (this) {                httpClient = httpClients.get(domain);                if (httpClient == null) {                    httpClient = httpClientGenerator.getClient(site, proxy);                    httpClients.put(domain, httpClient);                }            }        }        return httpClient;    }

主要思路是,通过Site获取域名,然后通过域名判断是否在httpClients这个map中已存在HttpClient实例,如果存在则重用,否则通过httpClientGenerator创建一个新的实例,然后加入到httpClients这个map中,并返回。

注意为了确保线程安全性,这里用到了线程安全的双重判断机制。

b、download方法:

public Page download(Request request, Task task) {    Site site = null;    if (task != null) {        site = task.getSite();    }    Set
acceptStatCode; String charset = null; Map
headers = null; if (site != null) { acceptStatCode = site.getAcceptStatCode(); charset = site.getCharset(); headers = site.getHeaders(); } else { acceptStatCode = WMCollections.newHashSet(200); } logger.info("downloading page {}", request.getUrl()); CloseableHttpResponse httpResponse = null; int statusCode=0; try { HttpHost proxyHost = null; Proxy proxy = null; //TODO if (site.getHttpProxyPool() != null && site.getHttpProxyPool().isEnable()) { proxy = site.getHttpProxyFromPool(); proxyHost = proxy.getHttpHost(); } else if(site.getHttpProxy()!= null){ proxyHost = site.getHttpProxy(); } HttpUriRequest httpUriRequest = getHttpUriRequest(request, site, headers, proxyHost); httpResponse = getHttpClient(site, proxy).execute(httpUriRequest); statusCode = httpResponse.getStatusLine().getStatusCode(); request.putExtra(Request.STATUS_CODE, statusCode); if (statusAccept(acceptStatCode, statusCode)) { Page page = handleResponse(request, charset, httpResponse, task); onSuccess(request); return page; } else { logger.warn("get page {} error, status code {} ",request.getUrl(),statusCode); return null; } } catch (IOException e) { logger.warn("download page {} error", request.getUrl(), e); if (site.getCycleRetryTimes() > 0) { return addToCycleRetry(request, site); } onError(request); return null; } finally { request.putExtra(Request.STATUS_CODE, statusCode); if (site.getHttpProxyPool()!=null && site.getHttpProxyPool().isEnable()) { site.returnHttpProxyToPool((HttpHost) request.getExtra(Request.PROXY), (Integer) request .getExtra(Request.STATUS_CODE)); } try { if (httpResponse != null) { //ensure the connection is released back to pool EntityUtils.consume(httpResponse.getEntity()); } } catch (IOException e) { logger.warn("close response fail", e); } }}

注意,这里的Task入参,其实就是Spider实例。

首先通过site来设置字符集、请求头、以及允许接收的响应状态码。
之后便是设置代理:首先判断site是否有设置代理池,以及代理池是否可用。可用,则随机从池中获取一个代理主机,否则判断site是否设置过直接代理主机。
然后获取HttpUriRequest(它是HttpGet、HttpPost的接口),执行请求、判断响应码,并将响应转换成Page对象返回。期间还调用了状态方法onSuccess,onError,但是这两个方法都是空实现。(主要原因可能是在Spider中已经通过调用Listener来处理状态了)。
如果发生异常,调用addToCycleRetry判断是否需要进行重试。
如果这里返回的Page为null,在Spider中就不会调用PageProcessor,所以我们在PageProcessor中不用担心Page是否为null
最后的finally块中进行资源回收处理,回收代理入池,回收HttpClient的connection等(EntityUtils.consume(httpResponse.getEntity());)。

c、具体说说怎么获取HttpUriRequest

protected HttpUriRequest getHttpUriRequest(Request request, Site site, Map
headers,HttpHost proxy) { RequestBuilder requestBuilder = selectRequestMethod(request).setUri(request.getUrl()); if (headers != null) { for (Map.Entry
headerEntry : headers.entrySet()) { requestBuilder.addHeader(headerEntry.getKey(), headerEntry.getValue()); } } RequestConfig.Builder requestConfigBuilder = RequestConfig.custom() .setConnectionRequestTimeout(site.getTimeOut()) .setSocketTimeout(site.getTimeOut()) .setConnectTimeout(site.getTimeOut()) .setCookieSpec(CookieSpecs.BEST_MATCH); if (proxy !=null) { requestConfigBuilder.setProxy(proxy); request.putExtra(Request.PROXY, proxy); } requestBuilder.setConfig(requestConfigBuilder.build()); return requestBuilder.build(); }

首先调用selectRequestMethod来获取合适的RequestBuilder,比如是GET还是POST,同时设置请求参数。之后便是调用HttpClient的相关API设置请求头、超时时间、代理等。

关于selectRequestMethod的改动:预计在WebMagic0.6.2(目前还未发布)之后由于作者合并并修改了PR,设置POST请求参数会大大简化。

之前POST请求设置参数需要
request.putExtra("nameValuePair",NameValuePair[]);然后这个NameValuePair[]需要不断add BasicNameValuePair,而且还需要UrlEncodedFormEntity,设置参数过程比较繁琐,整个过程如下:

List
formparams = new ArrayList
();formparams.add(new BasicNameValuePair("channelCode", "0008")); formparams.add(new BasicNameValuePair("pageIndex", i+""));formparams.add(new BasicNameValuePair("pageSize", "15"));formparams.add(new BasicNameValuePair("sitewebName", "广东省"));request.putExtra("nameValuePair",formparams.toArray());

之后我们只需要如下就可以了:

request.putParam("sitewebName", "广东省");request.putParam("xxx", "xxx");

d、说说下载的内容如何转换为Page对象:

protected Page handleResponse(Request request, String charset, HttpResponse httpResponse, Task task) throws IOException {        String content = getContent(charset, httpResponse);        Page page = new Page();        page.setRawText(content);        page.setUrl(new PlainText(request.getUrl()));        page.setRequest(request);        page.setStatusCode(httpResponse.getStatusLine().getStatusCode());        return page;    }

这个方法没什么好说的,唯一要说的就是它调用getContent方法。

protected String getContent(String charset, HttpResponse httpResponse) throws IOException {    if (charset == null) {        byte[] contentBytes = IOUtils.toByteArray(httpResponse.getEntity().getContent());        String htmlCharset = getHtmlCharset(httpResponse, contentBytes);        if (htmlCharset != null) {            return new String(contentBytes, htmlCharset);        } else {            logger.warn("Charset autodetect failed, use {} as charset. Please specify charset in Site.setCharset()", Charset.defaultCharset());            return new String(contentBytes);        }    } else {        return IOUtils.toString(httpResponse.getEntity().getContent(), charset);    }}

getContent方法,首先判断是否有charset(这是在Site中配置的),如果有,直接调用ApacheCommons的IOUtils将相应内容转化成对应编码字符串,否则智能检测响应内容的字符编码。

protected String getHtmlCharset(HttpResponse httpResponse, byte[] contentBytes) throws IOException {    return CharsetUtils.detectCharset(httpResponse.getEntity().getContentType().getValue(), contentBytes);}

getHtmlCharset是调用CharsetUtils来检测字符编码,其思路就是,首先判断httpResponse.getEntity().getContentType().getValue()是否含有比如charset=utf-8

否则用Jsoup解析内容,判断是提取meta标签,然后判断针对html4中html4.01 <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />和html5中<meta charset="UTF-8" />分情况判断出字符编码。
当然,你懂的,如果服务端返回的不是完整的html内容(不包含head的),甚至不是html内容(比如json),那么就会导致判断失败,返回默认jvm编码值.
所以说,如果可以,最好手动给Site设置字符编码。

3、HttpClientGenerator

用于生成HttpClient实例,算是一种工厂模式了。

public HttpClientGenerator() {        Registry
reg = RegistryBuilder.
create() .register("http", PlainConnectionSocketFactory.INSTANCE) .register("https", buildSSLConnectionSocketFactory()) .build(); connectionManager = new PoolingHttpClientConnectionManager(reg); connectionManager.setDefaultMaxPerRoute(100); }

构造函数主要是注册http以及https的socket工厂实例。https下我们需要提供自定义的工厂以忽略不可信证书校验(也就是信任所有证书),在webmagic0.6之前是存在不可信证书校验失败这一问题的,之后webmagic合并了一个关于这一问题的PR,目前的策略是忽略证书校验、信任一切证书(这才是爬虫该采用的嘛,我们爬的不是安全,是寂寞。)

private CloseableHttpClient generateClient(Site site, Proxy proxy) {    CredentialsProvider credsProvider = null;    HttpClientBuilder httpClientBuilder = HttpClients.custom();        if(proxy!=null && StringUtils.isNotBlank(proxy.getUser()) && StringUtils.isNotBlank(proxy.getPassword()))    {        credsProvider= new BasicCredentialsProvider();        credsProvider.setCredentials(                new AuthScope(proxy.getHttpHost().getAddress().getHostAddress(), proxy.getHttpHost().getPort()),                new UsernamePasswordCredentials(proxy.getUser(), proxy.getPassword()));        httpClientBuilder.setDefaultCredentialsProvider(credsProvider);    }    if(site!=null&&site.getHttpProxy()!=null&&site.getUsernamePasswordCredentials()!=null){        credsProvider = new BasicCredentialsProvider();        credsProvider.setCredentials(                new AuthScope(site.getHttpProxy()),//可以访问的范围                site.getUsernamePasswordCredentials());//用户名和密码        httpClientBuilder.setDefaultCredentialsProvider(credsProvider);    }        httpClientBuilder.setConnectionManager(connectionManager);    if (site != null && site.getUserAgent() != null) {        httpClientBuilder.setUserAgent(site.getUserAgent());    } else {        httpClientBuilder.setUserAgent("");    }    if (site == null || site.isUseGzip()) {        httpClientBuilder.addInterceptorFirst(new HttpRequestInterceptor() {            public void process(                    final HttpRequest request,                    final HttpContext context) throws HttpException, IOException {                if (!request.containsHeader("Accept-Encoding")) {                    request.addHeader("Accept-Encoding", "gzip");                }            }        });    }    //解决post/redirect/post 302跳转问题    httpClientBuilder.setRedirectStrategy(new CustomRedirectStrategy());        SocketConfig socketConfig = SocketConfig.custom().setSoTimeout(site.getTimeOut()).setSoKeepAlive(true).setTcpNoDelay(true).build();    httpClientBuilder.setDefaultSocketConfig(socketConfig);    connectionManager.setDefaultSocketConfig(socketConfig);    if (site != null) {        httpClientBuilder.setRetryHandler(new DefaultHttpRequestRetryHandler(site.getRetryTimes(), true));    }    generateCookie(httpClientBuilder, site);    return httpClientBuilder.build();}

前面是设置代理代理及代理的用户名密码

这里主要需要关注的两点是
1、post/redirect/post 302跳转问题:这是是通过设置一个自定义的跳转策略类来实现的。(这在0.6版本之前是存在问题的,0.6版本之后合并了PR)

httpClientBuilder.setRedirectStrategy(new CustomRedirectStrategy());

CustomRedirectStrategy在继承HttpClient自带额LaxRedirectStrategy(支持GET,POST,HEAD,DELETE请求重定向跳转)的基础上,对POST请求做了特殊化处理,如果是POST请求,代码处理如下:

HttpRequestWrapper httpRequestWrapper = (HttpRequestWrapper) request;httpRequestWrapper.setURI(uri);httpRequestWrapper.removeHeaders("Content-Length");

可以看到,POST请求时首先会重用原先的request对象,并重新设置uri为新的重定向url,然后移除新请求不需要的头部。重用request对象的好处是,post/redirect/post 302跳转时会携带原有的POST参数,就防止了参数丢失的问题。

否则默认实现是这样的

if (status == HttpStatus.SC_TEMPORARY_REDIRECT) {                return RequestBuilder.copy(request).setUri(uri).build();            } else {                return new HttpGet(uri);            }

SC_TEMPORARY_REDIRECT是307状态码,也就是说只有在307状态码的时候才会携带参数跳转。

2、HttpClient的重试: 这是是通过设置一个默认处理器来实现的,同时设置了重试次数(也就是Site中配置的retryTimes)。

httpClientBuilder.setRetryHandler(newDefaultHttpRequestRetryHandler(site.getRetryTimes(), true));

之后便是配置Cookie策略。

private void generateCookie(HttpClientBuilder httpClientBuilder, Site site) {    CookieStore cookieStore = new BasicCookieStore();    for (Map.Entry
cookieEntry : site.getCookies().entrySet()) { BasicClientCookie cookie = new BasicClientCookie(cookieEntry.getKey(), cookieEntry.getValue()); cookie.setDomain(site.getDomain()); cookieStore.addCookie(cookie); } for (Map.Entry
> domainEntry : site.getAllCookies().entrySet()) { for (Map.Entry
cookieEntry : domainEntry.getValue().entrySet()) { BasicClientCookie cookie = new BasicClientCookie(cookieEntry.getKey(), cookieEntry.getValue()); cookie.setDomain(domainEntry.getKey()); cookieStore.addCookie(cookie); } } httpClientBuilder.setDefaultCookieStore(cookieStore);}

首先创建一个CookieStore实例,然后将Site中的cookie加入到cookieStore中。并配置到httpClientBuilder中。那么在这个HttpClient实例执行的所有请求中都会用到这个cookieStore。比如登录保持就可以通过配置Site中的Cookie来实现。

4、关于Page对象说明:

Page对象代表了一个请求结果,或者说相当于页面(当返回json时这种说法有点勉强)。

public Html getHtml() {        if (html == null) {            html = new Html(UrlUtils.fixAllRelativeHrefs(rawText, request.getUrl()));        }        return html;    }

通过它得到的页面,原始页面中的链接是不包含域名的情况下会被自动转换为http[s]开头的完整链接。

关于Downloader就分析到这,后续会进行补充,下篇主题待定。

转载地址:https://blog.csdn.net/weixin_33910434/article/details/89063229 如侵犯您的版权,请留言回复原文章的地址,我们会给您删除此文章,给您带来不便请您谅解!

上一篇:使用reactor eventbus进行事件驱动开发
下一篇:段落首字母设置样式

发表评论

最新留言

第一次来,支持一个
[***.219.124.196]2024年04月02日 08时34分04秒