背景
我們在進行Android開發時往往會面臨技術選型的問題, 面對如此多的開源框架如何進行選擇、選擇的標準是什么,這是一個值得思考的問題. 為此我在后臺爬取了6000多個主流應用,逐個反編譯統計它們使用了哪些開源框架,因此做了一個款應用
基本思路
首先我們要有Apk才可以進行分析,我選擇爬取酷安的應用數據(感覺酷安比較好爬一點),將每個應用的apk下載到本地,通過apktool進行反編譯,查看反編譯后的結果。雖然大部分應用都會進行混淆,但是涉及三方庫的包一般是不會進行混淆的,所以我們只需要統計出代碼的目錄結構基本就可以推敲出該應用使用了哪些三方庫。
使用pyspider爬取酷安數據
一般提到爬蟲我們首先選擇Python,在GitHub上Python中star最多的爬蟲框架就是pyspider了,這是由國人開發的一個爬蟲框架,用起來還算方便。只是在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 requests
import _thread
import 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.urlpackageName = 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 appInfodef get_download_count(self, download_str):download_str = download_str.strip()if download_str.endswith('萬下載'):return float(download_str.split('萬下載')[0]) * 10000elif download_str.endswith('次下載'):return float(download_str.split('次下載')[0])elif download_str.endswith('下載'):return float(download_str.split('下載')[0])else:return 0def downloadFile(self, apkUrl, packageName, cookie):headers = self.headersheaders['cookie'] = cookier = 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=' + valuesres = 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反編譯之后的文件目錄大致包含以下內容
具體代碼如下
#!/usr/bin/env python
# -*- coding:utf-8 -*-from __future__ import print_functionimport requests
import json
import yaml
import os
import subprocess
import sys
import zipfile
from xml.dom import minidom
import threadpool
import 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 pathif ":\\" 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 = 0while 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_idres = 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下統計出來的包信息;另一個維度是按照庫被引用的次數展示,詳情頁面中展示哪些應用中包含這個庫。功能比較簡單所以就不多解釋了,直接放代碼地址:github.com/dumingxin/A…,歡迎大家star、提issue,或者有更好的想法一起來實現。
App目前已經發布在酷安市場,下載地址為:www.coolapk.com/apk/172597
二維碼:
總結
從開始著手準備,到最終完成第一個版本的功能大概兩周時間,由于沒有正經學習過python,所以python相關代碼寫的可能不太規范,僅供大家參考。
目前實際下載下來的apk文件只有5000+,還有1000多沒有下載下來。apk反編譯還在進行,目前已經分析了2000+,所以統計結果可能還會不斷變化
感謝
https://www.coolapk.com/
感謝酷安提供的數據(手動滑稽)
https://github.com/binux/pyspider
感謝pyspider讓我一個新手也可以爬數據
https://github.com/tp7309/AndroidOneKeyDecompiler
感謝作者提供python反編譯apk的思路