動機
最近因為想要英語學(xué)習(xí)航夺,特下載了「扇貝閱讀」App,保證自己抽空能夠提升一下自己的英語水平莺匠。這個App有一個功能闻丑,就是打卡功能挠锥,每天成功閱讀完兩篇英語短文众羡,就能完成每日打卡,并領(lǐng)取一些獎勵蓖租。
問題就出現(xiàn)在這里粱侣,因為這個App的設(shè)定是,如果天天都堅持打卡菜秦,那么你就能持續(xù)的獲得獎勵,這些獎勵可用來兌換付費的英語書舶掖。為了保證能夠最大化每日獎勵球昨,我就必須堅持閱讀打卡,平時這個設(shè)定沒啥問題眨攘,但是有時候(就是前兩天的五一放假)主慰,我可能一天都沒有時間閱讀,但是我又不想錯過每日的獎勵鲫售,該怎么辦呢共螺?
有朋友說了,你直接跳過閱讀情竹,點擊完成閱讀藐不,然后打卡可以嗎,這里就要說要扇貝閱讀APP的一個奇葩的設(shè)定了秦效,那就是:
直接快速滑到底部雏蛮,點擊完成閱讀,APP會檢測到你這個異常的操作阱州,然后提示你「請認(rèn)真閱讀」挑秉。
就是這樣:
不止是扇貝閱讀這個App,市面上其他一些主流英語閱讀App都有這種類似的設(shè)定,我想策劃是想告訴我們:
既然想要學(xué)習(xí)苔货,為何不好好認(rèn)真下來學(xué)習(xí)呢犀概?不要騙自己。
好好夜惭,你說的我都懂姻灶,可是我真的抽不出時間認(rèn)真閱讀2篇文章,又不想斷了自己的連續(xù)簽到獎勵怎么辦诈茧?
有朋友說了木蹬,既然他讓我們多花點時間閱讀,我們就擱在那里不動,過兩分鐘再點擊完成閱讀按鈕唄镊叁。
很遺憾尘颓,我進行了嘗試,然并卵晦譬,APP依舊冷漠無情疤苹,讓我「再認(rèn)真閱讀一遍」。
經(jīng)過嘗試敛腌,我發(fā)現(xiàn)卧土,APP的處理邏輯大概是這樣,確保用戶每一行文字都會被展示一段時間(就像我們正常閱讀的效果一樣)像樊,當(dāng)所有段落都經(jīng)過一段時間被「閱讀」后尤莺,才能正常「完成閱讀」生棍。
有朋友又說了颤霎,那我們就模擬閱讀,慢慢往下劃涂滴,讓文章被「慢速」友酱、「均勻」地拉到底部,怎么樣柔纵?
這就是筆者五一期間「作弊」式打卡的方式缔杉,事實證明完全可行,但是這種方式的弊端也很明顯搁料,一篇1.2百詞匯的文章或详,也許1分鐘就能拉到底,要是4.5百詞匯的文章郭计,就得花數(shù)分鐘來模擬「閱讀」操作鸭叙。
我不禁深深被APP這種奇葩的設(shè)定感動到了,這種完全是「防君子不防小人」的設(shè)定拣宏,究竟有什么意義沈贝?并且,隨著第二天勋乾,第三天這樣的操作過來宋下,我不禁無語了,這種毫無技術(shù)含量的作弊手段辑莫,可以稱得上既無聊又繁瑣学歧,讓我感覺自己是被APP強行交了一波智商稅。
那就寫個工具吧
基于上述事實各吨,我決定寫個工具枝笨,盡量代替雙手解決目前的窘境。
我選擇了Groovy作為開發(fā)語言,寫了一個腳本横浑,模擬用戶操作剔桨,緩慢閱讀文章,并自動點擊「完成閱讀」按鈕徙融。
先來看看腳本運行的效果洒缀,完全由ADB命令控制:
看一下命令行的輸出:
我的基本思路是這樣:
- 1树绩、通過adb命令模擬用戶向下翻頁的操作蛇摸;
- 2杜顺、每次模擬完翻頁操作后,將當(dāng)前屏幕截圖保存起宽;
- 3职车、然后將上次翻頁完成后的截圖和本次截圖進行圖像識別分析瘫俊,得到2張屏幕截圖的相似度;
- 4提鸟、當(dāng)2張屏幕截圖的相似度匹配不高時军援,視為兩張圖片不同仅淑,即應(yīng)該繼續(xù)向下翻頁称勋,并重復(fù)1~3的行為;
- 5涯竟、當(dāng)2張屏幕截圖的相似度匹配很高時赡鲜,視為該兩次操作達到了文章最底部(無法繼續(xù)下翻,所以截圖基本一樣)庐船,點擊「完成閱讀」按鈕银酬,并清除截圖緩存文件夾,結(jié)束本次腳本任務(wù)筐钟。
腳本代碼
前期是補充一些基本的屬性和配置:
final int actionInterval = 250 //兩次下翻操作的時間間隔揩瞪,單位毫秒
final float threshold = 0.95 //圖片分析相似度的閾值,當(dāng)相似度大于閾值時篓冲,視為圖片相同
println "已選中的Android設(shè)備:"
println "————————————————————————————————————"
println "adb devices".execute().text
println "————————————————————————————————————"
println "開始執(zhí)行自動閱讀"
//因為系統(tǒng)原因李破,很多情況下該命令實際的效果為對界面元素的長按,因此拋棄該命令
//println 'input swipe 540 1300 540 500 100 '
boolean clearScreenShotCacheWhenFinishTask = true //可選項壹将,當(dāng)腳本執(zhí)行結(jié)束時嗤攻,是否自動清除截圖緩存
def ending = false //是否已結(jié)束
def duration = 0 //本次操作已執(zhí)行時間
String rootPath = System.getProperty("user.dir") + "/screenshots/"
String lastScreenShot = null
String newScreenShot = null
本來想用 adb shell input swipe 命令模擬滑動操作,但是發(fā)現(xiàn)某些系統(tǒng)的設(shè)備不支持該命令诽俯,實現(xiàn)效果會變成「長按界面上某個元素」妇菱,而非我們想要的「滑動界面」操作,無奈,使用adb shell input keyevent 20命令代替闯团。
我們提供了幾個方法方便調(diào)用:
/**
* 為當(dāng)前的屏幕截圖辛臊,并保存在默認(rèn)路徑
*/
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
}
/**
* 為當(dāng)前app執(zhí)行向下翻頁操作
*/
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)
}
/**
* 結(jié)束閱讀,自動點擊屏幕下方按鈕「完成閱讀」或者「讀后感」
*/
def task_finishReading() {
println "——————————————————————————————————————————————"
println "執(zhí)行結(jié)束閱讀操作..."
println "adb shell input tap 540 1730".execute().text //模擬點擊按鈕完成閱讀偷俭,這里以1920*1080的屏幕分辨率為準(zhǔn)
println "執(zhí)行結(jié)束閱讀操作完畢."
println "——————————————————————————————————————————————"
}
/**
* 清除文件目錄下截圖文件
*/
def task_clearDir(boolean clear = true, String rootPath = System.getProperty("user.dir") + "/screenshots/") {
if (clear) {
println '清除圖片文件夾中...'
new File(rootPath).deleteDir()
println '清除完畢'
} else {
println '本次任務(wù)不清除screenshots文件夾下緩存圖片文件,若要修改該配置,請將腳本文件中clearScreenShotCacheWhenFinishTask設(shè)置為true'
}
}
有幾點補充的:
- 截圖和保存截圖功能我們依靠adb的命令實現(xiàn)浪讳。
adb shell screencap -p /sdcard/${millis} 是截圖保存到手機;
adb pull /sdcard/${millis} $screenShotPath是將截圖保存到自己的PC項目的指定目錄下涌萤。
- 結(jié)束閱讀淹遵,自動點擊屏幕下方按鈕「完成閱讀」
這個功能也不難,關(guān)鍵是獲取該按鈕的位置负溪,通過Android設(shè)備自帶的開發(fā)者選項透揣,輕松獲取到按鈕的位置。
因為我的MI6分辨率是1920*1080辐真,只需要確認(rèn)Y值即可,約為1730左右崖堤,X軸自然是1080/2=540侍咱,因此模擬點擊按鈕的adb命令為:
adb shell input tap 540 1730
時間原因,沒有做不同分辨率下不同機型的適配密幔,而是寫死了自己的機型1920*1080楔脯,以后有機會再補充其他主流的分辨率吧。
均值哈希實現(xiàn)圖像內(nèi)容相似度比較
腳本代碼中胯甩,「圖像內(nèi)容相似度比較」的算法是很重要的一部分昧廷,對此我參考了@10km前輩的文章:java:均值哈希實現(xiàn)圖像內(nèi)容相似度比較,并將代碼基本原封不動放入了項目中:
class FingerPrint {
/**
* 圖像指紋的尺寸,將圖像resize到指定的尺寸偎箫,來計算哈希數(shù)組
*/
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)
}
/**
* 從壓縮格式指紋創(chuàng)建{@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)
}
/**
* 指紋數(shù)據(jù)按位壓縮
* @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
}
/**
* 字符串類型的指紋數(shù)據(jù)轉(zhuǎn)為字節(jié)數(shù)組
* @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
// 將數(shù)組元素轉(zhuǎn)為無符號整數(shù)
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) {
// 將數(shù)組元素轉(zhuǎn)為無符號整數(shù)再比較
dst[i] = (byte) (((int) dst[i] & 0xff) >= mean ? 1 : 0)
}
return dst
}
/**
* 轉(zhuǎn)灰度圖像
* @param src
* @return
*/
private static BufferedImage toGray(BufferedImage src) {
if (src.getType() == BufferedImage.TYPE_BYTE_GRAY) {
return src
} else {
// 圖像轉(zhuǎn)灰
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)
}
/**
* 判斷兩個數(shù)組相似度木柬,數(shù)組長度必須一致否則拋出異常
* @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))
}
}
小結(jié)
寫到這里,這篇文章基本就結(jié)束了淹办,我把自己的代碼也托管到了我的github上眉枕。
ShanbayAutoReader:扇貝英語閱讀app,首頁短文自動閱讀腳本
其實這個腳本意義不是很大怜森,寫這個東西的動機也很簡單:
1速挑、不想自己被APP套路
2、鞏固一下自己的groovy知識體系