Skip to content
John Corner edited this page Feb 25, 2018 · 2 revisions

单纯爬虫的缺点就是不支持浏览器的很多动态特性,单纯是字符串解析的功能.实际上简单的静态数据读取是没有问题的,不过很多一些依赖动态特性的站点就不适用了,也就是说实际的站点还有浏览器一层,我们可以完全模拟浏览器的行为因为浏览器也是基于这个http协议的,但是太多太复杂基本不可行.

为什么要WebView Shell?

在App里面,我们都是使用系统提供的webview访问网页,同时也提供与web页面交互的api,在mac/iOS上API是

evaluateJavaScript(_ javaScriptString: String, completionHandler: ((Any?, Error?) -> Swift.Void)? = nil)

该API能给webview注入js,并且支持基础数据传递(字符串、数字、数组、字典)。还有一个API可以实现js调用原生代码,但是那个就比较复杂,需要原生这边建立监听机制,交互规则是很严格的。

本身我们使用系统给的API就可以完成对web页面的操作,但是实际代码会及其复杂,甚至不可维护。如下图所示,业务代码和webview之间的关系基本是异步、多线程操作,极度依赖全局变量来进行值传递。

webshell

一个给原生代码使用的Shell就必不可少了,可以减少很多繁杂又容易出错的工作量。

webview也算浏览器层了,很多工作它都可以帮我们做,想想直接js操作dom,比字符串的正则表达式简单多了.

全局变量是最可怕的东西,加上多线程,操作系统内核就是全世界全局变量用的最溜的程序,复杂程度可想而知,但是我们现在是在应用程序层面开发,能避免就避免,所以架构之路是一条必行的路。

web页面只知道当前状态,而过去和未来都是它缺少的东西,所以我要添加的东西就是整个页面序列的控制以及js注入的控制,所以webview来说只需要执行js并返回结果就可以了,其它部分原生代码来负责.

需求分析

什么事情都得要一个实际的例子来实验才有意义,因此我就把最近项目的一个模块解决方案和大家分享。

该模块称为 WebShell.

它要做的事情就是把网盘某个资源实现一键下载。而且不是一种网盘,是多种网盘。

一般情况是这样的:

  1. 我们获得一个网盘下载页面的地址
  2. 打开这个地址,各种跳转和倒计时等待然后到验证码输入页面
  3. 提交验证码后才返回真实下载地址(无文件名信息)

这样的下载地址一般是 dl.php?xxxxxxxx 这样的路径,是一种PHP的动态下载方案,并且不支持断点续传和多线程下载工具.

所以我们的工作就是模仿浏览器执行获取验证码和下载地址整个合法流程(非破解会员之类的功能).

一些网盘的验证码和延时其实是摆设,通过页面代码测试是完全可以跳过的,不过同一时间只能下载一个文件.

首先,我们来分享飞猫网盘的下载流程。

  1. 访问下载页面,有个“普通下载”按钮,点击

webshell

  1. 跳转到新页面,倒计时15秒,然后出现“普通下载”按钮,点击

webshell

webshell

  1. 出现验证码,输入验证码,提交

webshell

  1. 新页面打开,然后浏览器再开始下载

webshell

webshell

整个过程耗时大概1-2分钟,输错的话更久,这个过程你很难同时做其它事情.

Web代码分析

通过上面的流程在技术上看不出什么蹊跷,那么我们就看它整个流程的代码:

下载页面

http://feemoo.com/file-1876003.html

第一个下载页面是这个地址,没有再做重定向,也没有等待倒计时。

普通下载按钮实际是一个a标签,点击后跳转到第二个页面 fmdown.php?82f00aNQKUHxLAN8oF/APwF+W7I1bmezAqGx7Ua7v0afgV42/k4Y5mlrEEbrKB5utJrHCRtWBkzbs4q8wZBe0zCbN2LMIm9OG3Z/tDUI2U1HPrvq+YZTjB40Io0

<a href="fmdown.php?82f00aNQKUHxLAN8oF/APwF+W7I1bmezAqGx7Ua7v0afgV42/k4Y5mlrEEbrKB5utJrHCRtWBkzbs4q8wZBe0zCbN2LMIm9OG3Z/tDUI2U1HPrvq+YZTjB40Io0" style="border: 1px solid #6e8ea3;color: #6e8ea3;">  
普通下载  
</a>   

倒计时+验证码

