一起看一下主流应用使用了哪些三方库

背景

我们在进行Android开发时往往会面临技术选型的问题,
面对如此多的开源框架如何进行选择、选择的标准是什么,这是一个值得思考的问题.
为此我在后台爬取了6000多个主流应用,逐个反编译统计它们使用了哪些开源框架,因此做了一个款应用

基本思路

首先我们要有Apk才可以进行分析,我选择爬取酷安的应用数据(感觉酷安比较好爬一点),将每个应用的apk下载到本地,通过apktool进行反编译,查看反编译后的结果。虽然大部分应用都会进行混淆,但是涉及三方库的包一般是不会进行混淆的,所以我们只需要统计出代码的目录结构基本就可以推敲出该应用使用了哪些三方库。

使用pyspider爬取酷安数据

一般提到爬虫我们首先选择Python,在GitHub上Python中star最多的爬虫框架就是pyspider了,这是由国人开发的一个爬虫框架,用起来还算方便。只是在windows上安装不易,建议还是在linux安装,具体安装方式这里就不多介绍了,网上有很多教程。安装之后的界面是这样的

未命名1514536079.png

直接点击右边的Create新建任务就可以了

未命名1514537565.png

我们只需要在右边写代码,保存之后在左边点击run就可以查看执行结果
我们先来看一下要爬取的对象

未命名1514537866.png

一共有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,然后继续生成抓取任务

未命名1514875032.png

根据酷安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反编译之后的文件目录大致包含以下内容

未命名1514878879.png

第一个文件就不解释了,做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下统计出来的包信息;另一个维度是按照库被引用的次数展示,详情页面中展示哪些应用中包含这个库。功能比较简单所以就不多解释了,直接放代码地址:https://github.com/dumingxin/AndroidDevGuid,欢迎大家star、提issue,或者有更好的想法一起来实现。

App目前已经发布在酷安市场,下载地址为:https://www.coolapk.com/apk/172597

二维码:


image.png

总结

从开始着手准备,到最终完成第一个版本的功能大概两周时间,由于没有正经学习过python,所以python相关代码写的可能不太规范,仅供大家参考。

目前实际下载下来的apk文件只有5000+,还有1000多没有下载下来。apk反编译还在进行,目前已经分析了2000+,所以统计结果可能还会不断变化

感谢

https://www.coolapk.com/ 感谢酷安提供的数据(手动滑稽)

https://github.com/binux/pyspider 感谢pyspider让我一个新手也可以爬数据

https://github.com/tp7309/AndroidOneKeyDecompiler 感谢作者提供python反编译apk的思路

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,999评论 4 368
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,102评论 1 302
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 110,709评论 0 250
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,439评论 0 217
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,846评论 3 294
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,881评论 1 224
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,062评论 2 317
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,783评论 0 205
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,517评论 1 248
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,762评论 2 253
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,241评论 1 264
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,568评论 3 260
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,236评论 3 241
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,145评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,941评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,965评论 2 283
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,802评论 2 275

推荐阅读更多精彩内容