Android 用Groovy实现扇贝阅读APP的自动阅读功能

动机

最近因为想要英语学习,特下载了「扇贝阅读」App,保证自己抽空能够提升一下自己的英语水平。这个App有一个功能,就是打卡功能,每天成功阅读完两篇英语短文,就能完成每日打卡,并领取一些奖励。

问题就出现在这里,因为这个App的设定是,如果天天都坚持打卡,那么你就能持续的获得奖励,这些奖励可用来兑换付费的英语书。为了保证能够最大化每日奖励,我就必须坚持阅读打卡,平时这个设定没啥问题,但是有时候(就是前两天的五一放假),我可能一天都没有时间阅读,但是我又不想错过每日的奖励,该怎么办呢?

有朋友说了,你直接跳过阅读,点击完成阅读,然后打卡可以吗,这里就要说要扇贝阅读APP的一个奇葩的设定了,那就是:

直接快速滑到底部,点击完成阅读,APP会检测到你这个异常的操作,然后提示你「请认真阅读」。

就是这样:

error1.gif

不止是扇贝阅读这个App,市面上其他一些主流英语阅读App都有这种类似的设定,我想策划是想告诉我们:

既然想要学习,为何不好好认真下来学习呢?不要骗自己。

好好,你说的我都懂,可是我真的抽不出时间认真阅读2篇文章,又不想断了自己的连续签到奖励怎么办?

有朋友说了,既然他让我们多花点时间阅读,我们就搁在那里不动,过两分钟再点击完成阅读按钮呗。

很遗憾,我进行了尝试,然并卵,APP依旧冷漠无情,让我「再认真阅读一遍」。

经过尝试,我发现,APP的处理逻辑大概是这样,确保用户每一行文字都会被展示一段时间(就像我们正常阅读的效果一样),当所有段落都经过一段时间被「阅读」后,才能正常「完成阅读」

有朋友又说了,那我们就模拟阅读,慢慢往下划,让文章被「慢速」、「均匀」地拉到底部,怎么样?

这就是笔者五一期间「作弊」式打卡的方式,事实证明完全可行,但是这种方式的弊端也很明显,一篇1.2百词汇的文章,也许1分钟就能拉到底,要是4.5百词汇的文章,就得花数分钟来模拟「阅读」操作。

我不禁深深被APP这种奇葩的设定感动到了,这种完全是「防君子不防小人」的设定,究竟有什么意义?并且,随着第二天,第三天这样的操作过来,我不禁无语了,这种毫无技术含量的作弊手段,可以称得上既无聊又繁琐,让我感觉自己是被APP强行交了一波智商税。

那就写个工具吧

基于上述事实,我决定写个工具,尽量代替双手解决目前的窘境。

我选择了Groovy作为开发语言,写了一个脚本,模拟用户操作,缓慢阅读文章,并自动点击「完成阅读」按钮。

先来看看脚本运行的效果,完全由ADB命令控制:

因为gif图的原因,看起来很快,实际上ADB是控制屏幕缓慢地匀速下拉

看一下命令行的输出:

开始阅读
结束阅读

我的基本思路是这样:

  • 1、通过adb命令模拟用户向下翻页的操作;
  • 2、每次模拟完翻页操作后,将当前屏幕截图保存;
  • 3、然后将上次翻页完成后的截图和本次截图进行图像识别分析,得到2张屏幕截图的相似度;
  • 4、当2张屏幕截图的相似度匹配不高时,视为两张图片不同,即应该继续向下翻页,并重复1~3的行为;
  • 5、当2张屏幕截图的相似度匹配很高时,视为该两次操作达到了文章最底部(无法继续下翻,所以截图基本一样),点击「完成阅读」按钮,并清除截图缓存文件夹,结束本次脚本任务。

脚本代码

前期是补充一些基本的属性和配置:

final int actionInterval = 250        //两次下翻操作的时间间隔,单位毫秒
final float threshold = 0.95          //图片分析相似度的阈值,当相似度大于阈值时,视为图片相同

println "已选中的Android设备:"

println "————————————————————————————————————"

println "adb devices".execute().text

println "————————————————————————————————————"

println "开始执行自动阅读"

//因为系统原因,很多情况下该命令实际的效果为对界面元素的长按,因此抛弃该命令
//println 'input swipe 540 1300 540 500 100 '

