动机

最近因为想要英语学习,特下载了「扇贝阅读」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上。

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

1、不想自己被APP套路

2、巩固一下自己的groovy知识体系

android 自动阅读新闻,Android 用Groovy实现扇贝阅读APP的自动阅读功能相关推荐

  1. android腾讯新闻,Android实现腾讯新闻的新闻类别导航效果

    Android实现腾讯新闻的新闻类别导航效果 发布时间:2020-09-13 02:50:05 来源:脚本之家 阅读:135 作者:地中海伯爵 效果图如下所示: 1.在Adapter中加入如下代码 p ...

  2. Android代码模拟物理、屏幕点击事件 、APP内部自动点击

    一.应用中模拟物理和屏幕点击事件 例如,模拟对某个view的点击事件 private void simulateClick(View view, float x, float y) {long dow ...

  3. android 仿网易新闻客户端源码都有

    原文:android 仿网易新闻客户端源码都有 android 仿网易新闻服务端源码 源代码下载地址: http://www.zuidaima.com/share/1550463560944640.h ...

  4. Android 自动点击工具,自动点击器app下载-自动点击工具 安卓版v1.0.2-PC6安卓网

    自动点击工具app是一款非常不错的系统工具类手机软件,有了自动点击工具app,你将从此释放双手进行抢购,还在等什么?下载自动点击工具app试试吧! 软件介绍 自动点击工具是一款记录手势和点击操作轨迹的 ...

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

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

  6. ANDROID 开发一个新闻阅读器之新闻列表

    1.          功能描述 这一讲中我们将对如何实现新闻列表做一个详细的介绍,新闻列表会把所有我们从网上获取的新闻的标题显示给用户,用户通过阅读标题,选择自己想要查看的新闻,进入具体的新闻显示页 ...

  7. 用eclipse阅读编辑android和kernel,uboot的源代码

    from: http://hi.baidu.com/designhouse/blog/item/ff3f0df4a33571f37709d736.html 1.  用eclipse阅读编辑androi ...

  8. android自动夜间模式,Android实现日夜间模式的深入理解

    在本篇文章中给出了三种实现日间/夜间模式切换的方案,三种方案综合起来可能导致文章的篇幅过长,请耐心阅读. 1.使用 setTheme的方法让 Activity重新设置主题: 2.设置 Android ...

  9. android list嵌套list,Android开发日常-listVIiew嵌套webView回显阅读位置

    详情页布局结构 需求是回显webview展示网页的阅读位置 方案1: 使用webview.getScrollY()获取滑动到的位置,用setScrollY()回显设置, 但是两个方法都出现了问题,ge ...

  10. java安卓怎么开发一个新闻app,一个基于Android系统的新闻客户端(一)

    一个基于Android系统的新闻客户端(一) 一.整体概述 在服务器端,通过对凤凰网的抓取存入数据库,客户端通过向服务器发送请求得到新闻. 服务端用WCF,宿主为window服务,客户端为Java写的 ...

最新文章

  1. object转字符串
  2. 前端学习(875):dom事件流理论
  3. python 两个数据框合并计算_一文掌握Excel、SQL、Python【数据合并】大法!
  4. 【23】蔡高厅老师 - 高等数学下阅读笔记 - 重积分 - 直角坐标系下(下)23 - 27
  5. python processpoolexector 释放内存_python之ThreadPoolExecutor
  6. 剑指offer 面试题03. 数组中重复的数字
  7. 每天一个linux命令cd,Linux指令每日背诵(第一天)
  8. python和java学哪个好-学python还是java python和java哪个好入门
  9. 使用JMH做Java微基准测试(二)Hello2020!
  10. Delphi开发经验谈
  11. 2018-01-03 烂尾工程: Java实现的汇编语言编译器
  12. android平板电脑维修电路图,图解Windows10平板电脑电路原理和维修
  13. UIFont 字体设置
  14. 高德地图 天气java_高德地图API获取天气
  15. 什么是软件的生命周期?
  16. Hadoop之MapReduce02【自定义wordcount案例】
  17. 湖北农商行计算机类笔试题,2019年湖北农商行笔试入门汇总提前知~
  18. 管道,Linux命令,Windows命令,cmd命令,tmux,vim,shell,bash,sh文件,bat文件
  19. 50 道 Python 基础练习题(附答案详解)
  20. System Generator从入门到放弃(十)-ADC应用之音频信号采集与输出

热门文章

  1. PHP实现域名whois查询的代码(数据源万网、新网)
  2. 魔术方法、5个类的魔术属性和单态
  3. 死亡测试 - GoogleTest(五)
  4. Edison 物联网:使用MRAA发挥平台输入输出能力
  5. DTCloud 报表格式
  6. mysql中删除两条重复的数据,只保留一条
  7. [SAS Hard Coding] 车型对应车商代码
  8. 毛笔字软件测试简历,写字测试
  9. 解决学术打不开的方法
  10. gmail更改个人信息_如何在不创建新电子邮件地址的情况下更改Gmail名称