├── LICENSE ├── MANIFEST.in ├── README.md ├── pyproject.toml ├── requirements.txt ├── setup.cfg ├── setup.py ├── src └── cdp_socket │ ├── __init__.py │ ├── exceptions.py │ ├── files │ └── __init__.py │ ├── scripts │ ├── __init__.py │ └── abstract.py │ ├── socket.py │ └── utils │ ├── __init__.py │ ├── conn.py │ └── utils.py └── tests ├── check_event.py └── test_socket.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Aurin Aegerter 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include pyproject.toml 2 | include *.md 3 | include LICENSE.md 4 | recursive-include tests test*.py 5 | 6 | 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CDP-Socket 2 | 3 | * Handle [Chrome-Developer-Protocol](https://chromedevtools.github.io/devtools-protocol/) connections 4 | 5 | ### Feel free to test my code! 6 | 7 | ## Getting Started 8 | 9 | ### Dependencies 10 | 11 | * [Python >= 3.7](https://www.python.org/downloads/) 12 | * [Chrome-Browser](https://www.google.de/chrome/) installed 13 | 14 | ### Installing 15 | 16 | * [Windows] Install [Chrome-Browser](https://www.google.de/chrome/) 17 | * ```pip install cdp-socket``` 18 | 19 | #### Single socket 20 | ```python 21 | from cdp_socket.utils.utils import launch_chrome, random_port 22 | from cdp_socket.utils.conn import get_websock_url 23 | 24 | from cdp_socket.socket import SingleCDPSocket 25 | 26 | import os 27 | import asyncio 28 | 29 | 30 | async def main(): 31 | data_dir = os.getcwd()+"/data_dir" 32 | PORT = random_port() 33 | process = launch_chrome(data_dir,PORT) 34 | 35 | websock_url = await get_websock_url(PORT, timeout=5) 36 | async with SingleCDPSocket(websock_url, timeout=5) as sock: 37 | targets = await sock.exec("Target.getTargets") 38 | print(targets) 39 | 40 | os.kill(process.pid, 15) 41 | 42 | 43 | asyncio.run(main()) 44 | ``` 45 | 46 | #### on_closed callback 47 | ```python 48 | from cdp_socket.socket import SingleCDPSocket 49 | 50 | def on_closed(code, reason): 51 | print("Closed with: ", code, reason) 52 | 53 | async with SingleCDPSocket(websock_url, timeout=5) as sock: 54 | sock.on_closed.append(on_closed) 55 | # close window for dispatching this event 56 | targets = await sock.exec("Target.getTargets") 57 | print(targets) 58 | ``` 59 | 60 | #### add event listener 61 | ```python 62 | from cdp_socket.socket import SingleCDPSocket 63 | import asyncio 64 | 65 | def on_detached(params): 66 | print("Detached with: ", params) 67 | 68 | async with SingleCDPSocket(websock_url, timeout=5) as sock: 69 | # close window for dispatching this event 70 | sock.add_listener('Inspector.detached', on_detached) 71 | await asyncio.sleep(1000) 72 | ``` 73 | 74 | #### iterate over event 75 | ```python 76 | from cdp_socket.socket import SingleCDPSocket 77 | 78 | async with SingleCDPSocket(websock_url, timeout=5) as sock: 79 | async for i in sock.method_iterator('Inspector.detached'): 80 | print(i) 81 | break 82 | ``` 83 | 84 | #### wait for event 85 | ```python 86 | from cdp_socket.socket import SingleCDPSocket 87 | 88 | async with SingleCDPSocket(websock_url, timeout=5) as sock: 89 | res = await sock.wait_for('Inspector.detached') 90 | print(res) 91 | ``` 92 | 93 | #### synchronous 94 | ```python 95 | from cdp_socket.utils.utils import launch_chrome, random_port 96 | from cdp_socket.utils.conn import get_websock_url 97 | 98 | from cdp_socket.socket import SingleCDPSocket 99 | 100 | import os 101 | import shutil 102 | import asyncio 103 | 104 | data_dir = os.getcwd()+"/data_dir" 105 | PORT = random_port() 106 | process = launch_chrome(data_dir,PORT) 107 | 108 | loop = asyncio.get_event_loop() 109 | websock_url = loop.run_until_complete(get_websock_url(PORT, timeout=5)) 110 | 111 | conn = loop.run_until_complete(SingleCDPSocket(websock_url, timeout=5, loop=loop)) 112 | targets = loop.run_until_complete(conn.exec("Target.getTargets")) 113 | print(targets) 114 | 115 | os.kill(process.pid, 15) 116 | shutil.rmtree(data_dir) 117 | ``` 118 | 119 | #### CDPSocket 120 | ```python 121 | from cdp_socket.utils.utils import launch_chrome, random_port 122 | from cdp_socket.socket import CDPSocket 123 | 124 | import os 125 | import asyncio 126 | 127 | async def main(): 128 | data_dir = os.getcwd()+"/data_dir" 129 | PORT = random_port() 130 | process = launch_chrome(data_dir,PORT) 131 | 132 | async with CDPSocket(PORT) as base_socket: 133 | targets = await base_socket.targets 134 | sock1 = await base_socket.get_socket(targets[0]) 135 | targets = await sock1.exec("Target.getTargets") 136 | print(targets) 137 | os.kill(process.pid, 15) 138 | 139 | 140 | asyncio.run(main()) 141 | ``` 142 | 143 | #### Custom exception handling 144 | You can implement custom exception handling as following 145 | 146 | ```python 147 | import cdp_socket 148 | import sys 149 | # print exception without traceback 150 | sys.modules["cdp_socket"].EXC_HANDLER = lambda e: print(f'Exception in event-handler:\n{e.__class__.__module__}.{e.__class__.__name__}: {e}', file=sys.stderr) 151 | ``` 152 | 153 | ## Help 154 | 155 | Please feel free to open an issue or fork! 156 | 157 | ## Performance 158 | On a Win10 Laptop 159 | 160 | executing `"Browser.getVersion"` 161 | ``` 162 | Static benchmark: 163 | passed 3_640.0 mB in 2.74 s, 13_308.958 mB/sec 164 | 3_656 (each 364 bytes) exec per sec. 165 | ``` 166 | for returning a big (static) object over javascript 167 | ``` 168 | JS benchmark: 169 | passed 22_990.0 mB in 3.16 s, 7_284.5374 mB/sec 170 | 1_584 (each 4.598 mB) exec (JS) per s 171 | ``` 172 | 173 | 174 | ## Authors 175 | 176 | [Aurin Aegerter](mailto:aurinliun@gmx.ch) 177 | 178 | ## Disclaimer 179 | 180 | I am not responsible what you use the code for!!! Also no warranty! 181 | 182 | ## Acknowledgments 183 | 184 | Inspiration, code snippets, etc. 185 | - [Chrome-Developer-Protocol](https://chromedevtools.github.io/devtools-protocol/) 186 | 187 | ## contributors 188 | 189 | - thanks to [@Redrrx](https://github.com/Redrrx) who gave me some starting-points 190 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=46.4.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp 2 | websockets 3 | orjson 4 | 5 | # dev 6 | twine 7 | setuptools 8 | aiodebug -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | version = attr: cdp_socket.__version__ 3 | license_files = LICENSE 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | 4 | requirements = ['aiohttp', 'websockets', "orjson"] 5 | 6 | with open('README.md', 'r', encoding='utf-8') as fh: 7 | long_description = fh.read() 8 | 9 | setuptools.setup( 10 | name='cdp-socket', 11 | author='Aurin Aegerter', 12 | author_email='aurinliun@gmx.ch', 13 | description='socket for handling chrome-developer-protocol connections', 14 | keywords='Chromium,socket, webautomation', 15 | long_description=long_description, 16 | long_description_content_type='text/markdown', 17 | url='https://github.com/kaliiiiiiiiii/CDP-Socket', 18 | project_urls={ 19 | 'Documentation': 'https://github.com/kaliiiiiiiiii/CDP-Socket', 20 | 'Bug Reports': 21 | 'https://github.com/kaliiiiiiiiii/CDP-Socket/issues', 22 | 'Source Code': 'https://github.com/kaliiiiiiiiii/CDP-Socket', 23 | }, 24 | package_dir={'': 'src'}, 25 | packages=setuptools.find_packages(where='src'), 26 | classifiers=[ 27 | # see https://pypi.org/classifiers/ 28 | 'Development Status :: 2 - Pre-Alpha', 29 | 'Intended Audience :: Developers', 30 | 'Programming Language :: Python :: 3', 31 | 'Programming Language :: Python :: 3.7', 32 | 'Programming Language :: Python :: 3.8', 33 | 'Programming Language :: Python :: 3.9', 34 | 'Programming Language :: Python :: 3.10', 35 | 'Programming Language :: Python :: 3.11', 36 | 'License :: Free for non-commercial use', 37 | 'Natural Language :: English', 38 | 'Operating System :: OS Independent', 39 | 'Topic :: Communications', 40 | 'Topic :: Internet :: WWW/HTTP :: Browsers' 41 | 42 | ], 43 | python_requires='>=3.7', 44 | license="MIT", 45 | install_requires=requirements, 46 | include_package_data=True, 47 | extras_require={ 48 | 'dev': ['check-manifest'], 49 | # 'test': ['coverage'], 50 | }, 51 | ) 52 | -------------------------------------------------------------------------------- /src/cdp_socket/__init__.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | EXC_HANDLER = (lambda e: traceback.print_exc()) 3 | __version__ = "1.2.8" 4 | 5 | -------------------------------------------------------------------------------- /src/cdp_socket/exceptions.py: -------------------------------------------------------------------------------- 1 | class CDPError(Exception): 2 | def __init__(self, error): 3 | self.code = error["code"] 4 | self.message = error["message"] 5 | super().__init__(error) 6 | 7 | 8 | class SocketExcitedError(Exception): 9 | pass 10 | -------------------------------------------------------------------------------- /src/cdp_socket/files/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaliiiiiiiiii/CDP-Socket/4813479d7b856c4a609aa19e6fea80ed9d425445/src/cdp_socket/files/__init__.py -------------------------------------------------------------------------------- /src/cdp_socket/scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaliiiiiiiiii/CDP-Socket/4813479d7b856c4a609aa19e6fea80ed9d425445/src/cdp_socket/scripts/__init__.py -------------------------------------------------------------------------------- /src/cdp_socket/scripts/abstract.py: -------------------------------------------------------------------------------- 1 | from cdp_socket.socket import SingleCDPSocket 2 | import uuid 3 | import asyncio 4 | 5 | 6 | class CDPEventIter(object): 7 | def __init__(self, method, socket: SingleCDPSocket): 8 | self._method = method 9 | self._socket = socket 10 | self._id = uuid.uuid4().hex 11 | self._fut = None 12 | 13 | def __aiter__(self): 14 | return self 15 | 16 | def _new_fut(self, result=None): 17 | self._fut = asyncio.Future() 18 | # noinspection PyProtectedMember 19 | self._socket._iter_callbacks[self._method][self._id] = self._fut.set_result 20 | 21 | async def __anext__(self) -> asyncio.Future: 22 | self._new_fut() 23 | return await self._fut 24 | 25 | @property 26 | def id(self): 27 | return self._id 28 | -------------------------------------------------------------------------------- /src/cdp_socket/socket.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | 4 | import orjson 5 | from collections import defaultdict 6 | import websockets 7 | import inspect 8 | import typing 9 | import json 10 | 11 | from cdp_socket.exceptions import CDPError, SocketExcitedError 12 | from cdp_socket.utils.conn import get_websock_url, get_json 13 | 14 | background_tasks = set() 15 | 16 | 17 | def safe_wrap_fut(fut: typing.Awaitable): 18 | task = asyncio.ensure_future(fut) 19 | background_tasks.add(task) 20 | task.add_done_callback(background_tasks.discard) 21 | 22 | 23 | class SingleCDPSocket: 24 | def __init__(self, websock_url: str, timeout: float = 10, loop: asyncio.AbstractEventLoop = None, 25 | max_size: int = 2 ** 20): 26 | self._task = None 27 | if not loop: 28 | loop = asyncio.get_running_loop() 29 | self._ws: websockets.WebSocketClientProtocol = None 30 | self._url = websock_url 31 | self._timeout = timeout 32 | self._req_count = 0 33 | self._max_size = max_size 34 | self._responses = defaultdict(lambda: asyncio.Future()) 35 | self._events = defaultdict(lambda: []) 36 | self._iter_callbacks = defaultdict(lambda: {}) 37 | self._loop = loop 38 | self.on_closed = [] 39 | self._id = websock_url.split("/")[-1] 40 | self._exc = None 41 | 42 | def __await__(self): 43 | return self.start_session(timeout=self._timeout).__await__() 44 | 45 | async def __aenter__(self): 46 | await self.start_session(timeout=self._timeout) 47 | return self 48 | 49 | async def __aexit__(self, *args, **kwargs): 50 | await self.close() 51 | 52 | async def start_session(self, timeout: float = 10): 53 | try: 54 | self._ws: websockets.WebSocketClientProtocol = await websockets.connect(uri=self._url, 55 | open_timeout=timeout, 56 | max_size=self._max_size) 57 | except asyncio.TimeoutError: 58 | raise asyncio.TimeoutError(f"Couldn't connect to websocket within {timeout} seconds") 59 | self._task = self._loop.create_task(self._rec_coro()) 60 | self._task.add_done_callback(self._exc_handler) 61 | return self 62 | 63 | # noinspection PyMethodMayBeStatic 64 | def _exc_handler(self, task): 65 | # noinspection PyProtectedMember 66 | exc = task._exception 67 | if exc: 68 | raise exc 69 | 70 | async def send(self, method: str, params: dict = None): 71 | _id = [self._req_count][0] 72 | _dict = {'id': _id, 'method': method} 73 | if params: 74 | _dict['params'] = params 75 | await self._ws.send(json.dumps(_dict)) 76 | self._req_count += 1 77 | return _id 78 | 79 | # noinspection PyTypeChecker 80 | async def exec(self, method: str, params: dict = None, timeout: float = 2): 81 | _id = await self.send(method=method, params=params) 82 | # noinspection PyStatementEffect 83 | self._responses[_id] 84 | try: 85 | res = await asyncio.wait_for(self._responses[_id], timeout=timeout) 86 | try: 87 | del self._responses[_id] 88 | except KeyError: 89 | pass 90 | return res 91 | except asyncio.TimeoutError: 92 | if self._task.done(): 93 | # task has excited 94 | # noinspection PyProtectedMember 95 | if self._exc: 96 | raise self._exc 97 | elif self._task._exception: 98 | # noinspection PyProtectedMember 99 | raise self._task._exception 100 | else: 101 | raise SocketExcitedError("socket coroutine excited without exception") 102 | raise asyncio.TimeoutError(f'got no response for method: "{method}", params: {params}' 103 | f"\nwithin {timeout} seconds") 104 | 105 | def add_listener(self, method: str, callback: callable): 106 | self._events[method].append(callback) 107 | 108 | def remove_listener(self, method: str, callback: callable): 109 | self._events[method].remove(callback) 110 | 111 | def method_iterator(self, method: str): 112 | from cdp_socket.scripts.abstract import CDPEventIter 113 | return CDPEventIter(method=method, socket=self) 114 | 115 | async def wait_for(self, method: str, timeout=None): 116 | _iter = self.method_iterator(method) 117 | try: 118 | res = await asyncio.wait_for(_iter.__anext__(), timeout) 119 | except asyncio.TimeoutError as e: 120 | _id = _iter.id 121 | try: 122 | del self._iter_callbacks[_id] 123 | except KeyError: 124 | pass 125 | raise e 126 | return res 127 | 128 | async def load_json(self, data): 129 | try: 130 | if sys.getsizeof(data) > 8000: 131 | return await self._loop.run_in_executor(None, orjson.loads, data) 132 | else: 133 | return json.loads(data) 134 | except orjson.JSONDecodeError: 135 | return await self._loop.run_in_executor(None, json.loads, data) 136 | 137 | async def _rec_coro(self): 138 | # noinspection PyUnresolvedReferences 139 | try: 140 | async for data in self._ws: 141 | try: 142 | data = await self.load_json(data) 143 | except Exception as e: 144 | from cdp_socket import EXC_HANDLER 145 | EXC_HANDLER(e) 146 | data = {"method": "DecodeError", "params": {"e": e}} 147 | err = data.get('error') 148 | _id = data.get("id") 149 | if err is None: 150 | if _id is None: 151 | method = data.get("method") 152 | params = data.get("params") 153 | callbacks: callable = self._events[method] 154 | for callback in callbacks: 155 | await self._handle_callback(callback, params) 156 | for _id, fut_result_setter in list(self._iter_callbacks[method].items()): 157 | try: 158 | fut_result_setter(params) 159 | except asyncio.InvalidStateError: 160 | pass # callback got cancelled 161 | try: 162 | del self._iter_callbacks[method][_id] 163 | except KeyError: 164 | pass 165 | else: 166 | try: 167 | self._responses[_id].set_result(data["result"]) 168 | except asyncio.InvalidStateError: 169 | try: 170 | del self._responses[_id] 171 | except KeyError: 172 | pass 173 | else: 174 | exc = CDPError(error=err) 175 | try: 176 | self._responses[_id].set_exception(exc) 177 | except asyncio.InvalidStateError: 178 | try: 179 | del self._responses[_id] 180 | except KeyError: 181 | pass 182 | except websockets.exceptions.ConnectionClosedError as e: 183 | if self.on_closed: 184 | self._exc = e 185 | for callback in self.on_closed: 186 | await self._handle_callback(callback, code=e.code, reason=e.reason) 187 | 188 | @staticmethod 189 | async def _handle_callback(callback: callable, *args, **kwargs): 190 | from . import EXC_HANDLER 191 | if callback: 192 | async def async_handle(awaitable): 193 | try: 194 | await awaitable 195 | except Exception as e: 196 | EXC_HANDLER(e) 197 | try: 198 | res = callback(*args, **kwargs) 199 | except Exception as e: 200 | EXC_HANDLER(e) 201 | return 202 | if inspect.isawaitable(res): 203 | safe_wrap_fut(async_handle(res)) 204 | return res 205 | 206 | async def close(self, code: int = 1000, reason: str = ''): 207 | if self._ws.open: 208 | try: 209 | await self._ws.close(code=code, reason=reason) 210 | except AttributeError as e: 211 | if e.args[0] == "'NoneType' object has no attribute 'encode'": 212 | # closed 213 | pass 214 | else: 215 | raise e 216 | 217 | @property 218 | def closed(self): 219 | return self._ws.closed 220 | 221 | @property 222 | def ws_url(self): 223 | return self._url 224 | 225 | @property 226 | def id(self): 227 | return self._id 228 | 229 | def __eq__(self, other): 230 | if isinstance(other, SingleCDPSocket): 231 | return self._ws.id == other._ws.id 232 | else: 233 | return False 234 | 235 | def __ne__(self, other): 236 | return not self.__eq__(other) 237 | 238 | 239 | class CDPSocket: 240 | def __init__(self, port: int, host: str = "127.0.0.1", timeout: int = 30, loop=None, max_size: int = 2 ** 20): 241 | if not loop: 242 | loop = asyncio.get_event_loop() 243 | self._port = port 244 | self._max_size = max_size 245 | self._host_ = host 246 | self._host = f"{host}:{port}" 247 | self._timeout = timeout 248 | self._loop = loop 249 | # noinspection PyTypeChecker 250 | self._sockets: typing.Dict[str, SingleCDPSocket] = defaultdict(lambda: None) 251 | 252 | async def __aenter__(self): 253 | return await self.start_session() 254 | 255 | def __await__(self): 256 | return self.start_session().__await__() 257 | 258 | async def start_session(self, timeout: float = None): 259 | if not timeout: 260 | timeout = self._timeout 261 | return await asyncio.wait_for(self._connect(), timeout=timeout) 262 | 263 | async def _connect(self): 264 | ws_url = await get_websock_url(self._port, self._host_, timeout=self._timeout) 265 | conn = await SingleCDPSocket(ws_url, max_size=self._max_size) 266 | await conn.close() 267 | return self 268 | 269 | async def __aexit__(self, *args, **kwargs): 270 | for socket in list(self.sockets.values()): 271 | await socket.__aexit__(*args, **kwargs) 272 | 273 | async def close(self, code: int = 1000, reason: str = None): 274 | for socket in list(self.sockets.values()): 275 | await socket.close(code, reason) 276 | 277 | @property 278 | async def targets(self): 279 | return await get_json(self.host, timeout=2) 280 | 281 | async def get_socket(self, target: dict = None, sock_id: str = None, 282 | ensure_new: bool = False, timeout: float or None = 10): 283 | if not (target or sock_id) or (target and sock_id): 284 | return ValueError("expected either target or sock_id") 285 | if target: 286 | sock_id = target["id"] 287 | sock_url = f'ws://{self.host}/devtools/page/{sock_id}' 288 | 289 | existing = self.sockets[sock_id] 290 | if existing and (not ensure_new): 291 | socket = existing 292 | else: 293 | socket = await SingleCDPSocket(sock_url, timeout=timeout, loop=self._loop, max_size=self._max_size) 294 | self._sockets[sock_id] = socket 295 | 296 | # noinspection PyUnusedLocal 297 | def remove_sock(code, reason): 298 | _id = socket.id 299 | try: 300 | del self._sockets[_id] 301 | except KeyError: 302 | pass 303 | 304 | socket.on_closed.append(remove_sock) 305 | return socket 306 | 307 | @property 308 | def host(self): 309 | return self._host 310 | 311 | @property 312 | def sockets(self) -> typing.Dict[str, SingleCDPSocket]: 313 | return self._sockets 314 | -------------------------------------------------------------------------------- /src/cdp_socket/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaliiiiiiiiii/CDP-Socket/4813479d7b856c4a609aa19e6fea80ed9d425445/src/cdp_socket/utils/__init__.py -------------------------------------------------------------------------------- /src/cdp_socket/utils/conn.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import aiohttp 3 | 4 | 5 | async def get_http(url: str, timeout: float or None = 10): 6 | async with aiohttp.ClientSession() as session: 7 | async with session.get(url, timeout=timeout) as resp: 8 | return resp 9 | 10 | 11 | async def get_json(host: str, timeout: float or None = 10): 12 | res = None 13 | while not res: 14 | try: 15 | async with aiohttp.ClientSession() as session: 16 | res = await session.get(f"http://{host}/json", timeout=timeout) 17 | return await res.json() 18 | except aiohttp.ClientError: 19 | pass 20 | 21 | 22 | async def get_websock_url(port: int, host: str = "127.0.0.1", timeout: float or None = 10): 23 | host = f"{host}:{port}" 24 | try: 25 | _json = await asyncio.wait_for(get_json(host, timeout=timeout), timeout) 26 | except asyncio.TimeoutError: 27 | raise asyncio.TimeoutError(f"No response from Chrome within {timeout} seconds, assuming it crashed") 28 | return _json[0]['webSocketDebuggerUrl'] 29 | -------------------------------------------------------------------------------- /src/cdp_socket/utils/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import socket 4 | import subprocess 5 | import sys 6 | from contextlib import closing 7 | 8 | import cdp_socket 9 | 10 | IS_POSIX = sys.platform.startswith(("darwin", "cygwin", "linux", "linux2")) 11 | 12 | 13 | def find_chrome_executable(): 14 | # from https://github.com/ultrafunkamsterdam/undetected-chromedriver/blob/1c704a71cf4f29181a59ecf19ddff32f1b4fbfc0/undetected_chromedriver/__init__.py#L844 15 | # edited by kaliiiiiiiiii | Aurin Aegerter 16 | """ 17 | Finds the chrome, chrome beta, chrome canary, chromium executable 18 | 19 | Returns 20 | ------- 21 | executable_path : str 22 | the full file path to found executable 23 | 24 | """ 25 | candidates = set() 26 | if IS_POSIX: 27 | for item in os.environ.get("PATH").split(os.pathsep): 28 | for subitem in ( 29 | "google-chrome", 30 | "chromium", 31 | "chromium-browser", 32 | "chrome", 33 | "google-chrome-stable", 34 | ): 35 | candidates.add(os.sep.join((item, subitem))) 36 | if "darwin" in sys.platform: 37 | candidates.update( 38 | [ 39 | "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", 40 | "/Applications/Chromium.app/Contents/MacOS/Chromium", 41 | ] 42 | ) 43 | else: 44 | for item in map( 45 | os.environ.get, 46 | ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA", "PROGRAMW6432"), 47 | ): 48 | if item is not None: 49 | for subitem in ( 50 | "Google/Chrome/Application", 51 | "Google/Chrome Beta/Application", 52 | "Google/Chrome Canary/Application", 53 | ): 54 | candidates.add(os.sep.join((item, subitem, "chrome.exe"))) 55 | for candidate in candidates: 56 | if os.path.exists(candidate) and os.access(candidate, os.X_OK): 57 | return os.path.normpath(candidate) 58 | 59 | 60 | def cdp_sock_path(): 61 | return os.path.dirname(cdp_socket.__file__) + "/" 62 | 63 | 64 | def read(filename: str, encoding: str = "utf-8", sel_root: bool = True): 65 | if sel_root: 66 | path = cdp_sock_path() + filename 67 | else: 68 | path = filename 69 | with open(path, encoding=encoding) as f: 70 | return f.read() 71 | 72 | 73 | def write(filename: str, content: str, encoding: str = "utf-8", sel_root: bool = True): 74 | if sel_root: 75 | path = cdp_sock_path() + filename 76 | else: 77 | path = filename 78 | with open(path, "w+", encoding=encoding) as f: 79 | return f.write(content) 80 | 81 | 82 | def read_json(filename: str = 'example.json', encoding: str = "utf-8", sel_root: bool = True): 83 | if sel_root: 84 | path = cdp_sock_path() + filename 85 | else: 86 | path = filename 87 | with open(path, 'r', encoding=encoding) as f: 88 | return json.load(f) 89 | 90 | 91 | def write_json(obj: dict or list, filename: str = "out.json", encoding: str = "utf-8", sel_root=True): 92 | if sel_root: 93 | path = cdp_sock_path() + filename 94 | else: 95 | path = filename 96 | with open(path, "w", encoding=encoding) as outfile: 97 | outfile.write(json.dumps(obj)) 98 | 99 | 100 | def random_port(host: str = None): 101 | if not host: 102 | host = '' 103 | with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: 104 | s.bind((host, 0)) 105 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 106 | return s.getsockname()[1] 107 | 108 | 109 | def launch_chrome(data_dir_path: str, port: int, binary_path: str = None, args: list = None, ): 110 | if not binary_path: 111 | binary_path = find_chrome_executable() 112 | if not args: 113 | args = [] 114 | if IS_POSIX: 115 | args.append("--password-store=basic") 116 | if not os.path.exists(data_dir_path): 117 | os.makedirs(data_dir_path, exist_ok=True) 118 | 119 | args.extend([f'--user-data-dir={data_dir_path}', f"--remote-debugging-port={port}"]) 120 | process = subprocess.Popen( 121 | [binary_path, *args], 122 | stdin=subprocess.PIPE, 123 | stdout=subprocess.PIPE, 124 | stderr=subprocess.PIPE, 125 | close_fds=IS_POSIX, 126 | ) 127 | return process 128 | -------------------------------------------------------------------------------- /tests/check_event.py: -------------------------------------------------------------------------------- 1 | from cdp_socket.utils.utils import launch_chrome, random_port 2 | from cdp_socket.socket import CDPSocket 3 | import os 4 | import asyncio 5 | import aiodebug.log_slow_callbacks 6 | aiodebug.log_slow_callbacks.enable(0.025) 7 | 8 | global sock1 9 | data_dir = os.getcwd() + "/data_dir" 10 | 11 | 12 | async def on_resumed(params): 13 | url = params["request"]["url"] 14 | if url == "https://httpbin.org/post": 15 | print(params) 16 | global sock1 17 | await sock1.exec("Fetch.continueRequest", {"requestId": params['requestId']}) 18 | 19 | 20 | PORT = random_port() 21 | process = launch_chrome(data_dir, PORT) 22 | 23 | 24 | async def main(): 25 | global sock1 26 | 27 | async with CDPSocket(PORT) as base_socket: 28 | targets = await base_socket.targets 29 | target = targets[0] 30 | sock1 = await base_socket.get_socket(target) 31 | await sock1.exec("Network.clearBrowserCookies") 32 | await sock1.exec("Fetch.enable") 33 | sock1.add_listener("Fetch.requestPaused", on_resumed) 34 | await sock1.exec("Page.navigate", {"url": "https://httpbin.org"}) 35 | script = r""" 36 | fetch("https://httpbin.org/post", { 37 | method: "POST", 38 | body: "x%+2\u000f+z\u0017z\n\u00049Y\r\b\u001cO\ubfebI\u0004E~W{\f\u0fef-\u072f[lu;Z%\u0019\u0007C\u0000C&\b\u001b4|'o\u0017\u0003]\u043f\u001a\u0003\u0018\u001fMC\u04f83\u001c\u05e9-?\u0336oq\u000ew\u031b?~\u02bf\u000eY;\u0016t\u06c3\\\"Mo7cQ\u0016?@ \u001f\u001b%w\u0014w<\u000b\fS\fm}\u0003\u059f\u04f1v/i\u001ev\r% '/\u000b\u0439\u0019e\u0005^Lv\u00050u~a\u57da,;\u001d\u000fu-\u0010#Hc.)SOAVl~S\u0011\fMt[\u0014\u01dco[9\u026fy/dvf\u0012A(\r+\b=CS~\u0014CO\u0c01>\u000bo]D1\u0001D\u0013\u000b\u0004\u0002IFUi\u079e`K5o\u000bF\u0007\u0011vBH\u0002F$\u0004\t5 \u0006y>4oqaqJ;vI<\u0017\t\u0012j\u0505\t\u0012eH`\ua651VO)NN\u001fkF/.rmV+\tZz\u000f^%f$\u01833D,yK\u0007v\u05d6a\n\u001dc\\&\"\u000b6\u0018OSMG`,-\b\b\u0678tF=S\u001ak]gxx\u0017Sl;\u0012Fc\u0003\u0000\u0002roF\ue50e\u00038YI\u05c30\r\u001e,r\u0349\u04ba\f1\u000f\u0013y\u04f7LT&:J'\u001c\u0013\u060d$\u03e3D\u0012t\u0011#[j\u000e\u00dd\u022chKe\u0000%:o\u0018n)\u001dC/WZf\u88c8\u0015\u1ecaj\u001ck3|(6\u07d4\u0015\"Zc\"\u9a6b\u0017gJg-\u0013%k{$\u001a&s_<\u001dB\u0012\u001cDY\u0016=\u000f/\u0018\u03179}VDxyVv\u0004\u0018U\u0013\u00054>Bl$x4\u0002\u000fF{\u0011\u000f\bYfYC>\u0011\u0007\u0005\u000f\u0565f\u0006\n\u0106\u0000/t\u0011CtGS-^\u000bHQX8K2~\nPwZ\u001b\u00cfcW> 7\u056d9|FJt:\u06a4#@D\"`KbB\r\u0011m{ie_Pk+Vgy\u001dc\u07d0p16;7l\u0003\te:K\u000eP7e\uc41c%c\u0010Ro\u0010\u0011D<\u0017n2O3p\u000b}2\u001e+LmA \u001e\u0015^9)\u0018\u0018sYUwi1u~\fdIU\u001bls2CW\\>J/'\u001e\u0016^\u0000'7#\u001bp8z\u0015e\u0169J\u00f5\\RCI%[RWkje6X_\f1\u060eP|8]\u0006PNp>0svI7F$IS\u0011:\u001fwI|Y*Q8\\\tdFT+>*\u0002~\\4\u0007[\u0003rRx?}l\n]l\bfTb\u001f\u0016A\u0794\u001a\u0003S;JFT,U\u001d\u0016tC\u072c}a@[4v5kyxtm\u041ce\u001c\u001f\u0011\u0011\\.9|\u001f\u000e\u0000\u0011C\u0004/\u0004$\u0001Z\nP\u0596#\b.>^:-*h9Gr\u0007\u0011\\\u1a34\u0015ai\u0473A\u0013\u0001E@!B\u0004sbqo3I\u0012g\rE+C\n\u001f\u00113\"\u001fB\f+]\rBuOFB\u000b3i\u00147At{\u001a\u0460}|Op7F\u0234\u0647\u001en\u0002H}1o\u001bX\t4JxtV3\u001dv\u00076F\u0018'0~OG9-\u0003QY'5\u0015D`'\u0011g\u0016QwGL`\bTu}G\u5035\u03ab:Es\u0006g\\\u019e8et_d[\u03982onoh\"\r{H>\u0011KL\u00066\u0017\rimPjri\u001c*4\u001e>&&\\\"Z1Q@\u0007=Ee#\u00161\u0016MJO\u001bx\u0016aPWMcX\u046b\u0013.W0}\u0002wy\u0445'ZyV&\u0011\u001f*%\u0007?\u0002>\f16M\u0015\u001eH!sg\u0001\u0018f1?k5JP/W\t\u058a\u0011\r\u0005hk.omrXe`x?a\u0017\u0013D9l\u0004v`Y\u02f0\u001b8)\u0011d\u000f&\u0013\u05d0na\u000e\u0019t\u011b5`\u000e\u0006`\u001eyt\u000e\u0007|gI\u0707aqNh[\t;\n\u0002\u0210u\u0015xEw)\u0002g\u0013L\u02cfIA\u001dmJa?]~\u0013Ua\tGh\bTY\u0012M\u5d69)a@6\fC'\u0015za\u0383pL1\u0015\u00166IzFNPsQ\u02dcf\u0017,\u0003\u0013yG\u0000\tQ\">\u0010iM\u00055O~s\\c\u0005q\u062e8hFvs\u000fW |1Z\u000fww1iv\"\"{B]/j\u0011\u0014J9\u001f\t\u0010)e\u0013\u0011^m\u0001Kd@H!3]O|j;\u00189Phk\u0727=t\u001eGlteq[6E\u0002\u0000Z\t]}_*2sL\u0001\u0007:8S/[u\u0012=\u0010SXU#`\u0007k\u0018Y%,-Xa~c\u001c/\u001dITjJR\u001a\u0001,0tT\u03e5\u0011'M\u0006zD\u000e[f|K\u0003t\\4\u0005\u000ez}\u0013\t/!~\u001b8\u0001$/\u000b\u0016D\u00fe\u001e_\u0019\u0014:D\u02fd0V7/\u0017\u0007*\u0017\r0\u0000e0O'\"y\f\u001d\u0016t-X$\u0001an8N*\\\u001a\u0010l>0A.\u0019\tiQ74vs$pb\u00178Z#zuD9[\u0018\u04f1\u0015cLs(\fT\u0000felNu\u0470p\"Sc|\u0004'OtO()\"\u001c\u0011Y\u04db\u001et y\u001f;N\u001ecs\tj\r:2uE<%&#\u001a\u001aA\u000f#0m\u03249H@7s '2\u0014]\u0015>c|8/NQ\u0005/\u0016\u0407-\u5cab\bd_\u043a[d\u0002_#XN\\\u0004Zt.8#l:vA$z680_\u000b;'V$M\u0013fkqoT \u001alcD7\u000bP\u0012i\u0011 \u0016\u02ddgo\u001c\u0012%HO\u07b9\u0140ifw8\u060b\u0010\u001f<'#\u069ad\t[R\u001314\u0001uTdSm7\u0011\u0018E~+\u0007cp[9+)PD.SLCJIqG\u0011?p\bu\u0019!8R40\u000bk~'\u0010S\u001eq\u0010/gCXa\\:):q4^\u0003x2\b8\u0017MB*N?\u0018\u0006u\u056d<0c\u0011z<\u001a\u001fo~\u001c\ub68d\u52290>\bN;\u0012~7'|<(NQT>\u0004F\u021f[\u000eRsb&D/\u0000BQx\u001c{\u0004Z\u049c\u001b\u0019Ad\u0001NX.\u001b!\u0014\b\rX\u001fjTwre\n3u\u0019\u0014E7xk\u075ai8Un82$\u001c@\u0004N%|\u0516\u06be\u0012\u0010<\u066daz!f^|\u0666#0\"b\u0783\u001bb\u001a\u06db~75|Up30 \u0011>C\u0015/w'\u001b/fq\u0004p=i\"P=\u01d2\u0004Gy Y*`($\u0018U6\u0348-}M%\u001b-4[FQoz4~qTv89)uG/w\u0004AwWk\"?\ti|XN+\u01ffN9\u0005\u0004,__Rw\u06dd:^\u00041_uPEIb&6HO3\\v[^\u0011\"s|*XZ'yo$\u000e\u00166`lJ`\u000b\u0004YmX\b\u0440\\\u0012b\u00016\u0001K\u0631s@\u00055i\\\u000ef;?e\u001c\u0006uG\r\u0015RFzAn\b`HF\u0730DJIlE\u001a\u0013\u0015\\\u001aOa8\ua67e\u0010\u001e_;^7@F4jozS,\u0005\u00fb%1V\bN>D;h\u0012@CLn0&u\u0002O\u0005D\b*4]_6B:'o[VF1\u0012~Md\u0004\u05d2\u0409\u079cut|[\u000brn6,mZxYX:.L\u0780?\u0015Kkbl\u02b7/Pcf\u0016xzxwje?$\\9B.\u0013-\u0000qj3N\u0007\rWz'\u0017\u02289]3Go\u0415{W(%\n1\u000f7S}P\u00e0svQ8%\u04c1d_lC\u001f9\u0013>\u0011\u0004\u0015jT_\u06c2EG\u000e4\r\u001d?\u0004[V\u0014=5ZD\u000f{v]c\u0011YoT\u001d@ct<\u06b3*\u0013+o[.\u01fd\u078d\u000br\u042e\u001d\u0019-;\ua431#\u07fa1\f\u0017B-\u0010D\u001e<\u0014z\u001d\u001a\u0003\u06c3&ZNLX9\u000b@.*\u018c\bne\u0012p\u843aQ\u0013d(\"\u0014|~\u0019^\u0003[\u0011|\u0010a>%,-J(0v\u001e\u0016j|\u0016kZ1\\FV'\u0014tu+)Su1TJ#\u0019HwdUj\u0656P\u0017/+2w\u000b&5:xGd~.U;X_NO\u000f#]\u001dqaC\u0016\u000bBZj^7cy\u03b7\u000e=0BzT\u00172\u001b2rHo~\u0003A\u0017ZJ\u000f\u0016\u001bWb\u0003^'\u001c]!\u05c9p\u0002(|8Fz\u000e\rjZ\u000eTNbjg\u0002\"m*nR@e[hF\u0018#y\u0411 VA\f\u001ba\\]O\u0005c {6jj\u0015,?V\fYS\u00029\u07056C(\u001fUR\u0010v\u0002ur=\u0011\u0011ddx\u0549s1-o\u001b\u057b\u001d,B\u01b1h\u0007Rs/w,\u0006x<\u0019_U=&\u0007\u0012\u001c~Wc*\u0013~E^UPLOJHR*>=7&u\u0013f!7\u001dt1R\u0012\u037c:8\u0000\u001dO]e`\u0014\u001e=5E.:\\-\u001e?zz*\u0001\u0376n\u0006C'\u00115\u001f\u001el\u0003\u000b\u0005\u07f8\u045d\b2\u0019\tV1M@:=\u0019tCfC6\u001fAI\u0006\u9920\u00106,\u000b\u0012i\u0005\u001c+3\u0003i\u0255\u0003/Q\u6cdfif\u00018os^I\u0017HRxvQCFS\u0003(-\u00004jI\u000b\u0000I\u00034Y`a\u00b7ZQ\u0002\u069enl\u059aI\udee64?D@#ph\u043ee@\u0261\u001eG>E1FjGF\u0018\u05d8?q\u000bWt\u001fFW\u0012n= \u001aG\t7q}\u01b4\u0601AH-y k=\u0019wVze.h0\u0013f$S\u001fQE\u0013m\u0015<[Z5m5mZJ%(,s\u05afI_<#A*l-.zK_\u0017]CU\u0697-$\u000bZWz>\u001103,/\u05dc-s2,F=\u001c\b\u0011\u001f#\u001fXBRJ\"\u00176n+\u001a&X.H#7#\u0013S>\u0005Qk+}U\u02476T\u0001uQY\fl\u0216a\t,\u0002\u02a2f[\u000f\t%W\u001c)\u0013g4`\u001f?G'4$\u03a4NOsP/c\u0010\u00124w{f\u016c\u00195$7\udbf6\ude05i\u0011G]-1MfP\u010c_MxXy#s(07}=g7rju=\\i\ufdf1HZ{cDJ\u0001&K\u01e6s3yL.yWt(l`3_+\u01d7/,n\u0014\u00016\u0014\u0006\u063a:vzn3-<7\u001e\u0004pP@o\n31o\uf5ebQE>A\u0005^?m4]\u001bU\u07be\u000fr-\r>\u000f\\\u01ab6q&!xc'N .\u0018f\\\u0000\n\u000btiY9!J+69H&\u0007\t[VeXNeh\\\u03dd\u0017 \u001e\u0001Aecx\u0389\u03bb0S(s-\u001c\\::\u0000}O2pUKz`\fUC\u0015\u0002*~\u0011~E<}>\u05adh\u0017;;Ek\u05f9\u0004\u02c2K\u001c\u001f?#]),\u0006UuHXbYSOE/9\u027bn\u06f7>1\u00185\u001e5'hpK\n\u0002\u0005`icX\u001d\u03c0\u0794e\u001dx4\u0011/\f3Q\u0002\u077dr\rf\u001fy.8}P\u071dr%\u000fCe\u0004y1hV>Jan\f\u001c\u0013So5tTm#\u0015\u000ffl)!P+xn$\u0004Su)Y6*a\u0019DT\u001e;4x9wtD\u0014'|\u001c\u0010B'}CYV3VV\u0010h\rAY\bR\u0014N(\u062c\u00154\\\nmu7o-kN\u000f\u00ebe<5\u000eH8Nj\u0013\u001duFkyq`\u001a\\EZ+~UG\"7MC\u02c7Z\u000b5\u0006s$)\u0016\u001e|5T\t\u07b58DZ)NIe\u0016\u001aC}\u023a\u02ee\u001cBv\u0016\u0005iaz(\u000e|ea2j\"\u0016^Ke$y=7'S b3G\u0149\rQV\tiE\u045cC\u97cdi\u0095\u0017l\u0000\u03bd+b=\u000f\u07bc\r^l_L~Hu.\nl9^&P$G0\u001d5\u05fds\u0017\\GWf\u0019gy4&?Z\u0006\u001e@6c<*78;\u06f1qwPHXm\u000b|T\u0400=\u0000N +\u0014\u001d\u010d\u001fDN\u001d!bU}t\u0007Eykx68H\u001f\u0014\u0019\u0007\u0014@r\u001ew~o\u001a'\u0565\u000b3B\u0014\u000f\u0345(_n\u16e3}ij\u0010H\u000ecv1\u001fO:\u0004pzPR%Q\u0005)> \u0010#gp\u0018!Kf\u0019\f\u0004J\t\u000b\u0001v\u0310t\u06f6\u001b|j\u0003QB\u0006g\u0744\u00132g\u035b\nW~K2dGs\u0002B}Q\bamF0\r:O}w\u0003N6k\n\n\u0017\u001ddOtUE\b\u0004i\u0004J>0IO\f\u0018i8\u021f\bK,i6S4M=;>M\u0002Q!\"\u0004\u02b2n&u7V\rUn~|\r\u001eA\u0014+\u0015\u0010(a\nd\u0002@Z\u000b[n}\u001c1R\u0011g(d@+zusiU\u00134wt9\u0019I\u001cn|.2-\u0003\u000b\u00f0,\t\u0017X\u001ejdmg!~s\u05e8%\u0676\r{\u0013\b}\u001c\u047b\u0019>b6-}5\u0002I\u0005Tl\u001c\u0000%\u0018[q.;\u0007X@\u000f_)0WGf+;W^+6.\u000e1\b%\ud990bOg,\u0148k]q\u001f\u0007\u000e\u000e\u0019\u001a~@:;>!J7-J+_o&\u001b\u051c\u0017=u:`\u0011D:r)n#*W0AgUi3\bm:EYB]F\u070923t}\nA_\u001d3\u00180\u0001~b\u0001/<.]+-\u0017ha\u0004sz6F#\u0001U%:\u001f\u001e\b=IcNTT>n\u0002Olu\u001bec{\u06b7\u001b\u07eeh\u000ew\u000fy\u0012\u0006Gp31PrC3ld\bM\u0010%4|:\u0005_l{\u001dczDh\u0005\u3e9dd\u000b1]XH5!k\u0017;\u000b\u0017ZzRE\u0543AP6l'O\"\u0003:B\u042e\u001bJgHz{z\u02eb\u001f5\u00194 \u0011-*7({z|\u07ba\u0004\u0014>\u0007%\u07a5\u01d1\u0002l\f~\u00022i7KW$Qucv<)X~u7\u001ePwM0;\u0017\u001dPj@%\u0004u>*q;\u064a)BH\u001dD\u0015\u0014'\u011c\u0018\u01b7\u90f6mk3o\u001cn\u0010Ji!6u\u001f-C\u0011A\u0003c\u001f\u03fb\f\t!k:\u0000$\u001e\u00e6\u07e0Pc 1\u000f$\u039bx\u001eF\u001a`+\rD\t\u000b/.\r\u0016~\u0017N{L{5OV`\u054cA`yN\u051cW+k$\u0263~2E\uef12ZL\u00e2\u001d\u0016\u0010U\u0007\u0234\u0013>wT\u001c\u6d45P&=Db\u000exm\u0015(>8@Tf(\u0007\u011cTXm3:q\r\rGin=5\nD)r\u0015hV\b[]&\"\"~lvv\u0002\t\u05f1{UdES)R2\r\\\u0003\f\u0018fW}d\u05751m4i\u0004FF~E?/\u0769HA*Z,bs\t@XW\u0012Pp-\u02abgj\u000f+\u0018#xHAo\t\u0327Jb\u001c%Onh\u001b3\u0013!e&x#9j(\u0410En\u001f\u0006)h-T170\u0000l\u0017\" \b.\u000b$DP\u001c\u0006n79DnmeIuqD\u001bgaG\u01a90_\u001d)x(2 e|8]\u0018+PghYje(\u000e,2786\f \u0006\u0001\u001bmUS^\u0007$\u0006Ecgx3\u6237,|\u0002\u000e\u00079<\r~EQAO\u000eJ\u0019Ok\b \u0012/\u0012*[4_Y&5+\t>2\u012d\u0015h]t\u001aYT@j\u0013&RM7$Th]-G\"Y6+=\u012dQB.R