browserfetch 0.11.0__tar.gz → 0.13.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {browserfetch-0.11.0 → browserfetch-0.13.0}/PKG-INFO +1 -1
- {browserfetch-0.11.0 → browserfetch-0.13.0}/browserfetch/__init__.py +31 -26
- browserfetch-0.13.0/browserfetch/browserfetch.egg-info/PKG-INFO +71 -0
- browserfetch-0.13.0/browserfetch/browserfetch.egg-info/SOURCES.txt +9 -0
- browserfetch-0.13.0/browserfetch/browserfetch.egg-info/dependency_links.txt +1 -0
- browserfetch-0.13.0/browserfetch/browserfetch.egg-info/not-zip-safe +1 -0
- browserfetch-0.13.0/browserfetch/browserfetch.egg-info/requires.txt +2 -0
- browserfetch-0.13.0/browserfetch/browserfetch.egg-info/top_level.txt +1 -0
- {browserfetch-0.11.0 → browserfetch-0.13.0}/browserfetch/browserfetch.js +7 -2
- {browserfetch-0.11.0 → browserfetch-0.13.0}/pyproject.toml +15 -0
- browserfetch-0.11.0/tests/test_init.py +0 -25
- {browserfetch-0.11.0 → browserfetch-0.13.0}/LICENSE +0 -0
- {browserfetch-0.11.0 → browserfetch-0.13.0}/README.rst +0 -0
- {browserfetch-0.11.0 → browserfetch-0.13.0}/browserfetch/__main__.py +0 -0
|
@@ -1,17 +1,16 @@
|
|
|
1
|
-
__version__ = '0.
|
|
1
|
+
__version__ = '0.13.0'
|
|
2
2
|
__all__ = [
|
|
3
3
|
'BrowserError',
|
|
4
|
+
'Response',
|
|
4
5
|
'evaluate',
|
|
5
6
|
'fetch',
|
|
6
7
|
'get',
|
|
7
8
|
'post',
|
|
8
|
-
'Response',
|
|
9
9
|
'start_server',
|
|
10
10
|
]
|
|
11
11
|
import atexit
|
|
12
12
|
from asyncio import (
|
|
13
13
|
AbstractEventLoop,
|
|
14
|
-
CancelledError,
|
|
15
14
|
Event,
|
|
16
15
|
Task,
|
|
17
16
|
get_running_loop,
|
|
@@ -21,8 +20,9 @@ from collections import defaultdict
|
|
|
21
20
|
from dataclasses import dataclass
|
|
22
21
|
from functools import partial as _partial
|
|
23
22
|
from html import escape
|
|
24
|
-
from json import dumps as _dumps, loads
|
|
23
|
+
from json import dumps as _dumps, loads as _loads
|
|
25
24
|
from logging import getLogger
|
|
25
|
+
from typing import Any as _Any
|
|
26
26
|
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
|
27
27
|
|
|
28
28
|
from aiohttp import ClientSession, ClientWebSocketResponse
|
|
@@ -72,8 +72,8 @@ class Response:
|
|
|
72
72
|
|
|
73
73
|
def json(self, encoding=None, errors='strict'):
|
|
74
74
|
if encoding is None:
|
|
75
|
-
return
|
|
76
|
-
return
|
|
75
|
+
return _loads(self.body)
|
|
76
|
+
return _loads(self.text(encoding=encoding, errors=errors))
|
|
77
77
|
|
|
78
78
|
|
|
79
79
|
def extract_host(url: str) -> str:
|
|
@@ -123,7 +123,7 @@ async def receive_responses(ws: WebSocketResponse | ClientWebSocketResponse):
|
|
|
123
123
|
while True:
|
|
124
124
|
blob = await ws.receive_bytes()
|
|
125
125
|
json_blob, _, body = blob.partition(b'\0')
|
|
126
|
-
j =
|
|
126
|
+
j = _loads(json_blob)
|
|
127
127
|
j['body'] = body
|
|
128
128
|
event_id = j.pop('event_id')
|
|
129
129
|
try:
|
|
@@ -151,10 +151,21 @@ async def _(request):
|
|
|
151
151
|
)
|
|
152
152
|
logger.info('registering host %s', host)
|
|
153
153
|
|
|
154
|
-
|
|
154
|
+
ws_or_event = hosts[host]
|
|
155
|
+
if not isinstance(ws_or_event, Event):
|
|
156
|
+
await ws.send_bytes(
|
|
157
|
+
_dumps(
|
|
158
|
+
{
|
|
159
|
+
'action': 'close_ws',
|
|
160
|
+
'reason': f'a host with the name `{host}` is already registered',
|
|
161
|
+
}
|
|
162
|
+
).encode()
|
|
163
|
+
)
|
|
164
|
+
await ws.close()
|
|
165
|
+
return ws
|
|
166
|
+
|
|
155
167
|
hosts[host] = ws
|
|
156
|
-
|
|
157
|
-
ws_or_e.set()
|
|
168
|
+
ws_or_event.set()
|
|
158
169
|
|
|
159
170
|
try:
|
|
160
171
|
await receive_responses(ws)
|
|
@@ -175,7 +186,7 @@ async def _(request: Request) -> WebSocketResponse:
|
|
|
175
186
|
except TypeError: # ws closed
|
|
176
187
|
return ws
|
|
177
188
|
data, null, body = bytes_.partition(b'\0')
|
|
178
|
-
data =
|
|
189
|
+
data = _loads(data)
|
|
179
190
|
relay_event_id = data['event_id']
|
|
180
191
|
|
|
181
192
|
try:
|
|
@@ -231,14 +242,18 @@ async def relay_client(server_host, server_port):
|
|
|
231
242
|
|
|
232
243
|
|
|
233
244
|
async def evaluate(
|
|
234
|
-
|
|
245
|
+
expression: str,
|
|
246
|
+
/,
|
|
247
|
+
*,
|
|
235
248
|
host: str,
|
|
236
249
|
timeout: int | float = 95,
|
|
250
|
+
arg: _Any = None,
|
|
237
251
|
):
|
|
238
252
|
"""Evaluate string in browser context and return JSON.stringify(result)."""
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
253
|
+
data = {'action': 'eval', 'string': expression, 'timeout': timeout}
|
|
254
|
+
if arg is not None:
|
|
255
|
+
data['arg'] = arg
|
|
256
|
+
d = await _request(host, data, None)
|
|
242
257
|
return d['result']
|
|
243
258
|
|
|
244
259
|
|
|
@@ -267,7 +282,7 @@ async def fetch(
|
|
|
267
282
|
url = urlunparse(
|
|
268
283
|
parsed_url._replace(
|
|
269
284
|
query=urlencode(
|
|
270
|
-
parse_qsl(parsed_url.query)
|
|
285
|
+
[*parse_qsl(parsed_url.query), *params.items()]
|
|
271
286
|
)
|
|
272
287
|
)
|
|
273
288
|
)
|
|
@@ -352,18 +367,9 @@ app.add_routes(routes)
|
|
|
352
367
|
app_runner = AppRunner(app)
|
|
353
368
|
|
|
354
369
|
|
|
355
|
-
def _shutdown_server(loop: AbstractEventLoop):
|
|
356
|
-
logger.info('waiting for app_runner.cleanup()')
|
|
357
|
-
loop.run_until_complete(app_runner.cleanup())
|
|
358
|
-
|
|
359
|
-
|
|
360
370
|
def _cancel_relay_task(loop: AbstractEventLoop, task: Task):
|
|
361
371
|
logger.info('cancelling relay task')
|
|
362
372
|
task.cancel()
|
|
363
|
-
try:
|
|
364
|
-
loop.run_until_complete(task)
|
|
365
|
-
except CancelledError:
|
|
366
|
-
pass
|
|
367
373
|
|
|
368
374
|
|
|
369
375
|
_server = False
|
|
@@ -389,5 +395,4 @@ async def start_server(*, host=_host, port=_port):
|
|
|
389
395
|
atexit.register(_cancel_relay_task, loop, relay_task)
|
|
390
396
|
else:
|
|
391
397
|
_server = True
|
|
392
|
-
atexit.register(_shutdown_server, loop)
|
|
393
398
|
logger.info('server started at http://%s:%s', host, port)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: browserfetch
|
|
3
|
+
Version: 0.1.1.dev0
|
|
4
|
+
Summary: fetch in Python using your browser!
|
|
5
|
+
License: GNU General Public License v3 (GPLv3)
|
|
6
|
+
Project-URL: Homepage, https://github.com/5j9/browserfetch
|
|
7
|
+
Keywords: browser,fetch,python,cookies
|
|
8
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Description-Content-Type: text/x-rst
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: aiohttp
|
|
13
|
+
Requires-Dist: pyperclip
|
|
14
|
+
|
|
15
|
+
Fetch using your browser.
|
|
16
|
+
|
|
17
|
+
Let the browser manage cookies for you.
|
|
18
|
+
|
|
19
|
+
⚠️ This project is a very simple implementation. Not tested thoroughly. Consider it a proof of concept.
|
|
20
|
+
|
|
21
|
+
Usage
|
|
22
|
+
-----
|
|
23
|
+
1. You'll run a Python script containing some code like this:
|
|
24
|
+
|
|
25
|
+
.. code-block:: python
|
|
26
|
+
|
|
27
|
+
from asyncio import gather, get_event_loop
|
|
28
|
+
|
|
29
|
+
from browserfetch import fetch, run_server
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def main():
|
|
33
|
+
response1, response2, reponse3 = await gather(
|
|
34
|
+
fetch('https://example.com/path1'),
|
|
35
|
+
fetch('https://example.com/image.png'),
|
|
36
|
+
fetch('https://example.com/path2'),
|
|
37
|
+
)
|
|
38
|
+
# do stuff with retrieved responses
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
loop = get_event_loop()
|
|
42
|
+
loop.create_task(main())
|
|
43
|
+
run_server(loop=loop)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
2. Open your browser, goto http://example.com (perhaps solve a captcha and log in).
|
|
47
|
+
3. Copy the contents of `browserfetch.js`_ file and paste it in browser's console. (You can use a browser extensions like violentmonkey_/tampermonkey_ to do this step for you.)
|
|
48
|
+
|
|
49
|
+
That's it! Your Python script starts handling requests.
|
|
50
|
+
The browser tab should remain open of-coarse.
|
|
51
|
+
|
|
52
|
+
The server can handle multiple websocket connections from different websites simultaneously.
|
|
53
|
+
|
|
54
|
+
How it works
|
|
55
|
+
------------
|
|
56
|
+
``browserfetch`` communicates with your browser using a websocket. The ``fetch`` function just passes the request to browser and it is the browser that handles the actual request. Response data is sent back to Python using the same WebSocket connection.
|
|
57
|
+
|
|
58
|
+
Motivations
|
|
59
|
+
-----------
|
|
60
|
+
* `browser_cookie3 stopped working on Chrome-based browsers`_. There is a workaround: ShadowCopy, but it requires admin privilege.
|
|
61
|
+
* Another issue with browser_cookie's approach is that it retrieves cookies from cookie files, but these files are not updated instantly. Thus, you might have to wait or retry a few times before you can successfully access newly set cookies.
|
|
62
|
+
* ShadowCopying and File access are slow and inefficient operations.
|
|
63
|
+
|
|
64
|
+
Downsides
|
|
65
|
+
---------
|
|
66
|
+
* Setting up ``browserfetch`` is a more cumbersome since it requires running a Python server and also injecting a small script into the webpage. Using ``browser_cookie3`` might be a better choice if there are many websites that you need to communicate with.
|
|
67
|
+
|
|
68
|
+
.. _`browser_cookie3 stopped working on Chrome-based browsers`: https://github.com/borisbabic/browser_cookie3/issues/180
|
|
69
|
+
.. _tampermonkey: https://github.com/Tampermonkey/tampermonkey
|
|
70
|
+
.. _violentmonkey: https://github.com/violentmonkey/violentmonkey
|
|
71
|
+
.. _browserfetch.js: https://github.com/5j9/browserfetch/blob/master/browserfetch/browserfetch.js
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.rst
|
|
3
|
+
pyproject.toml
|
|
4
|
+
browserfetch/browserfetch.egg-info/PKG-INFO
|
|
5
|
+
browserfetch/browserfetch.egg-info/SOURCES.txt
|
|
6
|
+
browserfetch/browserfetch.egg-info/dependency_links.txt
|
|
7
|
+
browserfetch/browserfetch.egg-info/not-zip-safe
|
|
8
|
+
browserfetch/browserfetch.egg-info/requires.txt
|
|
9
|
+
browserfetch/browserfetch.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -57,13 +57,13 @@
|
|
|
57
57
|
evalled = eval(req['string']);
|
|
58
58
|
switch (evalled.constructor.name) {
|
|
59
59
|
case 'AsyncFunction':
|
|
60
|
-
evalled = await evalled;
|
|
60
|
+
evalled = await evalled(req['arg']);
|
|
61
61
|
break;
|
|
62
62
|
case 'Promise':
|
|
63
63
|
evalled = await evalled;
|
|
64
64
|
break;
|
|
65
65
|
case 'Function':
|
|
66
|
-
evalled = evalled();
|
|
66
|
+
evalled = evalled(req['arg']);
|
|
67
67
|
break;
|
|
68
68
|
}
|
|
69
69
|
resp = { 'result': evalled, 'event_id': req['event_id'] };
|
|
@@ -117,6 +117,11 @@
|
|
|
117
117
|
var /**@type {Uint8Array | Blob} */ result, j, b;
|
|
118
118
|
[b, j] = parseData(evt.data);
|
|
119
119
|
switch (j['action']) {
|
|
120
|
+
case 'close_ws':
|
|
121
|
+
console.debug(`websocket closed. reason: ${j["reason"]}`)
|
|
122
|
+
ws.onclose = null;
|
|
123
|
+
ws.close();
|
|
124
|
+
return;
|
|
120
125
|
case 'fetch':
|
|
121
126
|
result = await doFetch(j, b);
|
|
122
127
|
break;
|
|
@@ -40,13 +40,21 @@ lint.extend-select = [
|
|
|
40
40
|
'FA', # flake8-future-annotations
|
|
41
41
|
'I', # isort
|
|
42
42
|
'UP', # pyupgrade
|
|
43
|
+
'RUF', # Ruff-specific rules (RUF)
|
|
43
44
|
]
|
|
44
45
|
lint.ignore = [
|
|
45
46
|
'E721', # Do not compare types, use `isinstance()`
|
|
47
|
+
'RUF001', # ambiguous-unicode-character-string
|
|
48
|
+
'RUF002', # ambiguous-unicode-character-docstring
|
|
49
|
+
'RUF003', # ambiguous-unicode-character-comment
|
|
50
|
+
'RUF012', # mutable-class-default
|
|
46
51
|
]
|
|
47
52
|
|
|
48
53
|
[tool.pytest.ini_options]
|
|
49
54
|
addopts = '--quiet --tb=short'
|
|
55
|
+
asyncio_mode = "auto"
|
|
56
|
+
asyncio_default_test_loop_scope = "session"
|
|
57
|
+
asyncio_default_fixture_loop_scope = "session"
|
|
50
58
|
|
|
51
59
|
[tool.pyright]
|
|
52
60
|
typeCheckingMode = 'standard'
|
|
@@ -60,3 +68,10 @@ reportInvalidStringEscapeSequence = false
|
|
|
60
68
|
reportConstantRedefinition = 'error'
|
|
61
69
|
reportTypeCommentUsage = 'warning'
|
|
62
70
|
reportUnnecessaryComparison = 'warning'
|
|
71
|
+
|
|
72
|
+
[dependency-groups]
|
|
73
|
+
dev = [
|
|
74
|
+
"playwright>=1.52.0",
|
|
75
|
+
"pytest>=8.4.0",
|
|
76
|
+
"pytest-asyncio>=1.0.0",
|
|
77
|
+
]
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
from contextlib import suppress
|
|
2
|
-
from unittest.mock import Mock, patch
|
|
3
|
-
|
|
4
|
-
from browserfetch import fetch
|
|
5
|
-
|
|
6
|
-
request_mock = Mock()
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
@patch('browserfetch._request', new=request_mock)
|
|
10
|
-
async def test_params():
|
|
11
|
-
with suppress(TypeError):
|
|
12
|
-
await fetch(
|
|
13
|
-
url='http://stackoverflow.com/search?q=question',
|
|
14
|
-
params={'lang': 'en', 'tag': 'python', 'q': True},
|
|
15
|
-
)
|
|
16
|
-
request_mock.assert_called_once_with(
|
|
17
|
-
None, # host
|
|
18
|
-
{
|
|
19
|
-
'action': 'fetch',
|
|
20
|
-
'url': 'http://stackoverflow.com/search?q=question&lang=en&tag=python&q=True',
|
|
21
|
-
'options': None,
|
|
22
|
-
'timeout': 95,
|
|
23
|
-
}, # data
|
|
24
|
-
None, # body
|
|
25
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|