倒计时实际上是只是前端的计时,倒计时完成以后出现的普通下载按钮实际上调用了 vip_downvip_down('com','1876003'); 方法才是关键

<span onclick="vip_downvip_down('com','1876003');" class="combtn bc" style="background-color: rgb(171, 205, 230); display: inline-block; float: none; margin-top: 13px;">
普通下载
</span>
function vip_downvip_down(tab,file_id) {
		var het='200px';
		if('0'=='0'){
			het='480px';
		}
		var tabg=$('#very_btn');
		tabg.val('');
		tabg.attr('ftag',tab);
		$('#vecysmt').attr('fid',file_id);

        $.post('new_imgcode.php',{act:'downvf'},function (res) {
            if(res.base){
                tabg.prev().attr('src',res.base);
                codeencry=res.code;
            }else{
                layer.msg('连接超时,请刷新页面!');
            }

        },'json')

		layer.open({
			type: 1,
			shadeClose:true,
            title:['下载验证','background-color: #EDF7FF;border: 0;text-align: center;padding: 0;'],
			area: ['400px', het],
			content:$('#vecytable')
		});
}

其中核心方法是:

$.post('new_imgcode.php',{act:'downvf'},function (res) {
    if(res.base){
        tabg.prev().attr('src',res.base);
        codeencry=res.code;
    }else{
        layer.msg('连接超时,请刷新页面!');
    }
},'json')

给后台发了个post报文,通过报文分析可知参数是act=downvf, 也就是 x-www-form-urlencoded 键值对格式。 post报文的Header信息要按照如下参数设置, 这是多次抓包抓到的:

