Android 用Groovy實現(xiàn)扇貝閱讀APP的自動閱讀功能

動機

最近因為想要英語學(xué)習(xí)航夺,特下載了「扇貝閱讀」App,保證自己抽空能夠提升一下自己的英語水平莺匠。這個App有一個功能闻丑,就是打卡功能挠锥,每天成功閱讀完兩篇英語短文众羡,就能完成每日打卡,并領(lǐng)取一些獎勵蓖租。

問題就出現(xiàn)在這里粱侣,因為這個App的設(shè)定是,如果天天都堅持打卡菜秦,那么你就能持續(xù)的獲得獎勵,這些獎勵可用來兌換付費的英語書舶掖。為了保證能夠最大化每日獎勵球昨,我就必須堅持閱讀打卡,平時這個設(shè)定沒啥問題眨攘,但是有時候(就是前兩天的五一放假)主慰,我可能一天都沒有時間閱讀,但是我又不想錯過每日的獎勵鲫售,該怎么辦呢共螺?

有朋友說了,你直接跳過閱讀情竹,點擊完成閱讀藐不,然后打卡可以嗎,這里就要說要扇貝閱讀APP的一個奇葩的設(shè)定了秦效,那就是:

直接快速滑到底部雏蛮,點擊完成閱讀,APP會檢測到你這個異常的操作阱州,然后提示你「請認(rèn)真閱讀」挑秉。

就是這樣:

error1.gif

不止是扇貝閱讀這個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命令控制:

因為gif圖的原因,看起來很快欺冀,實際上ADB是控制屏幕緩慢地勻速下拉

看一下命令行的輸出:

開始閱讀
結(jié)束閱讀

我的基本思路是這樣:

  • 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ā)者選項透揣,輕松獲取到按鈕的位置。

打開指針位置選項
手指放在按鈕中間川抡,上方顯示坐標(biāo)點

因為我的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知識體系

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末塔插,一起剝皮案震驚了整個濱河市梗摇,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌想许,老刑警劉巖伶授,帶你破解...
    沈念sama閱讀 206,602評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件断序,死亡現(xiàn)場離奇詭異,居然都是意外死亡糜烹,警方通過查閱死者的電腦和手機违诗,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,442評論 2 382
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來疮蹦,“玉大人诸迟,你說我怎么就攤上這事°岛酰” “怎么了阵苇?”我有些...
    開封第一講書人閱讀 152,878評論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長感论。 經(jīng)常有香客問我绅项,道長,這世上最難降的妖魔是什么比肄? 我笑而不...
    開封第一講書人閱讀 55,306評論 1 279
  • 正文 為了忘掉前任快耿,我火速辦了婚禮,結(jié)果婚禮上芳绩,老公的妹妹穿的比我還像新娘掀亥。我一直安慰自己,他們只是感情好妥色,可當(dāng)我...
    茶點故事閱讀 64,330評論 5 373
  • 文/花漫 我一把揭開白布搪花。 她就那樣靜靜地躺著,像睡著了一般垛膝。 火紅的嫁衣襯著肌膚如雪鳍侣。 梳的紋絲不亂的頭發(fā)上丁稀,一...
    開封第一講書人閱讀 49,071評論 1 285
  • 那天吼拥,我揣著相機與錄音,去河邊找鬼线衫。 笑死凿可,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的授账。 我是一名探鬼主播枯跑,決...
    沈念sama閱讀 38,382評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼白热!你這毒婦竟也來了敛助?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,006評論 0 259
  • 序言:老撾萬榮一對情侶失蹤屋确,失蹤者是張志新(化名)和其女友劉穎纳击,沒想到半個月后续扔,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,512評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡焕数,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,965評論 2 325
  • 正文 我和宋清朗相戀三年纱昧,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片堡赔。...
    茶點故事閱讀 38,094評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡识脆,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出善已,到底是詐尸還是另有隱情灼捂,我是刑警寧澤,帶...
    沈念sama閱讀 33,732評論 4 323
  • 正文 年R本政府宣布换团,位于F島的核電站纵东,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏啥寇。R本人自食惡果不足惜偎球,卻給世界環(huán)境...
    茶點故事閱讀 39,283評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望辑甜。 院中可真熱鬧衰絮,春花似錦、人聲如沸磷醋。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,286評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽邓线。三九已至淌友,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間骇陈,已是汗流浹背震庭。 一陣腳步聲響...
    開封第一講書人閱讀 31,512評論 1 262
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留你雌,地道東北人器联。 一個月前我還...
    沈念sama閱讀 45,536評論 2 354
  • 正文 我出身青樓,卻偏偏與公主長得像婿崭,于是被迫代替她去往敵國和親拨拓。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 42,828評論 2 345

推薦閱讀更多精彩內(nèi)容

  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 171,512評論 25 707
  • 文|恬梔氓栈。 夏雨微涼渣磷,你可知,我一直都在等你授瘦。 小雨輕飄 薄涼的雨絲 勾勒出你淺笑的模樣 我撐著傘 在熙攘的人群中...
    恬梔閱讀 434評論 2 4
  • 我們之間醋界, 也許就是擦肩而過祟身。 我們互為知己, 陪伴彼此物独, 無數(shù)傷心難過的夜袜硫。 我們都在尋找, 我們都已找到挡篓。 也...
    1時依1閱讀 415評論 0 3
  • http://m.motie.com/wechat/book/96607_2907058
    后來我來了閱讀 197評論 0 0