├── README.md ├── analyui.py ├── demo01.png ├── demo02.png ├── demo03.png ├── demo04.png ├── demo05.png ├── demo06.png ├── easyadb.py ├── xuexi.exe └── xuexi.py /README.md: -------------------------------------------------------------------------------- 1 | # Xuexi-By-ADB 2 | 基于ADB的“学习强国”APP自动化脚本 3 | 4 | ## 为什么要使用ADB这种麻烦的方式? 5 | 使用爬虫或者用自动化框架操控浏览器实现,他不香吗? 6 | 当然香!但是也更容易被“学习强国”的后台技术监测到。 7 | 到时候账号被封,那可就相当不香了! 8 | 但是用ADB就不一样了,脚本只是在模拟人类操作手机,本身并未与“学习强国”的服务端产生任何联系; 9 | 实际与服务端交互的仍然是真实手机(或模拟器)上真实运行着的APP,服务端就很难察觉异常。 10 | 11 | ## 使用条件与方法 12 | 1.建议在Windows 10的CMD中运行; 13 | 2.确认安装了ADB驱动,以及Python3.7以上环境; 14 | 3.此项目仅含有三个文件,其中xuexi.py是主程序,直接运行即可。 15 | 16 | ## 参数说明 17 | 共两个参数,位置固定,皆可省略。 18 | 19 | ### 第一个参数为工作模式选择,分为下列几种。 20 | -a:全自动运行 21 | -d:列出当前连接的所有设备 22 | -t:手动启动应用,手动选择栏目,自动阅读文章 23 | -v:手动启动应用,手动选择栏目,自动播放视频 24 | 25 | -a全自动运行模式为:自动启动应用,根据程序内置的学习计划自动选择栏目,然后自动阅读文章和播放视频,直至达到每日积分上限为止。 26 | -d列出当前所连接设备的串号和ID(ADB自动分配),可用于之后的第二个参数。 27 | 28 | ### 第二个参数为设备的串号或ID,用于指定所操作的设备。 29 | 当只有一台设备连接的情况下可以省略,当有多台设备连接的情况下则必须指定,否则将会导致操作混乱。 30 | 利用该参数可以同时操作多台设备,但需要多开CMD窗口,可以手动,也可以另写脚本或批处理自动执行。 31 | 32 | ### 参数省略 33 | 1.省略第二个参数:默认操作当前唯一或第一个设备。 34 | 2.两个参数都省略:等同-a全自动模式。 35 | -------------------------------------------------------------------------------- /analyui.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- encoding: utf-8 -*- 3 | ''' 4 | @File : analyui.py 5 | @Time : 2021/01/11 6 | @Version : 1.0.1 7 | @Author : Triston Chow 8 | @Contact : triston2021@outlook.com 9 | @License : (C)Copyright 2020-2021, Triston Chow 10 | @Desc : 通过文本方式分析ADB dump文件,获取UI信息 11 | ''' 12 | 13 | 14 | import os 15 | from easyadb import EasyADB 16 | 17 | 18 | class AnalyUIText(EasyADB): 19 | def __init__(self, device_tag=''): 20 | super().__init__(device_tag) 21 | 22 | if device_tag == '': 23 | self.device_tag = 'dev' 24 | else: 25 | self.device_tag = device_tag 26 | 27 | self._dump_count = 0 28 | self._ui_lines = [] 29 | 30 | 31 | def gen_ui_lines(self) -> list: 32 | result = [] 33 | ''' 34 | dump设备当前的UI层级文件并复制到本地当前目录下 35 | 读取UI层级文件并提取文本显示组件,作为列表返回 36 | ''' 37 | if (device_path := self.uiDump()) != None: 38 | local_path = f'{self.cwd}/{self.device_tag}_ui_{self._dump_count:03d}.xml' 39 | self._dump_count += 1 40 | if (uifile := self.pullFile(device_path, local_path)) != None: 41 | with open(uifile, encoding='utf-8') as f: 42 | lines = f.read().split('>') 43 | result = [line for line in lines if 'class="android.widget.TextView"' in line] 44 | os.remove(uifile) 45 | return result 46 | 47 | 48 | def get_click_coords(self, line:str, point:str) -> tuple: 49 | ''' 50 | 从UI层级文件的一行文本中分离出元素界限坐标范围 51 | 再根据预定规则生成触摸点位置坐标值 52 | ''' 53 | bounds = line.split('bounds=')[-1].strip('"[]" /').replace('][', ',').split(',') 54 | x1, y1, x2, y2 = [int(b) for b in bounds] 55 | if point == 'start': 56 | return x1, y1 57 | elif point == 'end': 58 | return x2, y2 59 | elif point == 'center': 60 | return (x2-x1)//2 + x1, (y2-y1)//2 + y1 61 | elif point == 'article_top': 62 | return x2-100, y2+10 63 | elif point == 'article_bottom': 64 | return x2-100, y1-10 65 | 66 | 67 | def find_ui_text(self, keyword:str, getcoords=False, redump=True): 68 | ''' 69 | 逐行查找UI层级文件,匹配单个关键词,返回bool或坐标值 70 | ''' 71 | if redump: 72 | self._ui_lines = self.gen_ui_lines() 73 | 74 | for line in self._ui_lines: 75 | if keyword in line: 76 | if getcoords: 77 | return self.get_click_coords(line, 'center') 78 | else: 79 | return True 80 | 81 | if getcoords: 82 | print('当前页中找不到指定元素!\n') 83 | exit(0) 84 | else: 85 | return False 86 | 87 | 88 | def find_ui_multi_text(self, *keywords, getcoords=False): 89 | result = {} 90 | ''' 91 | 逐行查找UI层级文件,匹配多个关键词,返回包含bool和坐标值的字典 92 | ''' 93 | if (lines := self._ui_lines) != []: 94 | for i, keyword in enumerate(keywords) : 95 | found = False 96 | for line in lines: 97 | if keyword in line: 98 | found = True 99 | if getcoords: 100 | coords = self.get_click_coords(line, 'center') 101 | else: 102 | coords = () 103 | break 104 | if not found: 105 | coords = () 106 | result.update({i:{'found':found, 'coords':coords}}) 107 | return result 108 | -------------------------------------------------------------------------------- /demo01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Triston-Chow/Xuexi-By-ADB/faacbef6233b19b54df3a9959f3efe7d396cb438/demo01.png -------------------------------------------------------------------------------- /demo02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Triston-Chow/Xuexi-By-ADB/faacbef6233b19b54df3a9959f3efe7d396cb438/demo02.png -------------------------------------------------------------------------------- /demo03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Triston-Chow/Xuexi-By-ADB/faacbef6233b19b54df3a9959f3efe7d396cb438/demo03.png -------------------------------------------------------------------------------- /demo04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Triston-Chow/Xuexi-By-ADB/faacbef6233b19b54df3a9959f3efe7d396cb438/demo04.png -------------------------------------------------------------------------------- /demo05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Triston-Chow/Xuexi-By-ADB/faacbef6233b19b54df3a9959f3efe7d396cb438/demo05.png -------------------------------------------------------------------------------- /demo06.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Triston-Chow/Xuexi-By-ADB/faacbef6233b19b54df3a9959f3efe7d396cb438/demo06.png -------------------------------------------------------------------------------- /easyadb.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- encoding: utf-8 -*- 3 | ''' 4 | @File : easyadb.py 5 | @Time : 2020/03/30 6 | @Version : 1.0.0 7 | @Author : Triston Chow 8 | @Contact : triston2021@outlook.com 9 | @License : (C)Copyright 2020-2021, Triston Chow 10 | @Desc : 封装常用ADB指令,简化调用操作 11 | ''' 12 | 13 | 14 | import os 15 | import subprocess 16 | 17 | 18 | class EasyADB: 19 | def __init__(self, device_tag=''): 20 | if device_tag == '': 21 | self.adb = 'adb' 22 | else: 23 | if device_tag.isdecimal(): 24 | self.adb = f'adb -t {device_tag}' 25 | else: 26 | self.adb = f'adb -s {device_tag}' 27 | 28 | self.KEY = { 29 | 'power':26, 'menu':82, 'home':3, 'back':4, 30 | 'volume_up':24, 'volume_down':25, 'volume_mute':164, 31 | 'screen_on':224, 'screen_off':223 32 | } 33 | 34 | self.APP = { 35 | '抖音':'com.ss.android.ugc.aweme/.splash.SplashActivity', 36 | '抖音极速版':'com.ss.android.ugc.aweme.lite/com.ss.android.ugc.aweme.main.MainActivity', 37 | '快手极速版':'com.kuaishou.nebula/com.yxcorp.gifshow.HomeActivity', 38 | '学习强国':'cn.xuexi.android/com.alibaba.android.rimet.biz.SplashActivity' 39 | } 40 | 41 | self.cwd = os.path.abspath(os.path.dirname(__file__)).replace('\\', '/') 42 | 43 | os.system(f'{self.adb} start-server') 44 | 45 | 46 | def showDeviceInfo(self): 47 | os.system(f'{self.adb} devices -l') 48 | 49 | 50 | def showActivity(self): 51 | os.system(f'{self.adb} shell dumpsys activity activities | findstr mResumedActivity') 52 | 53 | 54 | def startAPP(self, appname:str) -> bool: 55 | # 此处使用subprocess.Popen是为了捕获错误信息 56 | with subprocess.Popen( 57 | f'{self.adb} shell am start -n {self.APP[appname]}', 58 | shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE 59 | ) as p: 60 | result = p.stdout.read() 61 | error = p.stderr.read() 62 | 63 | if error == b'': 64 | print(str(result, encoding='utf-8')) 65 | return True 66 | else: 67 | print(str(error, encoding='utf-8')) 68 | return False 69 | 70 | 71 | def touchScreen(self, x, y): 72 | os.system(f'{self.adb} shell input tap {x} {y}') 73 | 74 | 75 | def swipeScreen(self, start_x, start_y, end_x, end_y, duration = ''): 76 | os.system(f'{self.adb} shell input swipe {start_x} {start_y} {end_x} {end_y} {duration}') 77 | 78 | 79 | def pressKeyCode(self, keycode): 80 | os.system(f'{self.adb} shell input keyevent {keycode}') 81 | 82 | 83 | def longPressKeyCode(self, keycode): 84 | os.system(f'{self.adb} shell input keyevent --longpress {keycode}') 85 | 86 | 87 | def pressKey(self, keyname): 88 | os.system(f'{self.adb} shell input keyevent {self.KEY[keyname]}') 89 | 90 | 91 | def uiDump(self, device_path=''): 92 | # 此处使用subprocess.Popen是为了避免控制台打印错误信息 93 | with subprocess.Popen( 94 | f'{self.adb} shell uiautomator dump --compressed {device_path}', 95 | shell=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL 96 | ) as p: 97 | result = p.stdout.read() 98 | result = str(result, encoding='utf-8') 99 | 100 | if 'dumped to:' in result: 101 | return result.split()[-1] 102 | 103 | 104 | def pullFile(self, device_path, local_path=''): 105 | with subprocess.Popen( 106 | f'{self.adb} pull {device_path} {local_path}', 107 | shell=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL 108 | ) as p: 109 | result = p.stdout.read() 110 | result = str(result, encoding='utf-8') 111 | 112 | if 'pulled' in result: 113 | if local_path == '': 114 | local_path = f'{self.cwd}/{os.path.split(device_path)[-1]}' 115 | return local_path 116 | -------------------------------------------------------------------------------- /xuexi.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Triston-Chow/Xuexi-By-ADB/faacbef6233b19b54df3a9959f3efe7d396cb438/xuexi.exe -------------------------------------------------------------------------------- /xuexi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- encoding: utf-8 -*- 3 | ''' 4 | @File : xuexi.py 5 | @Time : 2021/01/11 6 | @Version : 1.0.1 7 | @Author : Triston Chow 8 | @Contact : triston2021@outlook.com 9 | @License : (C)Copyright 2020-2021, Triston Chow 10 | @Desc : 基于ADB的“学习强国”APP自动化脚本 11 | ''' 12 | 13 | 14 | import sys 15 | import time 16 | import re 17 | from analyui import AnalyUIText 18 | 19 | 20 | class XuexiByADB(AnalyUIText): 21 | def __init__(self, device_tag): 22 | super().__init__(device_tag) 23 | 24 | 25 | def select_topic(self, pattern:str, week_sn:int): 26 | week = ('星期一', '星期二', '星期三', '星期四', '星期五', '星期六', '星期日') 27 | topics = { 28 | 'article':((0,'重要活动'), (0,'重要会议'), (0,'重要讲话'), (0,'重要文章'), (1,'重要学习'), (1,'指示批示'), (4,'学习金句')), 29 | 'video':((0,'学习新视界'), (0,'奋斗新时代'), (0,'强军之路'), (0,'绿水青山'), (1,'一带一路'), (1,'初心使命'), (1,'强国建设')) 30 | } 31 | 32 | print(f'\r正在定位到学习栏目,请稍候...', end='') 33 | 34 | if pattern == 'article': 35 | x, y = self.find_ui_text('text="新思想"', getcoords=True) 36 | self.touchScreen(x, y) 37 | 38 | elif pattern == 'video': 39 | x, y = self.find_ui_text('text="电视台"', getcoords=True) 40 | self.touchScreen(x, y) 41 | 42 | x, y = self.find_ui_text('text="学习视频"', getcoords=True) 43 | self.touchScreen(x, y) 44 | 45 | times, topic = topics[pattern][week_sn] 46 | 47 | if week_sn > 3: 48 | x1, y1 = self.find_ui_text(f'text="{topics[pattern][0][1]}"', getcoords=True) 49 | x2, y2 = self.find_ui_text(f'text="{topics[pattern][3][1]}"', getcoords=True, redump=False) 50 | for i in range(times): 51 | self.swipeScreen(x2, y2, x1, y1, 600) 52 | 53 | x, y = self.find_ui_text(f'text="{topic}"', getcoords=True) 54 | self.touchScreen(x, y) 55 | 56 | print(f'\r今天是{week[week_sn]},按计划学习【{topic}】') 57 | 58 | 59 | def get_article_titles(self) -> list: 60 | title_lines = [line for line in self._ui_lines if 'general_card_title_id' in line] 61 | result = [] 62 | 63 | for line in title_lines: 64 | text = line.split('" ')[1].strip('text="') 65 | ''' 66 | 根据标题的位置(最上、最下、中间)设定点击坐标 67 | ''' 68 | if line == title_lines[0]: 69 | x, y = self.get_click_coords(line, 'article_top') 70 | elif line == title_lines[-1]: 71 | x, y = self.get_click_coords(line, 'article_bottom') 72 | else: 73 | x, y = self.get_click_coords(line, 'center') 74 | 75 | result.append({'text':text, 'coords':(x,y)}) 76 | return result 77 | 78 | 79 | def get_video_titles(self) -> list: 80 | usable_lines = [] # 第一遍过滤后得到的可用行列表 81 | result = [] # 返回结果列表 82 | title_flag = False # 标志前一行文本内容为时间长度“mm:ss”形式 83 | 84 | avoid_keywords = { 85 | '强国通', '百灵', '电视台', '电台', 86 | '积分', '第一频道', '学习视频', '联播频道', '看电视', '看理论', 87 | '中央广播电视总台', '新华社', '央视新闻', '央视网', '央视频', '人民日报', 88 | '学习新视界', '奋斗新时代', '强军之路', '绿水青山', '一带一路', '初心使命', '强国建设', 89 | } 90 | 91 | for line in self._ui_lines: 92 | if 'index="0"'in line or 'index="1"' in line: 93 | if 'text=' in line and 'text=""' not in line: 94 | usable_lines.append(line) 95 | 96 | for line in usable_lines: 97 | text = line.split('" ')[1].strip('text="') 98 | if text not in avoid_keywords and not text.isdecimal(): 99 | ''' 100 | 正则匹配文本内容是否为时间长度“mm:ss”形式,且范围从'0:00'至'23:59' 101 | 若匹配,则取文本内容和坐标值,并将操作下一行的标志变量置为True 102 | 若不匹配,根据标志变量状态添加记录 103 | ''' 104 | match_length = re.compile(r'^(([0-9]{1})|([0-1][0-9])|([1-2][0-3])):([0-5][0-9])$').search(text) 105 | if match_length != None: 106 | length = match_length.group() 107 | x, y = self.get_click_coords(line, 'start') 108 | title_flag = True 109 | else: 110 | if title_flag: 111 | result.append({'text':text, 'length':length, 'coords':(x,y)}) 112 | title_flag = False 113 | return result 114 | 115 | 116 | def xuexi(self, pattern:str): 117 | count = 0 # 已完成数 118 | total_time = 0 # 已完成总秒数 119 | completed = [] # 已完成的标题列表 120 | new_title = True # 主循环条件(执行开关) 121 | trigger_keywords = { 122 | 'name': {'article':'篇文章', 'video':'条视频'}, 123 | 'end': {'article':'text="观点"', 'video':'text="重新播放"', 'title':'text="你已经看到我的底线了"'}, 124 | 'maxlen': {'article': 40, 'video': 36}, 125 | 'passtime': {'article': 370, 'video': 390} 126 | } 127 | 128 | print('='*120) 129 | 130 | while new_title: 131 | if self.find_ui_text(trigger_keywords["end"]["title"]): 132 | new_title = False 133 | 134 | ''' 135 | 取当前页面所展示的文章或视频标题列表 136 | ''' 137 | if pattern == 'article': 138 | titles = self.get_article_titles() 139 | elif pattern == 'video': 140 | titles = self.get_video_titles() 141 | else: 142 | titles = [] 143 | 144 | if titles == []: 145 | print('当前页中找不到标题!') 146 | new_title = False 147 | else: 148 | ''' 149 | 遍历标题列表(for循环) 150 | 若当前标题未被点击过则点击进入 151 | 循环检测页面,出现标志结束的关键字时,返回原页面 152 | 检查是否满足每日积分上限条件,若满足则结束遍历(退出for循环) 153 | ''' 154 | for title in titles: 155 | if title['text'] not in completed: 156 | x, y = title['coords'] 157 | self.touchScreen(x, y) 158 | 159 | count += 1 160 | print(f'第{count}{trigger_keywords["name"][pattern]} => '.rjust(10), end='') 161 | if len(text := title['text']) > (maxlen := trigger_keywords["maxlen"][pattern]): 162 | text = text[:maxlen] + '...' 163 | 164 | if pattern == 'video': 165 | print(f'{text} ({title["length"]})') 166 | else: 167 | print(text) 168 | 169 | start_cputime = time.perf_counter() 170 | while True: 171 | if self.find_ui_text(trigger_keywords["end"][pattern]): 172 | break 173 | else: 174 | if pattern == 'article': 175 | self.swipeScreen(540, 1440, 540, 480, 2000) 176 | elif pattern == 'video': 177 | time.sleep(1) 178 | single_time = time.perf_counter()- start_cputime 179 | 180 | print(f'<第{count}{trigger_keywords["name"][pattern]}耗时:{single_time:6.2f}s>'.rjust(113, '-')) 181 | self.pressKey('back') 182 | 183 | completed.append(title['text']) 184 | total_time += single_time 185 | 186 | if count >= 6 and total_time >= trigger_keywords["passtime"][pattern]: 187 | new_title = False 188 | break 189 | time.sleep(1) 190 | ''' 191 | 若主循环未结束则上滑更新标题列表页面 192 | ''' 193 | if new_title: 194 | self.swipeScreen(540, 1400, 540, 520) 195 | 196 | print('='*120) 197 | print(f'累计耗时:{total_time/60:6.2f}m '.rjust(115)) 198 | 199 | 200 | def auto_xuexi(self): 201 | if self.startAPP('学习强国'): 202 | for i in range(12, 0, -1): 203 | print(f'\r正在启动应用,{i:02d}秒后开始学习。', end='') 204 | time.sleep(1) 205 | 206 | week_sn = time.localtime().tm_wday 207 | 208 | self.select_topic('article', week_sn) 209 | self.xuexi('article') 210 | 211 | print('\n') 212 | self.select_topic('video', week_sn) 213 | self.xuexi('video') 214 | 215 | 216 | def main(): 217 | try: 218 | arg1 = sys.argv[1] 219 | except IndexError: 220 | arg1 = '-a' 221 | 222 | try: 223 | arg2 = sys.argv[2] 224 | except IndexError: 225 | arg2 = '' 226 | 227 | tips =''' 228 | 参数错误! 229 | 230 | 第一个参数必须为下列开关选项: 231 | -a:全自动运行 232 | -d:列出当前连接的所有设备 233 | -t:手动启动应用,手动选择栏目,自动阅读文章 234 | -v:手动启动应用,手动选择栏目,自动播放视频 235 | ''' 236 | 237 | mode = { 238 | '-a':'xuexi.auto_xuexi()', 239 | '-d':'xuexi.showDeviceInfo()', 240 | '-t':'xuexi.xuexi("article")', 241 | '-v':'xuexi.xuexi("video")' 242 | } 243 | 244 | try: 245 | cmd = mode[arg1] 246 | except KeyError: 247 | print(tips) 248 | else: 249 | xuexi = XuexiByADB(arg2) 250 | exec(cmd) 251 | 252 | 253 | if __name__ =='__main__': 254 | main() 255 | --------------------------------------------------------------------------------