一起看一下主流應用使用了哪些三方庫

dmx發表於2018-01-02

背景

我們在進行Android開發時往往會面臨技術選型的問題, 面對如此多的開源框架如何進行選擇、選擇的標準是什麼,這是一個值得思考的問題. 為此我在後臺爬取了6000多個主流應用,逐個反編譯統計它們使用了哪些開源框架,因此做了一個款應用

基本思路

首先我們要有Apk才可以進行分析,我選擇爬取酷安的應用資料(感覺酷安比較好爬一點),將每個應用的apk下載到本地,通過apktool進行反編譯,檢視反編譯後的結果。雖然大部分應用都會進行混淆,但是涉及三方庫的包一般是不會進行混淆的,所以我們只需要統計出程式碼的目錄結構基本就可以推敲出該應用使用了哪些三方庫。

使用pyspider爬取酷安資料

一般提到爬蟲我們首先選擇Python,在GitHub上Python中star最多的爬蟲框架就是pyspider了,這是由國人開發的一個爬蟲框架,用起來還算方便。只是在windows上安裝不易,建議還是在linux安裝,具體安裝方式這裡就不多介紹了,網上有很多教程。安裝之後的介面是這樣的

一起看一下主流應用使用了哪些三方庫

直接點選右邊的Create新建任務就可以了

一起看一下主流應用使用了哪些三方庫

我們只需要在右邊寫程式碼,儲存之後在左邊點選run就可以檢視執行結果 我們先來看一下要爬取的物件

一起看一下主流應用使用了哪些三方庫
一共有653頁,每頁10個,一共6530個應用。爬取的就基本思路就是首先根據Url: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,然後繼續生成抓取任務

一起看一下主流應用使用了哪些三方庫
根據酷安App列表頁面的dom結構可以看到我們首先要找到classapp_left_listdiv,該diva標籤的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: kuan

from pyspider.libs.base_handler import *
import requests
import _thread
import json


class 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_function

import requests
import json
import yaml
import os
import subprocess
import sys
import zipfile
from xml.dom import minidom
import threadpool
import shutil

apktool = "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 += 50


def 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 res


def 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.text


if __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的思路

相關文章