boolean clearScreenShotCacheWhenFinishTask = true       //可选项,当脚本执行结束时,是否自动清除截图缓存
def ending = false              //是否已结束
def duration = 0                //本次操作已执行时间
String rootPath = System.getProperty("user.dir") + "/screenshots/"
String lastScreenShot = null
String newScreenShot = null

本来想用 adb shell input swipe 命令模拟滑动操作,但是发现某些系统的设备不支持该命令,实现效果会变成「长按界面上某个元素」,而非我们想要的「滑动界面」操作,无奈,使用adb shell input keyevent 20命令代替。

我们提供了几个方法方便调用:

/**
 * 为当前的屏幕截图,并保存在默认路径
 */
def task_screenShot(String rootPath) {

    def millis = currentTimeMillis()

    def screenShotPath = rootPath + millis  //要截图的路径

    println "screenShotPath = $screenShotPath"

    println "adb shell screencap -p /sdcard/${millis}".execute().text
    println "adb pull /sdcard/${millis} $screenShotPath".execute().text

    return screenShotPath
}

/**
 * 为当前app执行向下翻页操作
 */
def task_downPage(Integer interval = 500) {
    Thread.sleep(interval)
    println "adb shell input keyevent 20".execute().text
}

/**
 * 通过比较获取图片的相似度
 */
def task_compareSimilar(String pic1, String pic2) {
    def print1 = new FingerPrint(ImageIO.read(new File(pic1)))
    def print2 = new FingerPrint(ImageIO.read(new File(pic2)))

    return print1.compare(print2)
}

/**
 * 结束阅读,自动点击屏幕下方按钮「完成阅读」或者「读后感」
 */
def task_finishReading() {
    println "——————————————————————————————————————————————"
    println "执行结束阅读操作..."
    println "adb shell input tap 540 1730".execute().text         //模拟点击按钮完成阅读,这里以1920*1080的屏幕分辨率为准
    println "执行结束阅读操作完毕."
    println "——————————————————————————————————————————————"
}

/**
 * 清除文件目录下截图文件
 */
def task_clearDir(boolean clear = true, String rootPath = System.getProperty("user.dir") + "/screenshots/") {
    if (clear) {
        println '清除图片文件夹中...'
        new File(rootPath).deleteDir()
        println '清除完毕'
    } else {
        println '本次任务不清除screenshots文件夹下缓存图片文件,若要修改该配置,请将脚本文件中clearScreenShotCacheWhenFinishTask设置为true'
    }
}

有几点补充的:

  • 截图和保存截图功能我们依靠adb的命令实现。

adb shell screencap -p /sdcard/${millis} 是截图保存到手机;

adb pull /sdcard/${millis} $screenShotPath是将截图保存到自己的PC项目的指定目录下。

  • 结束阅读,自动点击屏幕下方按钮「完成阅读」

这个功能也不难,关键是获取该按钮的位置,通过Android设备自带的开发者选项,轻松获取到按钮的位置。

打开指针位置选项
手指放在按钮中间,上方显示坐标点

因为我的MI6分辨率是1920*1080,只需要确认Y值即可,约为1730左右,X轴自然是1080/2=540,因此模拟点击按钮的adb命令为:

adb shell input tap 540 1730

时间原因,没有做不同分辨率下不同机型的适配,而是写死了自己的机型1920*1080,以后有机会再补充其他主流的分辨率吧。

均值哈希实现图像内容相似度比较

脚本代码中,「图像内容相似度比较」的算法是很重要的一部分,对此我参考了@10km前辈的文章:java:均值哈希实现图像内容相似度比较,并将代码基本原封不动放入了项目中:

class FingerPrint {

    /**
     * 图像指纹的尺寸,将图像resize到指定的尺寸,来计算哈希数组
     */
    def static HASH_SIZE = 16

    /**
     * 保存图像指纹的二值化矩阵
     */
    private final byte[] binaryzationMatrix

    FingerPrint(byte[] hashValue) {
        if (hashValue.length != HASH_SIZE * HASH_SIZE)
            throw new IllegalArgumentException(String.format("length of hashValue must be %d", HASH_SIZE * HASH_SIZE))
        this.binaryzationMatrix = hashValue
    }

