爬虫框架WebMagic源码分析之Downloader

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 和html5中分情况判断出字符编码。
当然,你懂的,如果服务端返回的不是完整的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就分析到这,后续会进行补充,下篇主题待定。

你可能感兴趣的:(网页爬虫,webmagic,java)