背景
我们在进行Android开发时往往会面临技术选型的问题, 面对如此多的开源框架如何进行选择、选择的标准是什么,这是一个值得思考的问题. 为此我在后台爬取了6000多个主流应用,逐个反编译统计它们使用了哪些开源框架,因此做了一个款应用
基本思路
首先我们要有Apk才可以进行分析,我选择爬取酷安的应用数据(感觉酷安比较好爬一点),将每个应用的apk下载到本地,通过apktool进行反编译,查看反编译后的结果。虽然大部分应用都会进行混淆,但是涉及三方库的包一般是不会进行混淆的,所以我们只需要统计出代码的目录结构基本就可以推敲出该应用使用了哪些三方库。
使用pyspider爬取酷安数据
一般提到爬虫我们首先选择Python,在GitHub上Python中star最多的爬虫框架就是了,这是由国人开发的一个爬虫框架,用起来还算方便。只是在windows上安装不易,建议还是在linux安装,具体安装方式这里就不多介绍了,网上有很多教程。安装之后的界面是这样的
直接点击右边的Create新建任务就可以了
我们只需要在右边写代码,保存之后在左边点击run
就可以查看执行结果 我们先来看一下要爬取的对象
https://www.coolapk.com/apk?p=1
生成爬取的任务。在pyspider中通过self.crawl创建爬取任务,该方法有两个参数,第一个为要爬去的url,第二个为回调函数。如爬取每页数据的代码为 @config(age=10 * 24 * 60 * 60) def index_page(self, response): url = 'https://www.coolapk.com/apk?p=' # 从第1页到653页生成任务 for i in range(1, 654): self.crawl(url + str(i), callback=self.list_page)复制代码
这样爬虫会自动访问每页的数据,在访问成功之后回调list_page
方法,在list_page
方法中会提取该页中每个App的详情页对应的url,然后继续生成抓取任务
class
为 app_left_list
的 div
,该 div
下 a
标签的 href
值即为App详情页对应的url,具体代码如下 @config(priority=2) def list_page(self, response): # 从每一页中打开App详情页面 for each in response.doc('div[class="app_left_list"]').children('a').items(): self.crawl(each.attr.href, callback=self.detail_page)复制代码
最后就是在App详情页面提取我们需要的App的信息,然后将提取的信息保存到数据库中,并根据提取到的apk链接下载该apk,实际测试中发现酷安在进行apk文件下载时是有session校验的,所以下载时需要携带上session信息,由于下载过程比较耗时,pyspider不支持这种耗时操作,所以我们需要单独开启线程下载。
对于稍微具备一点前端知识的同学,然后查阅一下pyquery的用法,基本上提取我们需要的信息就没什么大问题。
完整的爬取代码如下
#!/usr/bin/env python# -*- encoding: utf-8 -*-# Created on 2017-12-13 20:17:00# Project: kuanfrom pyspider.libs.base_handler import *import requestsimport _threadimport jsonclass Handler(BaseHandler): crawl_config = { } # bomb应用配置信息 Bomb_Application_Id = 'bomb对应的Application Id' Bomb_Rest_Api_Key = 'bomb对应的Rest Api Key' headers = { 'User-Agent': 'Mozilla/4.0 (compatible; MSIE 5.5; Windows NT)', 'Referer': 'https://www.coolapk.com/apk/com.evernote'} @every(minutes=24 * 60) def on_start(self): self.crawl('https://www.coolapk.com/apk', callback=self.index_page) @config(age=10 * 24 * 60 * 60) def index_page(self, response): url = 'https://www.coolapk.com/apk?p=' # 从第1页到653页生成任务 for i in range(1, 654): self.crawl(url + str(i), callback=self.list_page) @config(priority=2) def list_page(self, response): # 从每一页中打开App详情页面 for each in response.doc('div[class="app_left_list"]').children('a').items(): self.crawl(each.attr.href, callback=self.detail_page) @config(priority=2) def detail_page(self, response): url = response.url packageName = url[28:len(url)] imgUrl = list(response.doc('div[class="apk_topbar"]').items())[ 0].children('img').attr("src") scriptLine = list(response.doc('script').items())[ 2].text().split('\n')[2] apkUrl = scriptLine[36:len(scriptLine) - 2] appName = response.doc( 'p[class="detail_app_title"]').text().split(" ")[0] desc = list(response.doc('div[class="apk_left_title_info"]').items())[ 0].html() left_info_list = list(response.doc( 'p[class="apk_left_title_info"]').items()) detail = left_info_list[len(left_info_list) - 1].html() # 获取下载量 apk_topba_message = response.doc('p[class="apk_topba_message"]').text() download_count = self.get_download_count( apk_topba_message.split('/')[1]) cookie = 'SESSID=' + response.cookies['SESSID'] _thread.start_new_thread( self.downloadFile, (apkUrl, packageName, cookie,)) appInfo = { "url": url, "packageName": packageName, "name": appName, "detail": detail, "imgUrl": imgUrl, 'downloadCount': download_count, "description": desc } self.saveAppInfo(appInfo) return appInfo def get_download_count(self, download_str): download_str = download_str.strip() if download_str.endswith('万下载'): return float(download_str.split('万下载')[0]) * 10000 elif download_str.endswith('次下载'): return float(download_str.split('次下载')[0]) elif download_str.endswith('下载'): return float(download_str.split('下载')[0]) else: return 0 def downloadFile(self, apkUrl, packageName, cookie): headers = self.headers headers['cookie'] = cookie r = requests.get(apkUrl, headers=self.headers, allow_redirects=True, verify=False) # 保存下载的文件 with open("/root/apk/" + packageName + ".apk", "wb") as f: f.write(r.content) # Bomb的唯一键不靠谱,每次保存之前先查询是否存在,然后再进行更新或者保存 def saveAppInfo(self, data): headers = { 'X-Bmob-Application-Id': self.Bomb_Application_Id, 'X-Bmob-REST-API-Key': self.Bomb_Rest_Api_Key, 'Content-Type': 'application/json'} url = 'https://api.bmob.cn/1/classes/app_info' exitInfo = self.queryAppByPackageName(data['packageName']) if(len(exitInfo['results']) > 0): url = url + '/' + exitInfo['results'][0]['objectId'] res = requests.put(url, headers=headers, data=json.dumps(data), verify=False) else: res = requests.post(url, headers=headers, data=json.dumps(data), verify=False) def queryAppByPackageName(self, packageName): headers = { 'X-Bmob-Application-Id': self.Bomb_Application_Id, 'X-Bmob-REST-API-Key': self.Bomb_Rest_Api_Key, 'Content-Type': 'application/json'} url = 'https://api.bmob.cn/1/cloudQuery' bql = 'select * from app_info where packageName=?' values = '[\'' + packageName + '\']' data = { 'bql': bql, 'values': values} url = url + '?bql=' + bql + '&values=' + values res = requests.get(url, headers=headers, verify=False) return json.loads(res.text)复制代码
使用Apktool反编译apk文件
apk文件下载完成之后我们就可以使用apktool进行反编译了。基本命令是java -jar apktool_2.3.0.jar d xxx.apk -o destDir -f
。这里我使用的apktool版本为2.3.0。
具体做法是依次反编译每个apk文件,一般情况下apk反编译之后的文件目录大致包含以下内容
第一个文件就不解释了,做Android开发的同学都知道。值得注意的是Apk的版本信息没有在AndroidManifest文件中,而是在apktool.yml文件中,这个文件里面包含很多apk有价值的信息。另一个值得我们关注的是smali文件夹,如果apk进行了分包可能还会出现smali_class2、smali_class3之类的文件夹。我们分析该app引用了哪些三方库主要看smali下的文件目录结构是什么样的。虽然这种方式并不完全准确,但是也能涵盖绝大部分三方库。具体代码如下
#!/usr/bin/env python# -*- coding:utf-8 -*-from __future__ import print_functionimport requestsimport jsonimport yamlimport osimport subprocessimport sysimport zipfilefrom xml.dom import minidomimport threadpoolimport shutilapktool = "apktool_2.3.0.jar"headers = { 'X-Bmob-Application-Id': 'bomb对应的Application Id', 'X-Bmob-REST-API-Key': 'bomb对应的Rest Api Key', 'Content-Type': 'application/json'}def sh(command): print(command) p = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) print(p.stdout.read())def decompileApk(f): # fix windows path if ":\\" in f and not ":\\\\" in f: f = f.replace("\\", "\\\\") dexes = [] jars = [] if f.endswith(".apk"): package_name = f[0:len(f) - 4] tempDir = os.path.splitext(f)[0] sh("java -jar %s d %s -o %s -f" % (apktool, f, tempDir)) if os.path.isdir(os.path.join(tempDir, 'smali_classes2')): sh("cp -rf smali_classes2/* smali/") jarDir = os.path.join(tempDir, 'smali') if os.path.exists(jarDir): packageList = [] getPackageName(jarDir, jarDir, packageList) packageList = cleanPackageName(packageList) savePackageList(packageList, package_name) sh('sed -i 1d %s' % (tempDir + '/apktool.yml')) versionInfo = getVersionInfo(tempDir + '/apktool.yml') saveApkInfo(package_name, versionInfo['versionCode'], versionInfo['versionName']) shutil.rmtree(tempDir) print("Done")def mapFunc(package): return package.replace('/', '.')def cleanPackageName(packageList): return list(map(mapFunc, packageList))def getVersionInfo(file): f = open(file) y = yaml.load(f) return y['versionInfo']def getPackageName(root, dir, packageList): files = [f for f in os.listdir( dir) if os.path.isfile(os.path.join(dir, f))] if len(files) > 0 and root != dir: if len(dir.split(root + '/')) > 1: packageList.append(dir.split(root + '/')[1]) else: print('error root:%s dir:%s' % (root, dir)) elif len([f for f in os.listdir(dir) if len(f) > 1]) == 0: if len(dir.split(root + '/')) > 1: packageList.append(dir.split(root + '/')[1]) else: print('error root:%s dir:%s' % (root, dir)) else: for file in [f for f in os.listdir(dir) if os.path.isdir(os.path.join(dir, f))]: if len(file) > 1: getPackageName(root, os.path.join(dir, file), packageList)def packageToRequest(package): return { 'method': 'POST', 'path': '/1/classes/lib_info', 'body': { 'packageName': package}}def savePackageList(packageList, apk_id): url = 'https://api.bmob.cn/1/batch' i = 0 while i < len(packageList): subList = packageList[i:i + 50] params = {} params['requests'] = list( map(packageToRequest, subList)) res = saveDataToBomb(url, params) saveLibApkRelation(subList, apk_id) i += 50def lib_id_to_request(lib_id): return { 'method': 'POST', 'path': '/1/classes/r_apk_lib', 'body': { 'libPackageName': lib_id}}def saveLibApkRelation(lib_id_list, apk_id): url = 'https://api.bmob.cn/1/batch' params = {} params['requests'] = list( map(lib_id_to_request, lib_id_list)) for req in params['requests']: req['body']['apkPackageName'] = apk_id res = saveDataToBomb(url, params)def saveApkInfo(packageName, versionCode, versionName): data = { "packageName": packageName, "versionCode": versionCode, "versionName": versionName} url = 'https://api.bmob.cn/1/classes/apk_info' oldInfo = json.loads(queryDataFromBomb(url, data)) if len(oldInfo['results']) > 0: print('%s is exits' % {str(data)}) else: saveDataToBomb(url, data)def saveDataToBomb(url, data): res = requests.post(url, headers=headers, data=json.dumps(data), verify=False) return resdef queryDataFromBomb(url, data): print('%s ?where=%s' % (url, json.dumps(data))) res = requests.get('%s?where=%s' % (url, json.dumps(data)), headers=headers, verify=False) return res.textif __name__ == "__main__": f = sys.argv[1] if os.path.isdir(f): pool = threadpool.ThreadPool(1) name_list = os.listdir(f) # 单线程运行 for name in name_list: decompileApk(name) # 多线程运行 # myrequets = threadpool.makeRequests(decompileApk, name_list) # [pool.putRequest(req) for req in myrequets] # pool.wait() print('All Finished') else: print('参数必须为一个目录')复制代码
从实际分析结果来看,目前的分析算法还有很多问题,统计出来的包名和我们实际使用的三方库不能完全匹配,有时会把子包名统计进去。所以只能靠大家经验还判断每个包名对应的是哪个三方库了。
App展示统计结果
最后将上面抓取和分析的结果以App的形式展示出来,相比上两步而言这个是最简单的了。目前主要提供两个维度的展示,一是按照酷安上的下载量展示App信息,在App详情中展示该app下统计出来的包信息;另一个维度是按照库被引用的次数展示,详情页面中展示哪些应用中包含这个库。功能比较简单所以就不多解释了,直接放代码地址:,欢迎大家star、提issue,或者有更好的想法一起来实现。
App目前已经发布在酷安市场,下载地址为:
二维码:
总结
从开始着手准备,到最终完成第一个版本的功能大概两周时间,由于没有正经学习过python,所以python相关代码写的可能不太规范,仅供大家参考。
目前实际下载下来的apk文件只有5000+,还有1000多没有下载下来。apk反编译还在进行,目前已经分析了2000+,所以统计结果可能还会不断变化
感谢
https://www.coolapk.com/
感谢酷安提供的数据(手动滑稽)
https://github.com/binux/pyspider
感谢pyspider让我一个新手也可以爬数据
https://github.com/tp7309/AndroidOneKeyDecompiler
感谢作者提供python反编译apk的思路