    FingerPrint(String hashValue) {
        this(toBytes(hashValue))
    }

    FingerPrint(BufferedImage src) {
        this(hashValue(src))
    }

    private static byte[] hashValue(BufferedImage src) {
        BufferedImage hashImage = resize(src, HASH_SIZE, HASH_SIZE)
        byte[] matrixGray = (byte[]) toGray(hashImage).getData().getDataElements(0, 0, HASH_SIZE, HASH_SIZE, null)
        return binaryzation(matrixGray)
    }
    /**
     * 从压缩格式指纹创建{@link FingerPrint}对象
     * @param compactValue
     * @return
     */
    static FingerPrint createFromCompact(byte[] compactValue) {
        return new FingerPrint(uncompact(compactValue))
    }

    static boolean validHashValue(byte[] hashValue) {
        if (hashValue.length != HASH_SIZE)
            return false
        for (byte b : hashValue) {
            if (0 != b && 1 != b) return false
        }
        return true
    }

    static boolean validHashValue(String hashValue) {
        if (hashValue.length() != HASH_SIZE)
            return false
        for (int i = 0; i < hashValue.length(); ++i) {
            if ('0' != hashValue.charAt(i) && '1' != hashValue.charAt(i)) return false
        }
        return true
    }

    byte[] compact() {
        return compact(binaryzationMatrix)
    }

    /**
     * 指纹数据按位压缩
     * @param hashValue
     * @return
     */
    static byte[] compact(byte[] hashValue) {
        byte[] result = new byte[(hashValue.length + 7) >> 3]
        byte b = 0
        for (int i = 0; i < hashValue.length; ++i) {
            if (0 == (i & 7)) {
                b = 0
            }
            if (1 == hashValue[i]) {
                b |= 1 << (i & 7)
            } else if (hashValue[i] != 0)
                throw new IllegalArgumentException("invalid hashValue,every element must be 0 or 1")
            if (7 == (i & 7) || i == hashValue.length - 1) {
                result[i >> 3] = b
            }
        }
        return result
    }

    /**
     * 压缩格式的指纹解压缩
     * @param compactValue
     * @return
     */
    private static byte[] uncompact(byte[] compactValue) {
        byte[] result = new byte[compactValue.length << 3]
        for (int i = 0; i < result.length; ++i) {
            if ((compactValue[i >> 3] & (1 << (i & 7))) == 0)
                result[i] = 0
            else
                result[i] = 1
        }
        return result
    }
    /**
     * 字符串类型的指纹数据转为字节数组
     * @param hashValue
     * @return
     */
    private static byte[] toBytes(String hashValue) {
        hashValue = hashValue.replaceAll("\\s", "")
        byte[] result = new byte[hashValue.length()]
        for (int i = 0; i < result.length; ++i) {
            char c = hashValue.charAt(i)
            if ('0' == c)
                result[i] = 0
            else if ('1' == c)
                result[i] = 1
            else
                throw new IllegalArgumentException("invalid hashValue String")
        }
        return result
    }
    /**
     * 缩放图像到指定尺寸
     * @param src
     * @param width
     * @param height
     * @return
     */
    private static BufferedImage resize(Image src, int width, int height) {
        BufferedImage result = new BufferedImage(width, height,
                BufferedImage.TYPE_3BYTE_BGR)
        Graphics g = result.getGraphics()
        try {
            g.drawImage(src.getScaledInstance(width, height, Image.SCALE_SMOOTH), 0, 0, null)
        } finally {
            g.dispose()
        }
        return result
    }
    /**
     * 计算均值
     * @param src
     * @return
     */
    private static int mean(byte[] src) {
        long sum = 0
        // 将数组元素转为无符号整数
        for (byte b : src) sum += (long) b & 0xff
        return (int) (Math.round((float) sum / src.length))
    }
    /**
     * 二值化处理
     * @param src
     * @return
     */
    private static byte[] binaryzation(byte[] src) {
        byte[] dst = src.clone()
        int mean = mean(src)
        for (int i = 0; i < dst.length; ++i) {
            // 将数组元素转为无符号整数再比较
            dst[i] = (byte) (((int) dst[i] & 0xff) >= mean ? 1 : 0)
        }
        return dst

    }
    /**
     * 转灰度图像
     * @param src
     * @return
     */
    private static BufferedImage toGray(BufferedImage src) {
        if (src.getType() == BufferedImage.TYPE_BYTE_GRAY) {
            return src
        } else {
            // 图像转灰
            BufferedImage grayImage = new BufferedImage(src.getWidth(), src.getHeight(),
                    BufferedImage.TYPE_BYTE_GRAY)
            new ColorConvertOp(ColorSpace.getInstance(ColorSpace.CS_GRAY), null).filter(src, grayImage)
            return grayImage
        }
    }

