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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: browserfetch
3
- Version: 0.11.0
3
+ Version: 0.13.0
4
4
  Summary: fetch in Python using your browser!
5
5
  Keywords: browser,fetch,python,cookies
6
6
  Requires-Python: >=3.11
@@ -1,17 +1,16 @@
1
- __version__ = '0.11.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 loads(self.body)
76
- return loads(self.text(encoding=encoding, errors=errors))
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 = loads(json_blob)
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
- ws_or_e = hosts[host]
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
- if isinstance(ws_or_e, Event):
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 = loads(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
- string: str,
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
- d = await _request(
240
- host, {'action': 'eval', 'string': string, 'timeout': timeout}, None
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) + [*params.items()]
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,2 @@
1
+ aiohttp
2
+ pyperclip
@@ -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