├── .gitignore ├── .gitmodules ├── .travis.yml ├── LICENSE.txt ├── README.md ├── build-standalone.sh ├── devtools ├── __init__.py ├── chrome-wrapper.sh └── proxy.py ├── examples ├── Gemfile ├── heap_snapshot.py ├── remote_inspect.py ├── requirements.txt ├── throttling.py └── throttling.rb ├── requirements-build.txt ├── requirements-dev.txt ├── requirements.txt ├── run-tests.sh ├── setup.py └── tests ├── __init__.py ├── compatibility ├── conftest.py.patch ├── getAttribute.js └── isDisplayed.js ├── functional ├── __init__.py └── test_status.py ├── integration ├── __init__.py └── test_basic.py └── utils └── __init__.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .cache 3 | .debug 4 | 5 | __pycache__/ 6 | *.py[cod] 7 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "tests/compatibility/selenium"] 2 | path = tests/compatibility/selenium 3 | url = https://github.com/SeleniumHQ/selenium.git 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty 2 | group: edge 3 | 4 | sudo: false 5 | 6 | language: python 7 | python: 8 | - "3.6.3" 9 | 10 | cache: pip 11 | 12 | git: 13 | submodules: false 14 | 15 | addons: 16 | apt: 17 | sources: 18 | - google-chrome 19 | packages: 20 | - google-chrome-stable 21 | 22 | env: 23 | global: 24 | - CHROMEDRIVER_VERSION=2.35 25 | - DISPLAY=:99.0 26 | matrix: 27 | - TESTS=functional 28 | - TESTS=functional CHROME_WRAPPER_PATH=devtools/chrome-wrapper.sh DEVTOOLS_PROXY_PATH=dist/linux/devtools-proxy 29 | - TESTS=integration 30 | - TESTS=integration CHROME_WRAPPER_PATH=devtools/chrome-wrapper.sh DEVTOOLS_PROXY_PATH=dist/linux/devtools-proxy 31 | - TESTS=compatibility 32 | - TESTS=compatibility CHROME_WRAPPER_PATH=devtools/chrome-wrapper.sh DEVTOOLS_PROXY_PATH=dist/linux/devtools-proxy 33 | - TESTS=compatibility DTP_UJSON=true 34 | - TESTS=compatibility DTP_UVLOOP=true 35 | 36 | matrix: 37 | fast_finish: true 38 | allow_failures: 39 | - env: TESTS=compatibility 40 | - env: TESTS=compatibility CHROME_WRAPPER_PATH=devtools/chrome-wrapper.sh DEVTOOLS_PROXY_PATH=dist/linux/devtools-proxy 41 | - env: TESTS=compatibility DTP_UJSON=true 42 | - env: TESTS=compatibility DTP_UVLOOP=true 43 | 44 | before_install: 45 | - | 46 | if [[ ${TESTS} != "functional" ]]; then 47 | curl -L -O "http://chromedriver.storage.googleapis.com/${CHROMEDRIVER_VERSION}/chromedriver_linux64.zip" 48 | unzip -o chromedriver_linux64.zip -d /tmp/chromedriver/ && chmod +x /tmp/chromedriver/chromedriver 49 | fi 50 | 51 | install: 52 | - pip install -Ur requirements-dev.txt 53 | 54 | before_script: 55 | - if [[ ${TESTS} != "functional" ]]; then PATH=$PATH:/tmp/chromedriver/; fi 56 | - if [[ ${DEVTOOLS_PROXY_PATH} == "dist/linux/devtools-proxy" ]]; then ./build-standalone.sh; fi 57 | - if [[ ${TESTS} == "compatibility" ]]; then git submodule update --init --recursive; fi 58 | - sh -e /etc/init.d/xvfb start 59 | 60 | script: 61 | - ./run-tests.sh ${TESTS} 62 | 63 | notifications: 64 | email: false 65 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016-present Alexander Bayandin 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DevTools Proxy 2 | 3 | [![Build Status](https://travis-ci.org/bayandin/devtools-proxy.svg?branch=master)](https://travis-ci.org/bayandin/devtools-proxy) 4 | [![PyPI](https://img.shields.io/pypi/v/devtools-proxy.svg)](https://pypi.python.org/pypi/devtools-proxy) 5 | [![GitHub release](https://img.shields.io/github/release/bayandin/devtools-proxy.svg)](https://github.com/bayandin/devtools-proxy/releases/latest) 6 | 7 | DevTools Proxy is a tool for creating simultaneous connections via DevTools Protocol (~~[which is not possible by default](https://developer.chrome.com/devtools/docs/debugger-protocol#simultaneous)~~ and it is [possible](https://developers.google.com/web/updates/2017/10/devtools-release-notes#multi-client) since Chrome 63 even without DevTools Proxy). 8 | 9 | ## How it works 10 | 11 | ``` 12 | +---+ +---+ 13 | | C | | | 14 | | L | | D | +-----------+ 15 | | I | | E | | | 16 | | E |<---->| V | | BROWSER | 17 | | N | | T | | | 18 | | T | | O | | | 19 | +---+ | O | | +---+ | 20 | | L | | | T | | 21 | | S |<-----> | A | | 22 | +---+ | | | | B | | 23 | | C | | P | | +---+ | 24 | | L | | R | | | 25 | | I |<---->| O | | | 26 | | E | | X | | | 27 | | N | | Y | +-----------+ 28 | | T | | | 29 | +---+ +---+ 30 | ``` 31 | 32 | ## Installation 33 | 34 | * Download & unzip [standalone binary](https://github.com/bayandin/devtools-proxy/releases/latest) for your system. 35 | * If you use Python (at least 3.6) you can install it via pip: `pip install devtools-proxy` 36 | 37 | ## Usage 38 | 39 | ### With Selenium and ChromeDriver 40 | 41 | There are [examples](examples/) for Python and Ruby. Demos for [CPU Throttling](https://youtu.be/NU46EkrRoYo), [Network requests](https://youtu.be/JDtuXAptypY) and [Remote debugging](https://youtu.be/X-dL_eKB1VE). 42 | 43 | #### Standalone (for any language) 44 | 45 | * Configure [`ChromeOptions`](https://sites.google.com/a/chromium.org/chromedriver/capabilities#TOC-chromeOptions-object): 46 | * Set path to `chrome-wrapper.sh` as a `binary`. Optional arguments are mentioned in example for Python below 47 | * Add `--devtools-proxy-binary=/path/to/devtools-proxy` to `args` 48 | 49 | #### Python 50 | 51 | `devtools-proxy` pypi package supports at least Python 3.6. If you use lower Python version use Standalone package. 52 | 53 | ```bash 54 | pip install -U devtools-proxy 55 | ``` 56 | 57 | ```python 58 | from selenium.webdriver.common.desired_capabilities import DesiredCapabilities 59 | 60 | from devtools.proxy import CHROME_WRAPPER_PATH 61 | 62 | devtools_proxy_binary = 'devtools-proxy' # Or path to `devtools-proxy` from downloaded binaries 63 | 64 | capabilities = DesiredCapabilities.CHROME.copy() 65 | capabilities['chromeOptions'] = { 66 | 'binary': CHROME_WRAPPER_PATH, # Or path to `chrome-wrapper.sh` from downloaded binaries 67 | 'args': [ 68 | f'--devtools-proxy-binary={devtools_proxy_binary}', 69 | # Optional arguments: 70 | # '--chrome-binary=/path/to/chrome/binary', # Path to real Chrome/Chromium binary 71 | # '--devtools-proxy-chrome-debugging-port=some-free-port', # Port which proxy will listen. Default is 12222 72 | # '--devtools-proxy-args=--additional --devtools-proxy --arguments, # Additional arguments for devtools-proxy from `devtools-proxy --help` 73 | ], 74 | } 75 | ``` 76 | 77 | ### With multiple Devtools instances 78 | 79 | * Run `devtools-proxy` (by default it started on 9222 port) 80 | * Run Chrome with parameters `--remote-debugging-port=12222 --remote-debugging-address=127.0.0.1` 81 | * Open a website which you want to inspect 82 | * Open debugger in a new Chrome tab: `http://localhost:9222` and choose your website to inspect 83 | * Repeat the previous step as many times as you need it 84 | -------------------------------------------------------------------------------- /build-standalone.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Building standalone binary for Linux on macOS: 4 | # docker run --volume $(pwd):/build --workdir /build python:3.6.3 /build/build-standalone.sh 5 | 6 | readonly PROJECT_DIR="$(dirname "$(readlink -f "$0")")" 7 | readonly PLATFORM=$(python3 -c "import sys; print(sys.platform)") 8 | readonly VERSION=$(python3 -c "from devtools import __version__; print(__version__)") 9 | 10 | pip3 install -U pip 11 | pip3 install -Ur requirements-build.txt 12 | pyinstaller --name devtools-proxy --clean --onefile --distpath ${PROJECT_DIR}/dist/${PLATFORM} ${PROJECT_DIR}/devtools/proxy.py 13 | 14 | tar -zcvf ${PROJECT_DIR}/dist/devtools-proxy-${PLATFORM}-${VERSION}.tgz \ 15 | -C ${PROJECT_DIR}/dist/${PLATFORM} devtools-proxy \ 16 | -C ${PROJECT_DIR}/devtools chrome-wrapper.sh 17 | -------------------------------------------------------------------------------- /devtools/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.2.0-dev' 2 | VERSION = __version__ 3 | -------------------------------------------------------------------------------- /devtools/chrome-wrapper.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | function find_binary { 4 | local KERNEL_NAME=$(uname --kernel-name) 5 | local FILE_PATH 6 | 7 | declare -a FILES 8 | declare -a PATHS 9 | 10 | if [ ${KERNEL_NAME} == 'Linux' ]; then 11 | # https://chromium.googlesource.com/chromium/src/+/2729e442b1172c5094503a03fe356c8580bb919d/chrome/test/chromedriver/chrome/chrome_finder.cc 12 | FILES=(google-chrome chrome chromium chromium-browser) 13 | PATHS=(/opt/google/chrome /usr/local/bin /usr/local/sbin /usr/bin /usr/sbin /bin /sbin) 14 | elif [ ${KERNEL_NAME} == 'Darwin' ]; then 15 | FILES=( 16 | Google\ Chrome.app/Contents/MacOS/Google\ Chrome 17 | Chromium.app/Contents/MacOS/Chromium 18 | ) 19 | PATHS=(/Applications) 20 | else 21 | echo "Unknown or unsupported OS: '${KERNEL_NAME}'" >&2 22 | exit 1 23 | fi 24 | 25 | for file in "${FILES[@]}"; do 26 | for path in "${PATHS[@]}"; do 27 | FILE_PATH="${path}/${file}" 28 | if [ -e "${FILE_PATH}" ]; then 29 | echo -n "${FILE_PATH}" 30 | return 31 | fi 32 | done 33 | done 34 | } 35 | 36 | function watch_dog { 37 | local CHROME_PID=$1 38 | local PROXY_PID=$2 39 | while true; do 40 | if ! kill -0 ${CHROME_PID}; then 41 | kill ${PROXY_PID} 42 | break 43 | fi 44 | sleep 1 45 | done 46 | } 47 | 48 | PROXY_DEBUGGING_PORT_RE="--remote-debugging-port=([[:digit:]]+)" 49 | DEV_TOOLS_PROXY_BINARY_RE="--devtools-proxy-binary=(.+)" 50 | 51 | CHROME_BINARY="" 52 | CHROME_BINARY_RE="--chrome-binary=(.+)" 53 | 54 | CHROME_DEBUGGING_PORT=12222 55 | CHROME_DEBUGGING_PORT_RE="--devtools-proxy-chrome-debugging-port=([[:digit:]]+)" 56 | 57 | DEV_TOOLS_PROXY_ARGS="" 58 | DEV_TOOLS_PROXY_ARGS_RE="--devtools-proxy-args=(.+)" 59 | 60 | declare -a CLI_PARAMS=("$@") 61 | 62 | for i in ${!CLI_PARAMS[@]}; do 63 | VALUE=${CLI_PARAMS[$i]} 64 | if [[ ${VALUE} =~ ${PROXY_DEBUGGING_PORT_RE} ]]; then 65 | PROXY_DEBUGGING_PORT=${BASH_REMATCH[1]} 66 | PROXY_DEBUGGING_PORT_IDX=${i} 67 | elif [[ ${VALUE} =~ ${DEV_TOOLS_PROXY_BINARY_RE} ]]; then 68 | DEV_TOOLS_PROXY_BINARY=${BASH_REMATCH[1]} 69 | unset CLI_PARAMS[${i}] 70 | elif [[ ${VALUE} =~ ${CHROME_BINARY_RE} ]]; then 71 | CHROME_BINARY=${BASH_REMATCH[1]} 72 | unset CLI_PARAMS[${i}] 73 | elif [[ ${VALUE} =~ ${CHROME_DEBUGGING_PORT_RE} ]]; then 74 | CHROME_DEBUGGING_PORT=${BASH_REMATCH[1]} 75 | unset CLI_PARAMS[${i}] 76 | elif [[ ${VALUE} =~ ${DEV_TOOLS_PROXY_ARGS_RE} ]]; then 77 | DEV_TOOLS_PROXY_ARGS=${BASH_REMATCH[1]} 78 | unset CLI_PARAMS[${i}] 79 | fi 80 | done 81 | 82 | if [ -n "$DEV_TOOLS_PROXY_BINARY" ]; then 83 | CLI_PARAMS[$PROXY_DEBUGGING_PORT_IDX]="--remote-debugging-port=${CHROME_DEBUGGING_PORT}" 84 | 85 | ${DEV_TOOLS_PROXY_BINARY} --port ${PROXY_DEBUGGING_PORT} --chrome-port ${CHROME_DEBUGGING_PORT} ${DEV_TOOLS_PROXY_ARGS} > /dev/null 2>&1 & 86 | PROXY_PID=$! 87 | CHROME_PID=$$ 88 | ( > /dev/null 2>&1 < /dev/null watch_dog ${CHROME_PID} ${PROXY_PID} & ) & 89 | fi 90 | 91 | CHROME_BINARY=${CHROME_BINARY:-$(find_binary)} 92 | exec -a "$0" "${CHROME_BINARY}" "${CLI_PARAMS[@]}" 93 | -------------------------------------------------------------------------------- /devtools/proxy.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import asyncio 5 | import math 6 | import os 7 | import sys 8 | import traceback as tb 9 | import warnings 10 | from pathlib import Path 11 | 12 | import aiohttp 13 | from aiohttp.web import Application, HTTPBadGateway, Response, WebSocketResponse, WSMsgType, hdrs, json_response 14 | 15 | from devtools import VERSION 16 | 17 | WITH_UJSON = os.environ.get('DTP_UJSON', '').lower() == 'true' 18 | if WITH_UJSON: 19 | import ujson as json 20 | else: 21 | import json 22 | 23 | WITH_UVLOOP = os.environ.get('DTP_UVLOOP', '').lower() == 'true' 24 | if WITH_UVLOOP: 25 | import uvloop 26 | 27 | asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) 28 | 29 | # https://pythonhosted.org/PyInstaller/runtime-information.html 30 | if not getattr(sys, 'frozen', False): 31 | CHROME_WRAPPER_PATH = str(Path(__file__, '../chrome-wrapper.sh').resolve()) 32 | 33 | 34 | async def the_handler(request): 35 | response = WebSocketResponse() 36 | 37 | handler = ws_handler if response.can_prepare(request) else proxy_handler 38 | return await handler(request) 39 | 40 | 41 | async def ws_handler(request): 42 | app = request.app 43 | tab_id = request.path_qs.split('/')[-1] 44 | tabs = app['tabs'] 45 | 46 | if tabs.get(tab_id) is None: 47 | app['tabs'][tab_id] = {} 48 | # https://aiohttp.readthedocs.io/en/v1.0.0/faq.html#how-to-receive-an-incoming-events-from-different-sources-in-parallel 49 | task = app.loop.create_task(ws_browser_handler(request)) 50 | app['tasks'].append(task) 51 | 52 | return await ws_client_handler(request) 53 | 54 | 55 | async def ws_client_handler(request): 56 | app = request.app 57 | path_qs = request.path_qs 58 | tab_id = path_qs.split('/')[-1] 59 | url = f'ws://{app["chrome_host"]}:{app["chrome_port"]}{path_qs}' 60 | encode_id = app['f']['encode_id'] 61 | client_id = len(app['clients']) 62 | log_prefix = f'[CLIENT {client_id}]' 63 | log_msg = app['f']['print'] 64 | 65 | ws_client = WebSocketResponse() 66 | await ws_client.prepare(request) 67 | 68 | if client_id >= app['max_clients']: 69 | log_msg(log_prefix, 'CONNECTION FAILED') 70 | return ws_client 71 | 72 | app['clients'][ws_client] = { 73 | 'id': client_id, 74 | 'tab_id': tab_id, 75 | 'subscriptions': set(), # TODO: Move subscriptions to separate entity 76 | } 77 | 78 | log_msg(log_prefix, 'CONNECTED') 79 | 80 | if app['tabs'][tab_id].get('ws') is None or app['tabs'][tab_id]['ws'].closed: 81 | session = aiohttp.ClientSession(loop=app.loop) 82 | app['sessions'].append(session) 83 | try: 84 | app['tabs'][tab_id]['ws'] = await session.ws_connect(url) 85 | except aiohttp.WSServerHandshakeError: 86 | log_msg(log_prefix, f'CONNECTION ERROR: {tab_id}') 87 | return ws_client 88 | 89 | async for msg in ws_client: 90 | if msg.type == WSMsgType.TEXT: 91 | if app['tabs'][tab_id]['ws'].closed: 92 | log_msg(log_prefix, 'RECONNECTED') 93 | break 94 | data = msg.json(loads=json.loads) 95 | 96 | data['id'] = encode_id(client_id, data['id']) 97 | log_msg(log_prefix, '>>', data) 98 | 99 | if data.get('method', '').endswith('.enable'): 100 | domain = data['method'].split('.')[0] 101 | app['clients'][ws_client]['subscriptions'].add(domain) 102 | elif data.get('method', '').endswith('.disable'): 103 | domain = data['method'].split('.')[0] 104 | if domain in app['clients'][ws_client]['subscriptions']: 105 | app['clients'][ws_client]['subscriptions'].remove(domain) 106 | 107 | app['tabs'][tab_id]['ws'].send_json(data, dumps=json.dumps) 108 | else: 109 | log_msg(log_prefix, 'DISCONNECTED') 110 | return ws_client 111 | 112 | 113 | async def ws_browser_handler(request): 114 | log_prefix = '<<' 115 | app = request.app 116 | tab_id = request.path_qs.split('/')[-1] 117 | decode_id = app['f']['decode_id'] 118 | log_msg = app['f']['print'] 119 | 120 | timeout = 10 121 | interval = 0.1 122 | 123 | for _ in range(math.ceil(timeout / interval)): 124 | if app['tabs'][tab_id].get('ws') is not None and not app['tabs'][tab_id]['ws'].closed: 125 | log_msg(f'[BROWSER {tab_id}]', 'CONNECTED') 126 | break 127 | await asyncio.sleep(interval) 128 | else: 129 | log_msg(f'[BROWSER {tab_id}]', 'DISCONNECTED') 130 | return 131 | 132 | async for msg in app['tabs'][tab_id]['ws']: 133 | if msg.type == WSMsgType.TEXT: 134 | data = msg.json(loads=json.loads) 135 | if data.get('id') is None: 136 | clients = { 137 | k: v for k, v in app['clients'].items() 138 | if v.get('tab_id') == tab_id and data.get('method', '').split('.')[0] in v['subscriptions'] 139 | } 140 | for client in clients.keys(): 141 | if not client.closed: 142 | client_id = app['clients'][client]['id'] 143 | log_msg(f'[CLIENT {client_id}]', log_prefix, msg.data) 144 | client.send_str(msg.data) 145 | else: 146 | client_id, request_id = decode_id(data['id']) 147 | log_msg(f'[CLIENT {client_id}]', log_prefix, data) 148 | data['id'] = request_id 149 | ws = next(ws for ws, client in app['clients'].items() if client['id'] == client_id) 150 | ws.send_json(data, dumps=json.dumps) 151 | else: 152 | log_msg(f'[BROWSER {tab_id}]', 'DISCONNECTED') 153 | return 154 | 155 | 156 | def update_tab(tab, host, port, log_msg): 157 | result = dict(tab) # It is safe enough — all values are strings 158 | 159 | if result.get('id') is None: 160 | log_msg('[ERROR]', f'Got a tab without id (which is improbable): {result}') 161 | return result # Maybe it should raise an error? 162 | 163 | devtools_url = f'{host}:{port}/devtools/page/{result["id"]}' 164 | result['webSocketDebuggerUrl'] = f'ws://{devtools_url}' 165 | result['devtoolsFrontendUrl'] = f'/devtools/inspector.html?ws={devtools_url}' 166 | 167 | return result 168 | 169 | 170 | async def proxy_handler(request): 171 | app = request.app 172 | method = request.method 173 | path_qs = request.path_qs 174 | session = aiohttp.ClientSession(loop=request.app.loop) 175 | url = f'http://{app["chrome_host"]}:{app["chrome_port"]}{path_qs}' 176 | log_msg = app['f']['print'] 177 | 178 | log_msg(f'[HTTP {method}] {path_qs}') 179 | try: 180 | response = await session.request(method, url) 181 | headers = response.headers.copy() 182 | if request.path in ('/json', '/json/list', '/json/new'): 183 | data = await response.json(loads=json.loads) 184 | 185 | proxy_host = request.url.host 186 | proxy_port = request.url.port 187 | if isinstance(data, list): 188 | data = [update_tab(tab, proxy_host, proxy_port, log_msg) for tab in data] 189 | elif isinstance(data, dict): 190 | data = update_tab(data, proxy_host, proxy_port, log_msg) 191 | else: 192 | log_msg('[WARN]', f'JSON data neither list nor dict: {data}') 193 | body, text = None, json.dumps(data) 194 | headers[hdrs.CONTENT_LENGTH] = str(len(text)) 195 | else: 196 | body, text = await response.read(), None 197 | 198 | return Response( 199 | body=body, 200 | text=text, 201 | status=response.status, 202 | reason=response.reason, 203 | headers=headers, 204 | ) 205 | except aiohttp.ClientError as exc: 206 | return HTTPBadGateway(text=str(exc)) 207 | finally: 208 | session.close() 209 | 210 | 211 | async def status_handler(request): 212 | fields = ( 213 | 'chrome_host', 214 | 'chrome_port', 215 | 'debug', 216 | 'internal', 217 | 'max_clients', 218 | 'proxy_hosts', 219 | 'proxy_ports', 220 | 'version', 221 | ) 222 | data = {k: v for k, v in request.app.items() if k in fields} 223 | return json_response(data=data, dumps=json.dumps) 224 | 225 | 226 | async def init(loop, args): 227 | app = Application(debug=args['debug']) 228 | app.update(args) 229 | log_msg = app['f']['print'] 230 | 231 | app['clients'] = {} 232 | app['tabs'] = {} 233 | # TODO: Move session and task handling to proper places 234 | app['sessions'] = [] 235 | app['tasks'] = [] 236 | 237 | app.router.add_route('*', '/{path:(?!status.json).*}', the_handler) 238 | app.router.add_route('*', '/status.json', status_handler) 239 | 240 | handler = app.make_handler() 241 | 242 | srvs = [await loop.create_server(handler, app['proxy_hosts'], proxy_port) for proxy_port in app['proxy_ports']] 243 | 244 | log_msg( 245 | f'DevTools Proxy started at {app["proxy_hosts"]}:{app["proxy_ports"]}\n' 246 | f'Use --remote-debugging-port={app["chrome_port"]} --remote-debugging-address={app["chrome_host"]} for Chrome', 247 | ) 248 | return app, srvs, handler 249 | 250 | 251 | async def finish(app, srvs, handler): 252 | for ws in list(app['clients'].keys()) + [tab['ws'] for tab in app['tabs'].values() if tab.get('ws') is not None]: 253 | if not ws.closed: 254 | await ws.close() 255 | 256 | for session in app['sessions']: 257 | if not session.closed: 258 | await session.close() 259 | 260 | for task in app['tasks']: 261 | task.cancel() 262 | 263 | await asyncio.sleep(0.1) 264 | for srv in srvs: 265 | srv.close() 266 | 267 | await handler.shutdown() 268 | 269 | for srv in srvs: 270 | await srv.wait_closed() 271 | 272 | app['f']['close_log']() 273 | 274 | 275 | def encode_decode_id(max_clients): 276 | bits_available = 31 277 | 278 | bits_for_client_id = math.ceil(math.log2(max_clients)) 279 | _max_clients = 2 ** bits_for_client_id 280 | max_request_id = 2 ** (bits_available - bits_for_client_id) - 1 281 | 282 | def encode_id(client_id, request_id): 283 | if request_id > max_request_id: 284 | raise OverflowError 285 | return (client_id << bits_available - bits_for_client_id) | request_id 286 | 287 | def decode_id(encoded_id): 288 | client_id = encoded_id >> (bits_available - bits_for_client_id) 289 | request_id = encoded_id & max_request_id 290 | return client_id, request_id 291 | 292 | return encode_id, decode_id, _max_clients 293 | 294 | 295 | def default_or_flatten_and_uniq(arg, default): 296 | # Simple helper for parsing arguments with action='append' and default value 297 | if arg is None: 298 | return default 299 | else: 300 | return list(set(e for ee in arg for e in ee)) 301 | 302 | 303 | def main(): 304 | parser = argparse.ArgumentParser( 305 | prog='devtools-proxy', 306 | description='DevTools Proxy' 307 | ) 308 | default_host = ['127.0.0.1'] 309 | parser.add_argument( 310 | '--host', 311 | type=str, nargs='+', action='append', 312 | help=f'Hosts to serve on (default: {default_host})', 313 | ) 314 | default_port = [9222] 315 | parser.add_argument( 316 | '--port', 317 | type=int, nargs='+', action='append', 318 | help=f'Ports to serve on (default: {default_port})', 319 | ) 320 | parser.add_argument( 321 | '--chrome-host', 322 | type=str, default='127.0.0.1', 323 | help=('Host on which Chrome is running, ' 324 | 'it corresponds with --remote-debugging-address Chrome argument (default: %(default)r)'), 325 | ) 326 | parser.add_argument( 327 | '--chrome-port', 328 | type=int, default=12222, 329 | help=('Port which Chrome remote debugger is listening, ' 330 | 'it corresponds with --remote-debugging-port Chrome argument (default: %(default)r)'), 331 | ) 332 | parser.add_argument( 333 | '--max-clients', 334 | type=int, default=8, 335 | help='Number of clients which proxy can handle during life cycle (default: %(default)r)', 336 | ) 337 | parser.add_argument( 338 | '--log', 339 | default=sys.stdout, type=argparse.FileType('w'), 340 | help='Write logs to file', 341 | ) 342 | parser.add_argument( 343 | '--version', 344 | action='version', 345 | version=VERSION, 346 | help='Print DevTools Proxy version', 347 | ) 348 | parser.add_argument( 349 | '--debug', 350 | action='store_true', default=False, 351 | help='Turn on debug mode (default: %(default)r)', 352 | ) 353 | args = parser.parse_args() 354 | 355 | encode_id, decode_id, max_clients = encode_decode_id(args.max_clients) 356 | 357 | args.port = default_or_flatten_and_uniq(args.port, default_port) 358 | args.host = default_or_flatten_and_uniq(args.host, default_host) 359 | 360 | arguments = { 361 | 'f': { 362 | 'encode_id': encode_id, 363 | 'decode_id': decode_id, 364 | 'print': lambda *a: args.log.write(' '.join(str(v) for v in a) + '\n'), 365 | 'close_log': lambda: args.log.close(), 366 | }, 367 | 'max_clients': max_clients, 368 | 'debug': args.debug, 369 | 'proxy_hosts': args.host, 370 | 'proxy_ports': args.port, 371 | 'chrome_host': args.chrome_host, 372 | 'chrome_port': args.chrome_port, 373 | 'internal': { 374 | 'ujson': WITH_UJSON, 375 | 'uvloop': WITH_UVLOOP, 376 | }, 377 | 'version': VERSION, 378 | } 379 | 380 | def _excepthook(exctype, value, traceback): 381 | return arguments['f']['print'](*tb.format_exception(exctype, value, traceback)) 382 | 383 | sys.excepthook = _excepthook 384 | 385 | loop = asyncio.get_event_loop() 386 | if args.debug: 387 | def _showwarning(message, category, filename, lineno, file=None, line=None): 388 | return arguments['f']['print'](warnings.formatwarning(message, category, filename, lineno, line)) 389 | 390 | warnings.showwarning = _showwarning 391 | warnings.simplefilter("always") 392 | loop.set_debug(True) 393 | 394 | application, srvs, handler = loop.run_until_complete(init(loop, arguments)) 395 | try: 396 | loop.run_forever() 397 | except KeyboardInterrupt: 398 | loop.run_until_complete(finish(application, srvs, handler)) 399 | 400 | 401 | if __name__ == '__main__': 402 | main() 403 | -------------------------------------------------------------------------------- /examples/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'selenium-webdriver', '3.3.0' 4 | gem 'websocket-client-simple', '0.3.0' 5 | -------------------------------------------------------------------------------- /examples/heap_snapshot.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # https://chromedevtools.github.io/debugger-protocol-viewer/tot/HeapProfiler/ 4 | 5 | import json 6 | 7 | import requests 8 | import selenium 9 | import websocket 10 | from devtools.proxy import CHROME_WRAPPER_PATH 11 | from selenium.webdriver.common.desired_capabilities import DesiredCapabilities 12 | 13 | if __name__ == '__main__': 14 | devtools_proxy_port = 9222 15 | 16 | desired_capabilities = DesiredCapabilities.CHROME.copy() 17 | desired_capabilities['chromeOptions'] = { 18 | 'binary': CHROME_WRAPPER_PATH, 19 | 'args': [ 20 | '--devtools-proxy-binary=devtools-proxy', 21 | f'--devtools-proxy-args=--port {devtools_proxy_port}', 22 | ] 23 | } 24 | 25 | driver = selenium.webdriver.Chrome(desired_capabilities=desired_capabilities) 26 | try: 27 | tabs = requests.get(f'http://localhost:{devtools_proxy_port}/json/list').json() 28 | tab = next(tab for tab in tabs if tab.get('type') == 'page') 29 | devtools_url = tab['webSocketDebuggerUrl'] 30 | driver.get('https://google.co.uk') 31 | 32 | ws = websocket.create_connection(devtools_url) 33 | data = { 34 | "method": "HeapProfiler.enable", 35 | "params": {}, 36 | "id": 0, 37 | } 38 | ws.send(json.dumps(data)) 39 | ws.recv() 40 | 41 | data = { 42 | "method": "HeapProfiler.takeHeapSnapshot", 43 | "params": {}, 44 | "id": 0, 45 | } 46 | ws.send(json.dumps(data)) 47 | 48 | heap_data = '' 49 | while True: 50 | raw_data = ws.recv() 51 | result = json.loads(raw_data) 52 | if result.get('id') == 0: 53 | break 54 | if result.get('method') == 'HeapProfiler.addHeapSnapshotChunk': 55 | heap_data += result['params']['chunk'] 56 | 57 | ws.close() 58 | 59 | with open('example.heapsnapshot', 'w') as f: 60 | f.write(heap_data) 61 | finally: 62 | driver.quit() 63 | -------------------------------------------------------------------------------- /examples/remote_inspect.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import re 4 | 5 | import requests 6 | import selenium 7 | from devtools.proxy import CHROME_WRAPPER_PATH 8 | from selenium.webdriver.common.by import By 9 | from selenium.webdriver.common.desired_capabilities import DesiredCapabilities 10 | from selenium.webdriver.support import expected_conditions 11 | from selenium.webdriver.support.ui import WebDriverWait 12 | 13 | 14 | def find_element(driver, locator): 15 | return WebDriverWait(driver, 10).until(expected_conditions.presence_of_element_located(locator)) 16 | 17 | 18 | if __name__ == '__main__': 19 | devtools_proxy_port = 9222 20 | 21 | desired_capabilities = DesiredCapabilities.CHROME.copy() 22 | desired_capabilities['chromeOptions'] = { 23 | 'binary': CHROME_WRAPPER_PATH, 24 | 'args': [ 25 | '--devtools-proxy-binary=devtools-proxy', 26 | f'--devtools-proxy-args=--port {devtools_proxy_port}', 27 | ] 28 | } 29 | 30 | driver = selenium.webdriver.Chrome(desired_capabilities=desired_capabilities) 31 | try: 32 | version = requests.get(f'http://localhost:{devtools_proxy_port}/json/version').json() 33 | webkit_version = version['WebKit-Version'] # 537.36 (@8ee402c67ff2f8f7c746e56d3530b4dcec0709ad) 34 | webkit_hash = re.search(r'\((.+)\)', webkit_version).group(1) # @8ee402c67ff2f8f7c746e56d3530b4dcec0709ad 35 | 36 | tabs = requests.get(f'http://localhost:{devtools_proxy_port}/json/list').json() 37 | tab = next(tab for tab in tabs if tab.get('type') == 'page') 38 | devtools_frontend_url = tab['devtoolsFrontendUrl'] 39 | devtools_frontend_url = re.sub(r'^/devtools/', '', devtools_frontend_url) 40 | 41 | url_template = 'https://chrome-devtools-frontend.appspot.com/serve_file/{}/{}&remoteFrontend=true' 42 | url = url_template.format(webkit_hash, devtools_frontend_url) 43 | print(url) 44 | 45 | driver.get('https://google.co.uk') 46 | find_element(driver, (By.CSS_SELECTOR, '[name = "q"]')).send_keys('ChromeDriver') 47 | find_element(driver, (By.CSS_SELECTOR, 'h3 > a')).click() # The first one search result 48 | find_element(driver, (By.XPATH, '//a[text() = "Capabilities & ChromeOptions"]')).click() 49 | find_element(driver, (By.XPATH, '//h3//code[text() = "chromeOptions"]')).location_once_scrolled_into_view 50 | finally: 51 | driver.quit() 52 | -------------------------------------------------------------------------------- /examples/requirements.txt: -------------------------------------------------------------------------------- 1 | devtools-proxy==0.1.0 2 | requests==2.21.0 3 | selenium==3.6.0 4 | websocket-client==0.54.0 5 | -------------------------------------------------------------------------------- /examples/throttling.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | 5 | import requests 6 | import selenium 7 | import websocket 8 | from devtools.proxy import CHROME_WRAPPER_PATH 9 | from selenium.webdriver.common.desired_capabilities import DesiredCapabilities 10 | 11 | if __name__ == '__main__': 12 | devtools_proxy_port = 9222 13 | 14 | desired_capabilities = DesiredCapabilities.CHROME.copy() 15 | desired_capabilities['chromeOptions'] = { 16 | 'binary': CHROME_WRAPPER_PATH, 17 | 'args': [ 18 | '--devtools-proxy-binary=devtools-proxy', 19 | f'--devtools-proxy-args=--port {devtools_proxy_port}', 20 | ] 21 | } 22 | 23 | driver = selenium.webdriver.Chrome(desired_capabilities=desired_capabilities) 24 | try: 25 | tabs = requests.get(f'http://localhost:{devtools_proxy_port}/json/list').json() 26 | tab = next(tab for tab in tabs if tab.get('type') == 'page') 27 | devtools_url = tab['webSocketDebuggerUrl'] 28 | driver.get('https://codepen.io/bayandin/full/xRpROy/') 29 | 30 | ws = websocket.create_connection(devtools_url) 31 | data = { 32 | "method": "Emulation.setCPUThrottlingRate", 33 | "params": { 34 | "rate": 10, 35 | }, 36 | "id": 0, 37 | } 38 | ws.send(json.dumps(data)) 39 | ws.recv() 40 | ws.close() 41 | finally: 42 | driver.quit() 43 | -------------------------------------------------------------------------------- /examples/throttling.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'selenium-webdriver' 4 | require 'websocket-client-simple' 5 | 6 | require 'json' 7 | require 'net/http' 8 | 9 | CHROME_WRAPPER_PATH = '/path/to/chrome-wrapper.sh' 10 | DEVTOOLS_PROXY_PATH = '/path/to/devtools-proxy' 11 | DEVTOOLS_PROXY_PORT = 9222 12 | 13 | opts = { 14 | chromeOptions: { 15 | binary: CHROME_WRAPPER_PATH, 16 | args: [ 17 | "--devtools-proxy-binary=#{DEVTOOLS_PROXY_PATH}", 18 | "--devtools-proxy-args=--port #{DEVTOOLS_PROXY_PORT}" 19 | ] 20 | } 21 | } 22 | 23 | caps = Selenium::WebDriver::Remote::Capabilities.chrome(opts) 24 | driver = Selenium::WebDriver.for(:chrome, desired_capabilities: caps) 25 | 26 | begin 27 | response = Net::HTTP.get '127.0.0.1', '/json/list', DEVTOOLS_PROXY_PORT 28 | tab = JSON.parse(response).find { |tab| tab['type'] == 'page' } 29 | devtools_url = tab['webSocketDebuggerUrl'] 30 | driver.navigate.to 'https://codepen.io/bayandin/full/xRpROy/' 31 | 32 | ws = WebSocket::Client::Simple.connect devtools_url 33 | data = { 34 | method: 'Emulation.setCPUThrottlingRate', 35 | params: { 36 | rate: 10, 37 | }, 38 | id: 0, 39 | }.to_json 40 | ws.send data 41 | ws.close 42 | ensure 43 | driver.quit 44 | end 45 | -------------------------------------------------------------------------------- /requirements-build.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | pip==19.0.1 3 | PyInstaller==3.4 4 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | pytest==4.1.1 3 | pytest-instafail==0.4.0 4 | pytest-timeout==1.3.3 5 | pytest-xdist==1.26.1 6 | requests==2.21.0 7 | selenium==3.6.0 8 | websocket-client==0.54.0 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==2.3.10 2 | cchardet==2.1.4 3 | ujson==1.35 4 | uvloop==0.12.0; sys_platform != 'win32' 5 | -------------------------------------------------------------------------------- /run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ $# -eq 0 ]] ; then 4 | echo >&2 "No arguments" 5 | exit 1 6 | elif [[ $# -gt 1 ]] ; then 7 | echo >&2 "Too many arguments: ${@:1}" 8 | exit 1 9 | fi 10 | 11 | case "$1" in 12 | functional) 13 | readonly TESTS="functional" 14 | ;; 15 | integration) 16 | readonly TESTS="integration" 17 | ;; 18 | compatibility) 19 | readonly TESTS="compatibility/selenium/py" 20 | ;; 21 | *) 22 | echo >&2 "Unknown argument: $1 (functional|integration|compatibility)" 23 | exit 1 24 | ;; 25 | esac 26 | 27 | readonly PROJECT_DIR="$(dirname "$(readlink -f "$0")")" 28 | readonly CHROME_WRAPPER_PATH=${CHROME_WRAPPER_PATH:-"${PROJECT_DIR}/devtools/chrome-wrapper.sh"} 29 | readonly DEVTOOLS_PROXY_PATH=${DEVTOOLS_PROXY_PATH:-"${PROJECT_DIR}/devtools/proxy.py"} 30 | PYTEST_OPTIONS=(-n=auto --verbose --instafail) 31 | 32 | if [[ ${TESTS} == "compatibility/selenium/py" ]]; then 33 | readonly WITH_DEVTOOLS_PROXY=${WITH_DEVTOOLS_PROXY:-true} 34 | readonly DEVTOOLS_PROXY_PATCH=$(cat ${PROJECT_DIR}/tests/compatibility/conftest.py.patch) 35 | echo "$DEVTOOLS_PROXY_PATCH" | patch -N -p0 -d "${PROJECT_DIR}/tests/compatibility/selenium/py" 36 | cp "${PROJECT_DIR}/tests/compatibility/getAttribute.js" "${PROJECT_DIR}/tests/compatibility/selenium/py/selenium/webdriver/remote/getAttribute.js" 37 | cp "${PROJECT_DIR}/tests/compatibility/isDisplayed.js" "${PROJECT_DIR}/tests/compatibility/selenium/py/selenium/webdriver/remote/isDisplayed.js" 38 | PYTEST_OPTIONS+=(--driver=Chrome --timeout-method=thread --timeout=120) 39 | fi 40 | 41 | py.test ${PYTEST_OPTIONS[@]} "${PROJECT_DIR}/tests/${TESTS}/" 42 | EXIT_CODE=$? 43 | 44 | if [[ ${TESTS} == "compatibility/selenium/py" ]]; then 45 | echo "$DEVTOOLS_PROXY_PATCH" | patch -R -N -p0 -d "${PROJECT_DIR}/tests/compatibility/selenium/py" 46 | rm "${PROJECT_DIR}/tests/compatibility/selenium/py/selenium/webdriver/remote/getAttribute.js" "${PROJECT_DIR}/tests/compatibility/selenium/py/selenium/webdriver/remote/isDisplayed.js" 47 | fi 48 | 49 | exit ${EXIT_CODE} 50 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | from pip.req import parse_requirements 5 | from setuptools import setup, find_packages 6 | 7 | 8 | def requirements_from_file(filename): 9 | requirements = [] 10 | for r in parse_requirements(filename, session='fake'): 11 | if r.match_markers(): 12 | requirements.append(str(r.req)) 13 | return requirements 14 | 15 | 16 | here = os.path.abspath(os.path.dirname(__file__)) 17 | 18 | with open(os.path.join(here, 'devtools', '__init__.py'), encoding='utf-8') as f: 19 | try: 20 | version = re.findall(r"^__version__ = '([^']+)'\r?$", f.read(), re.M)[0] 21 | except IndexError: 22 | raise RuntimeError('Unable to determine version') 23 | 24 | setup( 25 | name='devtools-proxy', 26 | 27 | version=version, 28 | 29 | description='DevTools Proxy', 30 | long_description='DevTools Proxy', 31 | 32 | url='https://github.com/bayandin/devtools-proxy', 33 | 34 | author='Alexander Bayandin', 35 | author_email='a.bayandin@gmail.com', 36 | 37 | license='MIT', 38 | 39 | classifiers=[ 40 | 'Development Status :: 3 - Alpha', 41 | # 'Development Status :: 4 - Beta', 42 | 'Intended Audience :: Developers', 43 | 'License :: OSI Approved :: MIT License', 44 | 'Operating System :: MacOS :: MacOS X', 45 | # 'Operating System :: Microsoft :: Windows', 46 | 'Operating System :: POSIX', 47 | 'Programming Language :: Python', 48 | 'Programming Language :: Python :: 3.6', 49 | 'Topic :: Software Development :: Testing', 50 | ], 51 | 52 | keywords='selenium chrome chromedriver devtools', 53 | 54 | packages=find_packages(exclude=['tests', 'tests.*']), 55 | 56 | install_requires=requirements_from_file('requirements.txt'), 57 | 58 | package_data={ 59 | 'devtools': [ 60 | 'chrome-wrapper.sh', 61 | ], 62 | }, 63 | 64 | entry_points={ 65 | 'console_scripts': [ 66 | 'devtools-proxy=devtools.proxy:main', 67 | ], 68 | }, 69 | ) 70 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | PROJECT_DIR = str(Path(__file__, '../../').resolve()) 5 | DEVTOOLS_PROXY_PATH = os.environ.get('DEVTOOLS_PROXY_PATH', f'{PROJECT_DIR}/devtools/proxy.py') 6 | CHROME_WRAPPER_PATH = os.environ.get('CHROME_WRAPPER_PATH', f'{PROJECT_DIR}/devtools/chrome-wrapper.sh') 7 | -------------------------------------------------------------------------------- /tests/compatibility/conftest.py.patch: -------------------------------------------------------------------------------- 1 | --- conftest.py 2 | +++ conftest.py 3 | @@ -26,6 +26,7 @@ from _pytest.skipping import MarkEvaluator 4 | 5 | from selenium import webdriver 6 | from selenium.webdriver import DesiredCapabilities 7 | +from selenium.webdriver.common.utils import free_port 8 | from test.selenium.webdriver.common.webserver import SimpleWebServer 9 | from test.selenium.webdriver.common.network import get_lan_ip 10 | 11 | @@ -95,6 +96,19 @@ def driver(request): 12 | 13 | global driver_instance 14 | if driver_instance is None: 15 | + if driver_class == 'Chrome': 16 | + port = free_port() 17 | + capabilities = DesiredCapabilities.CHROME.copy() 18 | + 19 | + if os.environ.get('WITH_DEVTOOLS_PROXY') == 'true': 20 | + capabilities['chromeOptions'] = { 21 | + 'binary': os.environ['CHROME_WRAPPER_PATH'], 22 | + 'args': [ 23 | + '--devtools-proxy-binary={}'.format(os.environ['DEVTOOLS_PROXY_PATH']), 24 | + '--devtools-proxy-chrome-debugging-port={}'.format(port), 25 | + ] 26 | + } 27 | + kwargs['desired_capabilities'] = capabilities 28 | if driver_class == 'BlackBerry': 29 | kwargs.update({'device_password': 'password'}) 30 | if driver_class == 'Firefox': 31 | -------------------------------------------------------------------------------- /tests/compatibility/getAttribute.js: -------------------------------------------------------------------------------- 1 | function(){return function(){var d=this;function f(a){return"string"==typeof a};function h(a,b){this.code=a;this.a=l[a]||m;this.message=b||"";a=this.a.replace(/((?:^|\s+)[a-z])/g,function(a){return a.toUpperCase().replace(/^[\s\xa0]+/g,"")});b=a.length-5;if(0>b||a.indexOf("Error",b)!=b)a+="Error";this.name=a;a=Error(this.message);a.name=this.name;this.stack=a.stack||""} 2 | (function(){var a=Error;function b(){}b.prototype=a.prototype;h.b=a.prototype;h.prototype=new b;h.prototype.constructor=h;h.a=function(b,c,g){for(var e=Array(arguments.length-2),k=2;kparseFloat(D)){C=String(F);break a}}C=D}var G;var H=d.document;G=H&&y?B()||("CSS1Compat"==H.compatMode?parseInt(C,10):5):void 0;var ba=r("Firefox"),ca=v()||r("iPod"),da=r("iPad"),I=r("Android")&&!(w()||r("Firefox")||r("Opera")||r("Silk")),ea=w(),J=r("Safari")&&!(w()||r("Coast")||r("Opera")||r("Edge")||r("Silk")||r("Android"))&&!(v()||r("iPad")||r("iPod"));function K(a){return(a=a.exec(n))?a[1]:""}(function(){if(ba)return K(/Firefox\/([0-9.]+)/);if(y||z||x)return C;if(ea)return v()||r("iPad")||r("iPod")?K(/CriOS\/([0-9.]+)/):K(/Chrome\/([0-9.]+)/);if(J&&!(v()||r("iPad")||r("iPod")))return K(/Version\/([0-9.]+)/);if(ca||da){var a=/Version\/(\S+).*Mobile\/(\S+)/.exec(n);if(a)return a[1]+"."+a[2]}else if(I)return(a=K(/Android\s+([0-9.]+)/))?a:K(/Version\/([0-9.]+)/);return""})();var L,M=function(){if(!A)return!1;var a=d.Components;if(!a)return!1;try{if(!a.classes)return!1}catch(g){return!1}var b=a.classes,a=a.interfaces,e=b["@mozilla.org/xpcom/version-comparator;1"].getService(a.nsIVersionComparator),c=b["@mozilla.org/xre/app-info;1"].getService(a.nsIXULAppInfo).version;L=function(a){e.compare(c,""+a)};return!0}(),N=y&&!(8<=Number(G)),fa=y&&!(9<=Number(G));I&&M&&L(2.3);I&&M&&L(4);J&&M&&L(6);var ga={SCRIPT:1,STYLE:1,HEAD:1,IFRAME:1,OBJECT:1},O={IMG:" ",BR:"\n"};function P(a,b,e){if(!(a.nodeName in ga))if(3==a.nodeType)e?b.push(String(a.nodeValue).replace(/(\r\n|\r|\n)/g,"")):b.push(a.nodeValue);else if(a.nodeName in O)b.push(O[a.nodeName]);else for(a=a.firstChild;a;)P(a,b,e),a=a.nextSibling};function Q(a,b){b=b.toLowerCase();return"style"==b?ha(a.style.cssText):N&&"value"==b&&R(a,"INPUT")?a.value:fa&&!0===a[b]?String(a.getAttribute(b)):(a=a.getAttributeNode(b))&&a.specified?a.value:null}var ia=/[;]+(?=(?:(?:[^"]*"){2})*[^"]*$)(?=(?:(?:[^']*'){2})*[^']*$)(?=(?:[^()]*\([^()]*\))*[^()]*$)/; 5 | function ha(a){var b=[];t(a.split(ia),function(a){var c=a.indexOf(":");0