    @Override
    String toString() {
        return toString(true)
    }
    /**
     * @param multiLine 是否分行
     * @return
     */
    String toString(boolean multiLine) {
        StringBuffer buffer = new StringBuffer()
        int count = 0
        for (byte b : this.binaryzationMatrix) {
            buffer.append(0 == b ? '0' : '1')
            if (multiLine && ++count % HASH_SIZE == 0)
                buffer.append('\n')
        }
        return buffer.toString()
    }

    @Override
    boolean equals(Object obj) {
        if (obj instanceof FingerPrint) {
            return Arrays.equals(this.binaryzationMatrix, ((FingerPrint) obj).binaryzationMatrix)
        } else
            return super.equals(obj)
    }

    /**
     * 与指定的压缩格式指纹比较相似度
     * @param compactValue
     * @return
     * @see #compare(FingerPrint)
     */
    float compareCompact(byte[] compactValue) {
        return compare(createFromCompact(compactValue))
    }
    /**
     * @param hashValue
     * @return
     * @see #compare(FingerPrint)
     */
    float compare(String hashValue) {
        return compare(new FingerPrint(hashValue))
    }
    /**
     * 与指定的指纹比较相似度
     * @param hashValue
     * @return
     * @see #compare(FingerPrint)
     */
    float compare(byte[] hashValue) {
        return compare(new FingerPrint(hashValue))
    }
    /**
     * 与指定图像比较相似度
     * @param image2
     * @return
     * @see #compare(FingerPrint)
     */
    float compare(BufferedImage image2) {
        return compare(new FingerPrint(image2))
    }

    /**
     * 比较指纹相似度
     * @param src
     * @return
     * @see #compare(byte [ ], byte [ ])
     */
    float compare(FingerPrint src) {
        if (src.binaryzationMatrix.length != this.binaryzationMatrix.length)
            throw new IllegalArgumentException("length of hashValue is mismatch")
        return compare(binaryzationMatrix, src.binaryzationMatrix)
    }
    /**
     * 判断两个数组相似度,数组长度必须一致否则抛出异常
     * @param f1
     * @param f2
     * @return 返回相似度 ( 0.0 ~ 1.0 )
     */
    static float compare(byte[] f1, byte[] f2) {
        if (f1.length != f2.length)
            throw new IllegalArgumentException("mismatch FingerPrint length")
        int sameCount = 0
        for (int i = 0; i < f1.length; ++i) {
            if (f1[i] == f2[i]) ++sameCount
        }
        return (float) sameCount / f1.length
    }

    static float compareCompact(byte[] f1, byte[] f2) {
        return compare(uncompact(f1), uncompact(f2))
    }

    static float compare(BufferedImage image1, BufferedImage image2) {
        return new FingerPrint(image1).compare(new FingerPrint(image2))
    }
}

小结

写到这里,这篇文章基本就结束了,我把自己的代码也托管到了我的github上。

ShanbayAutoReader:扇贝英语阅读app,首页短文自动阅读脚本

其实这个脚本意义不是很大,写这个东西的动机也很简单:

1、不想自己被APP套路
2、巩固一下自己的groovy知识体系

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

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,143评论 25 707
  • 文|恬栀。 夏雨微凉,你可知,我一直都在等你。 小雨轻飘 薄凉的雨丝 勾勒出你浅笑的模样 我撑着伞 在熙攘的人群中...
    恬栀阅读 426评论 2 4
  • 我们之间, 也许就是擦肩而过。 我们互为知己, 陪伴彼此, 无数伤心难过的夜。 我们都在寻找, 我们都已找到。 也...
    1时依1阅读 409评论 0 3
  • http://m.motie.com/wechat/book/96607_2907058
    后来我来了阅读 196评论 0 0