Key Value
Host www.feemoo.com
Accept application/json, text/javascript, */*; q=0.01
X-Requested-With XMLHttpRequest
keep-alive zh-cn
Accept-Encoding gzip, deflate
Content-Type application/x-www-form-urlencoded
Origin http://www.feemoo.com
User-Agent Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_3) AppleWebKit/604.5.6 (KHTML, like Gecko) Version/11.0.3 Safari/604.5.6
Connection keep-alive

验证码验证需要三个参数,这时候就返回下载地址了,在函数 com_down(file_id, verycode, event) 里面实现验证码和下载地址获取, 我们得重写它:

// 定义一个全局变量
var downloadfilelink = "";
// 重写验证函数
function com_down(file_id, verycode, event) {
    var c1 = layer.load();
    $.ajax({
       type: 'post',
       url: 'ajax.php',
       data: 'action=load_down_addr_com&file_id=' + file_id + '&verycode=' + verycode + '&codeencry=' + codeencry,
       dataType: 'json',
       success: function(msg) {
           layer.close(c1);
           // 获取下载地址成功后存储下载链接,等待原生代码js注入获取
           if (msg.status) {
		        downloadfilelink = msg.str;
           }    else    {
		        downloadfilelink = 'bad boy';
           }
       },
       error: function() {}
     });
}
参数名 作用
code 验证码明文
codeencry 验证码加密字符串,用于后台验证
fileid 文件ID

这三个参数通过ajax传递到后台, 返回的是json字符串:

{
	"status":true,
	"str":"文件下载地址"
}

经过测试需要在页面获取新验证码才有效,并且layer.open();需要大概1s时间才能渲染出dom,所以需要延时注入获取验证码图片的js代码。

js抓取的图片对象原生代码是无法解析的,所以转成base64字符串给原生代码

重新理下来就是这样: webshell

下载

好了,抓到下载地址以后就是下载了,下载功能需要支持多个网盘下载同时下载(同一个网盘只有一个任务),那么久需要管理起来,我们首先创建两个结构体,用于下载参数的配置:

/// 下载任务
struct DownloadTask {
    /// 请求配置
    var request : DownloadRequest
    /// http任务
    var task : URLSessionDownloadTask
    /// 下载进度,1.0为100%
    var progress : Float {
        return totalBytes > 0 ? Float(revBytes) / Float(totalBytes) : 0
    }
    /// 总共需要下载的字节数
    var totalBytes : Int64
    /// 已经接收到的字节数
    var revBytes : Int64
    /// 接受到的数据
    var revData : Data?
    
    init(request newRequest: DownloadRequest, task newTask: URLSessionDownloadTask) {
        self.request = newRequest
        self.task = newTask
        self.totalBytes = 0
        self.revBytes = 0
        self.revData = nil
    }
}

/// 请求配置
struct DownloadRequest {
    /// 唯一标签
    var label : String
    /// 文件名
    var fileName : String
    /// 下载进度更新回调
    var downloadStateUpdate : ((DownloadTask) -> ())?
    /// 下载完成回调
    var downloadFinished : ((DownloadTask) -> ())?
    /// http报文头部键值对
    var headFields : [String:String]
    /// 地址
    var url : URL
    /// http报文方法
    var method : HTTPMethod
    /// httpBody,post的时候放参数
    var body : Data?
    /// 用于URLSession, 启动下载任务
    var request : URLRequest {
        get {
            var req = URLRequest(url: url)
            req.httpShouldHandleCookies = true
            req.httpMethod = method.rawValue
            for item in headFields {
                req.addValue(item.value, forHTTPHeaderField: item.key)
            }
            req.httpBody = body
            return req
        }
    }
}

抓到的下载地址就是 DownloadRequest 里面的urlheadFields用于报文的header信息生成,由调用下载任务的调用者赋值。

/// 下载状态数据模型,用于视图数据绑定
class DownloadInfo : NSObject {
    var uuid = ""
    @objc dynamic var name = ""
    @objc dynamic var progress = ""
    @objc dynamic var totalBytes = ""
    @objc dynamic var site = ""
    @objc dynamic var state = ""
    override init() {
        super.init()
    }
}

/// 下载管理器,单例实现
class DownloadManager : NSObject {
    private static let _manager = DownloadManager()
    /// 外部访问的单例对象
    static var share : DownloadManager {
        get {
            return _manager
        }
    }
    private var session : URLSession!
    
    var tasks = [DownloadTask]()
    
    override init() {
        super.init()
        let config = URLSessionConfiguration.default
        session = URLSession(configuration: config, 
	        					  delegate: self, 
        					 delegateQueue: OperationQueue.main)
    }
    
    
    /// 添加下载任务,并开始执行下载
    ///
    /// - Parameter request: 下载任务
    func add(request: DownloadRequest) {
        let tk = session.downloadTask(with: request.request)
        let task = DownloadTask(request: request, task: tk)
        tasks.append(task)
        tk.resume()
        print("start task \(task.request.fileName)")
    }
}

extension DownloadManager : URLSessionDownloadDelegate {
    /// 下载完成代理方法
    ///
    /// - Parameters:
    ///   - session: 会话对象
    ///   - downloadTask: http下载任务对象
    ///   - location: 临时下载文件本地地址
    func urlSession(_ session: URLSession, 
    				downloadTask: URLSessionDownloadTask, 
    				didFinishDownloadingTo location: URL) {
        var task = tasks.first(where: { $0.task == downloadTask })
        do {
            task?.revData = try Data(contentsOf: location)
            print("download \(task?.request.fileName ?? "") finish!")
            if let tk = task {
                // 调用下载完成回调
                task?.request.downloadFinished?(tk)
            }
        } catch {
            print("Download Save Error: \(error)")
        }
    }
    
    func urlSession(_ session: URLSession, 
    		didBecomeInvalidWithError error: Error?) {
        print("didBecomeInvalidWithError: \(error != nil ? error!.localizedDescription : "no error")")
    }
    
    func urlSession(_ session: URLSession, 
    					task: URLSessionTask, didCompleteWithError error: Error?) {
        print("didCompleteWithError: \(error.debugDescription)")
    }
    
    /// 下载进度更新代理方法
    ///
    /// - Parameters:
    ///   - session: 会话
    ///   - downloadTask: http下载任务
    ///   - bytesWritten: 本次下载多少字节
    ///   - totalBytesWritten: 已经下载多少字节
    ///   - totalBytesExpectedToWrite: 一共需要下载多少字节
    
    func urlSession(_ session: URLSession, 
    		downloadTask: URLSessionDownloadTask, 
    		didWriteData bytesWritten: Int64, 
    		totalBytesWritten: Int64, 
    		totalBytesExpectedToWrite: Int64) {
        var task = tasks.first(where: { $0.task == downloadTask })
        task?.totalBytes = totalBytesExpectedToWrite
        task?.revBytes = totalBytesWritten
        if let tk = task {
            print("------ name: \(tk.request.fileName) ------ progress: \(tk.progress) ------")
            // 调用下载更新回调
            task?.request.downloadStateUpdate?(tk)
        }
    }
}

使用方法:

// 生成下载任务,逻辑代码较复杂
let request = DownloadRequest(......)
// 添加至下载管理器自动下载,下载状态更新方法在request里面定义
DownloadManager.share.add(request: request)

Shell架构

下载搞定了,接下来就是shell的开发了。也就是围绕webview的api再一次封装。 我把核心方法做成一个类叫WebRiffle,维护着一个WebBullet数组(包含页面链接、js脚本、回调处理闭包),核心实现js注入和回调流程控制,也可以看成一种流程描述模型。

  1. 读取当前WebBullet,载入页面,成功以后下一步
  2. 如果有注入js,按顺序注入js,执行成功后下一步
  3. js返回数据通过回调处理
  4. 若js已经执行到最后一组,下一步,否则返回执行步骤2
  5. 若标志位设置为false,则暂停直到强制读取下一个WebBullet或增加当前页面js注入动作。否则自动读取下一个WebBullet

WebBullet

WebBullet 就是用来存储配置参数,js脚本序列、每个js脚本执行后的回调闭包通过结构体InjectUnit绑定在一起:

/// js注入模块
struct InjectUnit {
    /// 注入js
    var script : String
    /// js执行成功回调, 返回对象可能是字符串、数组、字典、数字的组合
    var successAction : ((Any?)->())?
    /// js执行失败回调
    var failedAction : ((Error)->())?
    var isAutomaticallyPass : Bool
}

WebBullet 本身也是结构体,因为它主要是存储绑定功能:

struct WebBullet {
    /// URLRequest对象,属于计算变量,根据其他变量生成
    var request : URLRequest {
        get {
            var req = URLRequest(url: url)
            req.httpMethod = method.rawValue
            for item in headFields {
                req.addValue(item.value, forHTTPHeaderField: item.key)
            }
            var body = ""
            for (index, item) in formData.enumerated() {
                body += "\(item.key)=\(item.value)\(index < formData.count - 1 ? "&":"")"
            }
            if body != "" {
                req.httpBody = body.data(using: .utf8)
            }
            return req
        }
    }
    /// http方法
    var method : HTTPMethod
    /// http请求头部自定义信息
    var headFields : [String:String]
    /// http报文body数据(x-www-form-urlencoded格式)
    var formData : [String:String]
    /// 访问url
    var url : URL
    /// 注入js,执行时间为当前页面载入完成后
    var injectJavaScript : [InjectUnit]
}

WebRiffle

WebRiffle 就很复杂了,我们就看看核心的webview注入和流程控制:

/// 跳转下一个页面并支持执行多个js指令序列
func execNextCommand() {
    DispatchQueue.global().async {
        if let result = self.currentResult {
            for js in result.injectJavaScript {
            		// 线程同步,信号量,evaluateJavaScript为异步执行
                let sem = DispatchSemaphore(value: 0)
                DispatchQueue.main.async {
                    self.webView.evaluateJavaScript(js.script, completionHandler: { (data, err) in
                        if let e = err {
                            js.failedAction?(e)
                            print("error : \(e)")
                            // 失败也发送信号量
                            sem.signal()
                            return
                        }
                        js.successAction?(data)
                        print("sucess : \(result.method)")
                        // 成功发送信号量
                        sem.signal()
                    })
                }
                // 等待信号量
                sem.wait()
                // 执行完当前js模块后移除,FIFO序列
                let _ = self.currentResult?.injectJavaScript.removeFirst()
                // 标志位设置着不载入下一个WebRiffle
                if !js.isAutomaticallyPass {
                    print("pause")
                    return
                }
            }
            self.currentResult = self.bulletsIterator?.next()
            if let result = self.currentResult {
                DispatchQueue.main.async {
                    self.webView.load(result.request)
                }
            }
        }
    }
}

示例程序

我已经写好了示例代码,并且封装成一个框架,感兴趣的朋友可以在这里下载完整的代码:

https://github.com/0xfeedface1993/WebShell

该项目支持 Carthage。

carthage

添加 WebShell 到你的工程下的 Cartfile:

github "0xfeedface1993/WebShell" ~> 0.2

演示

下载测试文件
飞猫盘:http://www.feemoo.com/s/v2j0z15j
牛 盘:http://www.88pan.cc/file-530009.html
彩虹盘:http://www.ccchoo.com/down-51745.html

Clone this wiki locally