├── .gitignore ├── LICENSE ├── Makefile ├── README.md └── gen-devtools.py /.gitignore: -------------------------------------------------------------------------------- 1 | browser_protocol.json 2 | *.pyc 3 | devtools.py 4 | __pycache__ 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Shish 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: devtools.py 2 | 3 | browser_protocol.json: 4 | wget https://raw.githubusercontent.com/ChromeDevTools/devtools-protocol/master/json/browser_protocol.json 5 | 6 | devtools.py: gen-devtools.py browser_protocol.json 7 | python gen-devtools.py 8 | 9 | test: devtools.py 10 | python -m doctest gen-devtools.py 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # DevTools.py 3 | 4 | A set of automatically generated python bindings for the devtools protocol 5 | 6 | 7 | # Example 8 | 9 | Take a screenshot of a webpage 10 | ``` 11 | import base64, devtools 12 | 13 | c = devtools.Client("localhost:9222") 14 | c.page.navigate("http://www.shishnet.org") 15 | b64data = c.page.captureScreenshot()['data'] 16 | 17 | open('screenshot.png', 'w').write(base64.b64decode(b64data)) 18 | ``` 19 | 20 | 21 | # Dependencies 22 | 23 | ``` 24 | websocket-client 25 | ``` 26 | 27 | # Build 28 | 29 | ``` 30 | $ git clone https://github.com/shish/devtools-py 31 | $ make -C devtools-py 32 | ``` 33 | 34 | 35 | # Smoke Test 36 | 37 | This should open chrome, then connect to it, and open shishnet.org in the new browser 38 | 39 | ``` 40 | $ /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 --user-data-dir=~/.chrome-test 41 | $ python devtools-py/devtools.py 42 | ``` 43 | 44 | -------------------------------------------------------------------------------- /gen-devtools.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import json 4 | 5 | 6 | def paramToPython(p): 7 | """ 8 | >>> paramToPython(dict(name="foo")) 9 | 'foo' 10 | >>> paramToPython(dict(name="foo", optional=True)) 11 | 'foo=None' 12 | >>> paramToPython(dict(name="foo", type="integer")) 13 | 'foo: int' 14 | >>> paramToPython(dict(name="foo", type="integer", optional=True)) 15 | 'foo: int=None' 16 | """ 17 | name = p['name'] 18 | 19 | # Add type hint 20 | name += { 21 | "integer": ": int", 22 | "string": ": str", 23 | "number": ": float", 24 | }.get(p.get("type"), "") 25 | 26 | if p.get("optional"): 27 | name += "=None" 28 | 29 | return name 30 | 31 | 32 | def paramToDoc(p): 33 | """ 34 | >>> paramToDoc(dict(name="aParam", description="A parameter")) 35 | ':param aParam: A parameter' 36 | >>> paramToDoc(dict(name="aParam")) 37 | ':param aParam' 38 | """ 39 | return ":param {name}{spc}{doc}".format( 40 | name=p.get("name"), 41 | spc=": " if "description" in p else "", 42 | doc=p.get("description", "") 43 | ) 44 | 45 | 46 | def domainToAttrName(domain): 47 | """ 48 | >>> domainToAttrName("CSS") 49 | 'css' 50 | >>> domainToAttrName("DOMDebugger") 51 | 'domDebugger' 52 | >>> domainToAttrName("IndexedDB") 53 | 'indexedDB' 54 | """ 55 | ret = "" 56 | init = True 57 | for n, c in enumerate(domain): 58 | if n == 0: 59 | ret += c.lower() 60 | elif init and domain[n-1].isupper(): 61 | if n+1 < len(domain) and domain[n+1].islower(): 62 | ret += c 63 | else: 64 | ret += c.lower() 65 | if c.islower(): 66 | init = False 67 | else: 68 | ret += c 69 | return ret 70 | 71 | 72 | def genHeader(): 73 | """ 74 | >>> type(genHeader()) 75 | 76 | """ 77 | data = """#!/usr/bin/env python 78 | 79 | import json 80 | import websocket 81 | import logging 82 | 83 | log = logging.getLogger(__name__) 84 | 85 | 86 | class _DevToolsDomain(object): 87 | def __init__(self, instance): 88 | self.instance = instance 89 | """ 90 | return data 91 | 92 | 93 | def genDomain(d): 94 | """ 95 | >>> type(genDomain(dict( 96 | ... domain='Test', 97 | ... commands=[], 98 | ... ))) 99 | 100 | """ 101 | # Domain Header 102 | data = """ 103 | 104 | class _DevTools{domain}(_DevToolsDomain): 105 | def __init__(self, instance): 106 | _DevToolsDomain.__init__(self, instance) 107 | self.experimental = {experimental} 108 | """.format( 109 | domain=d['domain'], 110 | experimental=repr(d.get('experimental', False)) 111 | ) 112 | 113 | # Commands 114 | for c in d['commands']: 115 | params = c.get('parameters', []) 116 | fullDoc = ( 117 | '\n """' + 118 | "\n ".join( 119 | ["", c.get('description', '')] + 120 | [paramToDoc(p) for p in params] 121 | ) + 122 | '\n """' 123 | ) 124 | if fullDoc.strip('\n" ') == "": 125 | fullDoc = "" 126 | 127 | data += """ 128 | def {method}({args}):{fullDoc} 129 | return self.instance.call( 130 | "{domain}.{method}", 131 | dict({sendArgs}) 132 | ) 133 | """.format( 134 | domain=d['domain'], 135 | method=c['name'], 136 | args=", ".join(['self'] + [paramToPython(p) for p in params]), 137 | fullDoc=fullDoc, 138 | sendArgs=", ".join(["%s=%s" % (p['name'], p['name']) for p in params]), 139 | ) 140 | 141 | return data 142 | 143 | 144 | def genClient(js): 145 | """ 146 | >>> type(genClient(dict( 147 | ... version=dict(major="0", minor="1"), 148 | ... domains=[], 149 | ... ))) 150 | 151 | """ 152 | 153 | return """ 154 | 155 | def _urlopen(url): 156 | try: 157 | from urllib import urlopen 158 | except ImportError: 159 | from urllib.request import urlopen 160 | return urlopen(url).read().decode('utf8') 161 | 162 | 163 | class Client(object): 164 | def __init__(self, url, tab=-1): 165 | self._url = url # FIXME 166 | self._tabList = json.loads(_urlopen("http://localhost:9222/json")) 167 | self._tabs = [ 168 | websocket.create_connection( 169 | t['webSocketDebuggerUrl'] 170 | ) for t in self._tabList 171 | ] 172 | self._tab = tab 173 | self._id = 0 174 | self._extraEvents = [] 175 | if tab == -1: 176 | self.focus("") 177 | 178 | self.version = "{version}" 179 | 180 | {domains} 181 | 182 | def focus(self, tabName): 183 | for n, t in enumerate(self._tabList): 184 | if t['type'] == 'page' and tabName in t['title']: 185 | tab = n 186 | break 187 | else: 188 | print("Failed to focus on %s" % tabName) 189 | tab = 0 190 | self._tab = tab 191 | 192 | def call(self, method, args): 193 | for arg in list(args.keys()): 194 | if args[arg] is None: 195 | del args[arg] 196 | log.debug("send[%03d] %s(%r)" % (self._id, method, args)) 197 | self._tabs[self._tab].send(json.dumps({{ 198 | "id": self._id, 199 | "method": method, 200 | "params": args 201 | }})) 202 | 203 | retval = None 204 | while not retval: 205 | data = json.loads(self._tabs[self._tab].recv()) 206 | log.debug("recv[%03d] %s" % (data['id'], repr(data)[:200])) 207 | if data['id'] == self._id: 208 | retval = data 209 | else: 210 | self._extraEvents.append(data) 211 | 212 | self._id += 1 213 | if 'error' in retval: 214 | raise Exception(retval['error']) 215 | return retval['result'] 216 | 217 | 218 | def _cli(): 219 | c = Client("localhost:9222") 220 | 221 | for n, t in enumerate(c._tabList): 222 | print("Tab %d" % n) 223 | for k, v in t.items(): 224 | print("- %s: %s" % (k, v)) 225 | 226 | import code 227 | code.interact( 228 | local=c.__dict__, 229 | banner='''=== DevTools Interactive Mode === 230 | - Loaded modules: %s 231 | - have fun!''' % ", ".join([m for m in c.__dict__.keys() if m[0] != '_']) 232 | ) 233 | 234 | # c.page.navigate("http://www.shishnet.org") 235 | # data = c.page.captureScreenshot() 236 | # print(data['data'][:60]) 237 | # from base64 import b64decode 238 | # data = b64decode(data['data']) 239 | 240 | 241 | if __name__ == "__main__": 242 | _cli() 243 | """.format( 244 | version=js['version']['major'] + "." + js['version']['minor'], 245 | domains="\n ".join([ 246 | "self.%s = _DevTools%s(self)" % (domainToAttrName(d['domain']), d['domain']) 247 | for d 248 | in js['domains'] 249 | ]) 250 | ) 251 | 252 | 253 | def genFile(inFile, outFile): 254 | js = json.load(open(inFile)) 255 | 256 | data = genHeader() 257 | for d in js['domains']: 258 | data += genDomain(d) 259 | data += genClient(js) 260 | 261 | open(outFile, 'w').write(data) 262 | 263 | 264 | if __name__ == "__main__": 265 | genFile("browser_protocol.json", "devtools.py") 266 | --------------------------------------------------------------------------------