__init__.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. # coding: utf-8
  2. #
  3. import re
  4. import threading
  5. import time
  6. from logzero import logger
  7. from lxml import etree
  8. # import uiautomator2
  9. from ssat_sdk import uiautomator2
  10. from ssat_sdk.uiautomator2.exceptions import XPathElementNotFoundError
  11. from ssat_sdk.uiautomator2.utils import U
  12. def safe_xmlstr(s):
  13. return s.replace("$", "-")
  14. def init():
  15. uiautomator2.plugin_register("xpath", XPath)
  16. def string_quote(s):
  17. """ TODO(ssx): quick way to quote string """
  18. return '"' + s + '"'
  19. class TimeoutException(Exception):
  20. pass
  21. class XPathError(Exception):
  22. """ basic error for xpath plugin """
  23. class XPath(object):
  24. def __init__(self, d):
  25. """
  26. Args:
  27. d (uiautomator2 instance)
  28. """
  29. self._d = d
  30. self._watchers = [] # item: {"xpath": .., "callback": func}
  31. self._timeout = 10.0
  32. # used for click("#back") and back is the key
  33. self._alias = {}
  34. self._alias_strict = False
  35. def global_set(self, key, value): #dicts):
  36. valid_keys = {"timeout", "alias", "alias_strict"}
  37. if key not in valid_keys:
  38. raise ValueError("invalid key", key)
  39. setattr(self, "_" + key, value)
  40. # for k, v in dicts.items():
  41. # if k not in valid_keys:
  42. # raise ValueError("invalid key", k)
  43. # setattr(self, "_"+k, v)
  44. def implicitly_wait(self, timeout):
  45. """ set default timeout when click """
  46. self._timeout = timeout
  47. def dump_hierarchy(self):
  48. return self._d.dump_hierarchy()
  49. def send_click(self, x, y):
  50. self._d.click(x, y)
  51. def send_swipe(self, sx, sy, tx, ty):
  52. self._d.swipe(sx, sy, tx, ty)
  53. def match(self, xpath, source=None):
  54. return len(self(xpath, source).all()) > 0
  55. def when(self, xpath):
  56. obj = self
  57. def _click(selector):
  58. selector.get_last_match().click()
  59. class _Watcher():
  60. def click(self):
  61. obj._watchers.append({
  62. "xpath": xpath,
  63. "callback": _click,
  64. })
  65. def call(self, func):
  66. """
  67. Args:
  68. func: accept only one argument "selector"
  69. """
  70. obj._watchers.append({
  71. "xpath": xpath,
  72. "callback": func,
  73. })
  74. return _Watcher()
  75. def run_watchers(self, source=None):
  76. source = source or self.dump_hierarchy()
  77. for h in self._watchers:
  78. selector = self(h['xpath'], source)
  79. if selector.exists:
  80. logger.info("XPath(hook) %s", h['xpath'])
  81. h['callback'](selector)
  82. return True
  83. return False
  84. def watch_background(self):
  85. def _watch_forever():
  86. while 1:
  87. self.run_watchers()
  88. time.sleep(4)
  89. th = threading.Thread(target=_watch_forever)
  90. th.daemon = True
  91. th.start()
  92. def sleep_watch(self, seconds):
  93. """ run watchers when sleep """
  94. deadline = time.time() + seconds
  95. while time.time() < deadline:
  96. self.run_watchers()
  97. left_time = max(0, deadline - time.time())
  98. time.sleep(min(0.5, left_time))
  99. def click(self, xpath, source=None, watch=True, timeout=None):
  100. """
  101. Args:
  102. xpath (str): xpath string
  103. watch (bool): click popup elements
  104. timeout (float): pass
  105. Raises:
  106. TimeoutException
  107. """
  108. timeout = timeout or self._timeout
  109. logger.info("XPath(timeout %.1f) %s", timeout, xpath)
  110. deadline = time.time() + timeout
  111. while True:
  112. source = self.dump_hierarchy()
  113. if watch and self.run_watchers(source):
  114. time.sleep(.5) # post delay
  115. deadline = time.time() + timeout
  116. continue
  117. selector = self(xpath, source)
  118. if selector.exists:
  119. selector.get_last_match().click()
  120. time.sleep(.5) # post sleep
  121. return
  122. if time.time() > deadline:
  123. break
  124. time.sleep(.5)
  125. raise TimeoutException("timeout %.1f" % timeout)
  126. def __alias_get(self, key, default=None):
  127. """
  128. when alias_strict set, if key not in _alias, XPathError will be raised
  129. """
  130. value = self._alias.get(key, default)
  131. if value is None:
  132. if self._alias_strict:
  133. raise XPathError("alias have not found key", key)
  134. value = key
  135. return value
  136. def __call__(self, xpath, source=None):
  137. if xpath.startswith('//'):
  138. pass
  139. elif xpath.startswith('@'):
  140. xpath = '//*[@resource-id={}]'.format(string_quote(xpath[1:]))
  141. elif xpath.startswith('^'):
  142. xpath = '//*[re:match(text(), {})]'.format(string_quote(xpath))
  143. elif xpath.startswith("$"): # special for objects
  144. key = xpath[1:]
  145. return self(self.__alias_get(key), source)
  146. elif xpath.startswith('%') and xpath.endswith("%"):
  147. xpath = '//*[contains(@text, {})]'.format(string_quote(
  148. xpath[1:-1]))
  149. elif xpath.startswith('%'):
  150. xpath = '//*[starts-with(@text, {})]'.format(
  151. string_quote(xpath[1:]))
  152. elif xpath.endswith('%'):
  153. # //*[ends-with(@text, "suffix")] only valid in Xpath2.0
  154. xpath = '//*[ends-with(@text, {})]'.format(string_quote(
  155. xpath[:-1]))
  156. else:
  157. xpath = '//*[@text={0} or @content-desc={0}]'.format(
  158. string_quote(xpath))
  159. print("XPATH:", xpath)
  160. return XPathSelector(self, xpath, source)
  161. class XPathSelector(object):
  162. def __init__(self, parent, xpath, source=None):
  163. self._parent = parent
  164. self._d = parent._d
  165. self._xpath = xpath
  166. self._source = source
  167. self._last_source = None
  168. self._watchers = []
  169. @property
  170. def _global_timeout(self):
  171. return self._parent._timeout
  172. def all(self, source=None):
  173. """
  174. Returns:
  175. list of XMLElement
  176. """
  177. xml_content = source or self._source or self._parent.dump_hierarchy()
  178. self._last_source = xml_content
  179. root = etree.fromstring(xml_content.encode('utf-8'))
  180. for node in root.xpath("//node"):
  181. node.tag = safe_xmlstr(node.attrib.pop("class"))
  182. match_nodes = root.xpath(
  183. U(self._xpath),
  184. namespaces={"re": "http://exslt.org/regular-expressions"})
  185. return [XMLElement(node, self._parent) for node in match_nodes]
  186. @property
  187. def exists(self):
  188. return len(self.all()) > 0
  189. def get(self):
  190. """
  191. Get first matched element
  192. Returns:
  193. XMLElement
  194. Raises:
  195. XPathElementNotFoundError
  196. """
  197. if not self.wait(self._global_timeout):
  198. raise XPathElementNotFoundError(self._xpath)
  199. return self.get_last_match()
  200. def get_last_match(self):
  201. return self.all(self._last_source)[0]
  202. def get_text(self):
  203. """
  204. get element text
  205. Returns:
  206. string of node text
  207. Raises:
  208. XPathElementNotFoundError
  209. """
  210. return self.get().attrib.get("text", "")
  211. def wait(self, timeout=None):
  212. """
  213. Args:
  214. timeout (float): seconds
  215. Raises:
  216. None or XMLElement
  217. """
  218. deadline = time.time() + (timeout or self._global_timeout)
  219. while time.time() < deadline:
  220. if self.exists:
  221. return self.get_last_match()
  222. time.sleep(.2)
  223. return None
  224. def click_nowait(self):
  225. x, y = self.all()[0].center()
  226. logger.info("click %d, %d", x, y)
  227. self._parent.send_click(x, y)
  228. def click(self, watch=True, timeout=None):
  229. """
  230. Args:
  231. watch (bool): click popup element before real operation
  232. timeout (float): max wait timeout
  233. """
  234. self._parent.click(self._xpath, watch=watch, timeout=timeout)
  235. class XMLElement(object):
  236. def __init__(self, elem, parent):
  237. """
  238. Args:
  239. elem: lxml node
  240. d: uiautomator2 instance
  241. """
  242. self.elem = elem
  243. self._parent = parent
  244. def center(self):
  245. return self.offset(0.5, 0.5)
  246. def offset(self, px, py):
  247. """
  248. Offset from left_top
  249. Args:
  250. px (float): percent of width
  251. py (float): percent of height
  252. Example:
  253. offset(0.5, 0.5) means center
  254. """
  255. x, y, width, height = self.rect
  256. return x + int(width * px), y + int(height * py)
  257. def click(self):
  258. """
  259. click element
  260. """
  261. x, y = self.center()
  262. self._parent.send_click(x, y)
  263. @property
  264. def rect(self):
  265. """
  266. Returns:
  267. (left_top_x, left_top_y, width, height)
  268. """
  269. bounds = self.elem.attrib.get("bounds")
  270. lx, ly, rx, ry = map(int, re.findall(r"\d+", bounds))
  271. return lx, ly, rx - lx, ry - ly
  272. @property
  273. def text(self):
  274. return self.elem.attrib.get("text")
  275. @property
  276. def attrib(self):
  277. return self.elem.attrib
  278. # class Exists(object):
  279. # """Exists object with magic methods."""
  280. # def __init__(self, selector):
  281. # self.selector = selector
  282. # def __nonzero__(self):
  283. # """Magic method for bool(self) python2 """
  284. # return len(self.selector.all()) > 0
  285. # def __bool__(self):
  286. # """ Magic method for bool(self) python3 """
  287. # return self.__nonzero__()
  288. # def __call__(self, timeout=0):
  289. # """Magic method for self(args).
  290. # Args:
  291. # timeout (float): exists in seconds
  292. # """
  293. # if timeout:
  294. # return self.uiobject.wait(timeout=timeout)
  295. # return bool(self)
  296. # def __repr__(self):
  297. # return str(bool(self))
  298. if __name__ == "__main__":
  299. init()
  300. import uiautomator2.ext.htmlreport as htmlreport
  301. d = uiautomator2.connect()
  302. hrp = htmlreport.HTMLReport(d)
  303. # take screenshot before each click
  304. hrp.patch_click()
  305. d.app_start("com.netease.cloudmusic", stop=True)
  306. # watchers
  307. d.ext_xpath.when("跳过").click()
  308. # d.ext_xpath.when("知道了").click()
  309. # steps
  310. d.ext_xpath("//*[@text='私人FM']/../android.widget.ImageView").click()
  311. d.ext_xpath("下一首").click()
  312. d.ext_xpath.sleep_watch(2)
  313. d.ext_xpath("转到上一层级").click()