From beacca6bb03dd754d57aed85cca0d702c057d216 Mon Sep 17 00:00:00 2001 From: break27 Date: Thu, 21 May 2026 18:01:03 +0800 Subject: [PATCH] update: pivot to http protocol --- pyproject.toml | 8 +- src/common/actionflow.py | 9 +- src/common/jsonrpc2/__init__.py | 27 +---- src/common/jsonrpc2/client.js | 62 +++++------ src/common/jsonrpc2/server.py | 182 +++++++++++++++++++------------- src/common/{utils => }/timer.py | 0 src/common/utils/__init__.py | 10 +- src/common/utils/selenium.py | 39 +++---- 8 files changed, 171 insertions(+), 166 deletions(-) rename src/common/{utils => }/timer.py (100%) diff --git a/pyproject.toml b/pyproject.toml index d2152b2..4e02311 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,16 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "common" -description = "Reusable code stubs" -version = "0.1.14" +description = "Commonly reusable code" +version = "0.1.15" requires-python = ">=3.13" authors = [ { name="BreakerBear", email="breakerbear@autistic.men" }, ] -dependencies = [ - "trio", - "trio-websocket", -] [project.optional-dependencies] selenium = ["selenium"] diff --git a/src/common/actionflow.py b/src/common/actionflow.py index 5b37d73..398dcd2 100644 --- a/src/common/actionflow.py +++ b/src/common/actionflow.py @@ -31,10 +31,11 @@ class ActionFlow: self.on[index] = bool(self.actions[index].prepare()) def react(self): - for index in self.indices.values(): - if (self.on[index]): - self.on[index] = None - self.on[index] = bool(self.actions[index].perform()) + while any(self.on): + for index in self.indices.values(): + if (self.on[index]): + self.on[index] = None + self.on[index] = bool(self.actions[index].perform()) def index(self, action: type[Action]) -> int: name = action.__name__ diff --git a/src/common/jsonrpc2/__init__.py b/src/common/jsonrpc2/__init__.py index 57ddd03..188696e 100644 --- a/src/common/jsonrpc2/__init__.py +++ b/src/common/jsonrpc2/__init__.py @@ -1,30 +1,11 @@ from common.jsonrpc2.server import ( - Request, - Response, - Error, History, - Options, - define, - remove, - run, + RequestHandler, + ServiceProvider, ) -def prelude() -> str: - with open(__file__.replace('__init__.py', 'client.js'), 'r') as file: - from common.jsonrpc2.server import server - assert server is not None - endpoint = server.listeners[0] - url = f'ws://{endpoint.address}:{endpoint.port}' - return file.read() % url - __all__ = [ - 'Request', - 'Response', - 'Error', 'History', - 'Options', - 'define', - 'remove', - 'run', - 'prelude', + 'RequestHandler', + 'ServiceProvider', ] diff --git a/src/common/jsonrpc2/client.js b/src/common/jsonrpc2/client.js index 86d4d42..428e222 100644 --- a/src/common/jsonrpc2/client.js +++ b/src/common/jsonrpc2/client.js @@ -1,55 +1,45 @@ -const CONNECTION = new WebSocket("%s"); -const QUEUE = new Array(); +export default new class extends Function { + constructor() { + super('...args', 'return this.get(...args)'); + return this.bind(this); + } -CONNECTION.addEventListener('message', (e) => { - let message = JSON.parse(e.data); - let index = QUEUE.findIndex(item => item.id === message.id); + all(selectors) { return document.querySelectorAll(selectors) } + get(selectors) { return document.querySelector(selectors) } + set(selectors, event, listener) { return this.get(selectors).addEventListener(event, listener) } +}; - if (index === -1) throw Error('Invalid message ID: ' + message.id); - let promise = QUEUE.splice(index, 1)[0]; - - if (message.error) promise.reject(message.error); - else promise.resolve(message.result); -}); - -while (CONNECTION.readyState !== CONNECTION.OPEN) { - await new Promise(resolve => setTimeout(resolve, 200)); -} - -const LogRecord = { - format: (record) => { - let ms = record.created * 1000 - new Date().getTimezoneOffset() * 60000; +export const LogRecord = { + format: (log) => { + let ms = (log.created - new Date().getTimezoneOffset() * 60) * 1000; let date = new Date(ms).toISOString().slice(0, 16).replace("T", " "); - let line = `[${date}] [${record.levelname}] [${record.name}] ${record.msg}`; - let message = [line, record.exc_text, record.stack_info].filter(Boolean).join('\n'); + let line = `[${date}] [${log.levelname}] [${log.name}] ${log.msg}`; + let message = [line, log.exc_text, log.stack_info].filter(Boolean).join('\n'); return message; }, }; -const RemoteProceduralCall = { +export const Rpc2 = { notify: async (method, ...args) => { let request = { method }; if (args.length > 0) request.params = args; - let data = JSON.stringify(request); - CONNECTION.send(data); + let body = JSON.stringify(request); + await fetch('/', { method: 'POST', body }); }, invoke: async (method, ...args) => { let id = Math.floor(Math.random() * 1000000000); let request = { method, id }; if (args.length > 0) request.params = args; - let data = JSON.stringify(request); - CONNECTION.send(data); + let body = JSON.stringify(request); + let response = await fetch('/', { method: 'POST', body }); + let json = await response.json(); - let [resolve, reject] = [null, null]; - let promise = new Promise((a, b) => { resolve = a; reject = b; }); - QUEUE.push({ resolve, reject, id }); - - let result = await promise; - return result; + if (json.error) { + let message = `(${json.error.code}) ${json.error.message}`; + if (json.error.data) message += `: ${json.error.data}`; + throw new Error(message); + } + return json.result; }, }; - -window.LogRecord = LogRecord; -window.Rpc2 = RemoteProceduralCall; -main(...arguments); // user-defined code \ No newline at end of file diff --git a/src/common/jsonrpc2/server.py b/src/common/jsonrpc2/server.py index f16b9ae..0a4faae 100644 --- a/src/common/jsonrpc2/server.py +++ b/src/common/jsonrpc2/server.py @@ -1,53 +1,50 @@ import json import logging -import trio -import trio_websocket as ws +import dataclasses from enum import Enum +from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler from typing import Self, Callable, Any -from itertools import repeat from threading import Thread -handlers: dict[str, Callable] = dict() -connection, server = None, None - class Request[T]: def __init__(self, method: str, params = None, id = None): - if not isinstance(method, str): raise TypeError() - if not isinstance(id, (str, int)) and id is not None: raise TypeError() + if not isinstance(method, str): raise TypeError('method') + if not isinstance(id, (str, int)) and id is not None: raise TypeError('id') self.id = id self.method = method self.params = params - class ParamsError(Exception): - pass + class ParamsError(Exception): pass + class NotFound(Exception): pass + class Invalid(Exception): pass @classmethod - def load(cls, raw: bytes) -> Self: - data = json.loads(raw) - return cls(**data) + def load(cls, data: str) -> Self: + result = json.loads(data) + return cls(**result) - def fulfill(self) -> T: + def handle(self, handlers: dict[str, Any]) -> T: args = self.params - handler: Callable[..., T] = handlers[self.method] + handler: Callable[..., T] = handlers.get(self.method) + + if handler is None: raise self.NotFound(self.method) argcount = handler.__code__.co_argcount - varnames = handler.__code__.co_varnames + argnames = handler.__code__.co_varnames[:argcount] if self.params is None: - if argcount > 0: raise self.ParamsError(None) + if argcount > 0: raise self.ParamsError('Too less') return handler() - if isinstance(args, list): if len(args) != argcount: raise self.ParamsError(args) return handler(*args) - if isinstance(args, dict): - if args.keys() != set(varnames): raise self.ParamsError(args) + if args.keys() != set(argnames): raise self.ParamsError(args) return handler(**args) - raise TypeError(repr(args)) + raise self.Invalid('Arguments unacceptable') class Response[T]: - def __init__(self, id: str|int, inner: T): + def __init__(self, id: str|int|None, inner: T): self.id = id self.inner = inner @@ -80,7 +77,7 @@ class Error: result['code'] = self.code.value result['message'] = self.message() if self.data is not None: - result['data'] = self.data + result['data'] = str(self.data) return result class History(logging.Handler): @@ -100,65 +97,98 @@ class History(logging.Handler): self.records.clear() return copy -class Options: - def __init__(self): - self.listen = '127.0.0.1' - self.port = 0 - self.max_message_size = 125_000_000 +class Server(ThreadingHTTPServer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.encoding: str + self.handlers = dict() -async def handler(request: ws.WebSocketRequest): - global connection - logger = logging.getLogger('jsonrpc2') - websocket = await request.accept() +class RequestHandler(BaseHTTPRequestHandler): + server: Server + protocol_version = 'HTTP/1.1' - if connection is None: - connection = websocket - else: - await websocket.aclose(code=1000, reason="Engaged") - return + def log_message(self, format, *args): + pass - for mid, res, err, exc in repeat((None, None, None, None)): + def do_GET(self): try: - message = await connection.get_message() - inbound = Request.load(message) - mid = inbound.id - res = inbound.fulfill() - except json.decoder.JSONDecodeError as e: - err = Error(Error.Code.PARSE_ERROR) - exc = e - except Request.ParamsError as e: - err = Error(Error.Code.INVALID_PARAMS) - exc = e - except TypeError as e: - err = Error(Error.Code.INVALID_REQUEST) - exc = e - except KeyError as e: - err = Error(Error.Code.METHOD_NOT_FOUND) - err.data = str(e).strip("'") - exc = e - except ws.ConnectionClosed as e: - logger.critical('Going away', exc_info=e) - return + match self.headers.get('Sec-Fetch-Dest'): + case 'script': + file, mime = __file__[:__file__.rfind('server.py')] + 'client.js', 'text/javascript' + case _: + file, mime = './index.html', 'text/html' + response = open(file, mode='rb').read() + self.send_response(200) + self.send_header('Content-Type', '%s; charset=%s' % (mime, self.server.encoding)) + except FileNotFoundError: + response = b'File not found' + self.send_response(404) except Exception as e: - err = Error(Error.Code.INTERNAL_ERROR) - exc = e - - if err is not None: logger.error(err.message(), exc_info=exc) - if mid is not None: await connection.send_message(str(Response(mid, err or res))) + response = b'Internal error: %b' % str(e).encode(self.server.encoding) + self.send_response(500) -async def backend(options: Options): - global server - listeners = await trio.open_tcp_listeners(port=options.port, host=options.listen) - server = ws.WebSocketServer(handler, listeners, max_message_size=options.max_message_size) - await server.run() + self.send_header('Content-Length', str(len(response))) + self.end_headers() + self.wfile.write(response) -def define(method: str, handler: Callable[..., Any]): - if method in handlers: raise KeyError(method) - handlers[method] = handler + def do_POST(self, request=None): + try: + size = int(self.headers.get('Content-Length', '0')) + data = self.rfile.read(size) + request = Request.load(data) + message = request.handle(self.server.handlers) + except json.JSONDecodeError as e: + message = Error(Error.Code.PARSE_ERROR, e) + except Request.ParamsError as e: + message = Error(Error.Code.INVALID_PARAMS, e) + except Request.Invalid as e: + message = Error(Error.Code.INVALID_REQUEST, e) + except Request.NotFound as e: + message = Error(Error.Code.METHOD_NOT_FOUND, e) + except Exception as e: + message = Error(Error.Code.INTERNAL_ERROR, e) -def remove(method: str) -> Callable[..., Any]: - return handlers.pop(method) + response = Response(request.id if request is not None else None, message) + buffer = bytes(str(response), encoding=self.server.encoding) + self.send_response(200) + self.send_header('Content-Length', str(len(buffer))) + self.end_headers() + self.wfile.write(buffer) -def run(options: Options): - thread = Thread(target=lambda: trio.run(backend, options), daemon=True) - thread.start() +class ServiceProvider: + def __init__(self, options): + self.options = dataclasses.asdict(options) + self.server = Server((self.options['host'], self.options['port']), self.options['handler']) + self.server.encoding = self.options['encoding'] + + @classmethod + def default(cls): + import sys, _thread as t + logger = logging.getLogger() + history = History() + logger.addHandler(history) + opts = cls.Options() + self = cls(opts) + self.set('history', lambda: history.truncate()) + self.set('exit', lambda: t.interrupt_main() or sys.exit(0)) + return self + + @dataclasses.dataclass + class Options: + host : str = '127.0.0.1' + port : int = 0 + handler : type = RequestHandler + encoding : str = 'UTF-8' + interval : float = 0.2 + + def set(self, method: str, handler: Callable[..., Any]): + if method in self.server.handlers: raise KeyError(method) + self.server.handlers[method] = handler + + def pop(self, method: str) -> Callable[..., Any]: + return self.server.handlers.pop(method) + + def run(self) -> str: + thread = Thread(target=lambda: self.server.serve_forever(self.options['interval']), daemon=True) + thread.start() + return 'http://%s:%d' % self.server.server_address diff --git a/src/common/utils/timer.py b/src/common/timer.py similarity index 100% rename from src/common/utils/timer.py rename to src/common/timer.py diff --git a/src/common/utils/__init__.py b/src/common/utils/__init__.py index 10337fb..0dfe878 100644 --- a/src/common/utils/__init__.py +++ b/src/common/utils/__init__.py @@ -1,8 +1,12 @@ -from common.utils.timer import Timer -from common.utils.selenium import until, sleep, locate, click, setup +from common.utils.selenium import ( + until, + sleep, + locate, + click, + setup, +) __all__ = [ - 'Timer', 'until', 'sleep', 'locate', diff --git a/src/common/utils/selenium.py b/src/common/utils/selenium.py index a52e4f3..c59726d 100644 --- a/src/common/utils/selenium.py +++ b/src/common/utils/selenium.py @@ -7,24 +7,25 @@ from selenium.webdriver.remote.webelement import WebElement from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.wait import WebDriverWait -from selenium.common.exceptions import StaleElementReferenceException, TimeoutException +from selenium.common.exceptions import StaleElementReferenceException, NoAlertPresentException, TimeoutException driver: WebDriver|None = None -attempts, timeout, interval, identity = int(), float(), float(), None +identity, parameters = None, dict() def until(condition: typing.Callable[[WebDriver], bool], watch=True): try: - WebDriverWait(driver, timeout).until(condition) + WebDriverWait(driver, parameters['timeout']).until(condition) except (TimeoutException, StaleElementReferenceException): pass - if watch: WebDriverWait(driver, timeout).until_not(condition) + if watch: WebDriverWait(driver, parameters['timeout']).until_not(condition) def sleep(seconds: float): - from common.jsonrpc2.server import connection - assert connection is not None and connection.closed is None + try: driver.switch_to.alert + except NoAlertPresentException: pass + except: raise KeyboardInterrupt() try: WebDriverWait(driver, seconds, seconds).until(lambda _: False) - except: pass + except TimeoutException: pass def locate(selector: str, wait=True, condition=True) -> WebElement: while True: @@ -33,12 +34,12 @@ def locate(selector: str, wait=True, condition=True) -> WebElement: if not wait: return driver.find_element(*locator) presence = EC.presence_of_element_located(locator) - element = WebDriverWait(driver, timeout).until(presence, 'Timeout') + element = WebDriverWait(driver, parameters['timeout']).until(presence, 'Timeout') driver.execute_script("arguments[0].scrollIntoView({ block: 'center', inline: 'nearest' });", element) if condition is not None and condition != False: predicate = condition if callable(condition) else EC.visibility_of_element_located - element = WebDriverWait(driver, timeout).until(predicate(locator), 'Timeout') + element = WebDriverWait(driver, parameters['timeout']).until(predicate(locator), 'Timeout') return element except StaleElementReferenceException: @@ -49,11 +50,12 @@ def click(selector: str|WebElement, wait=True, condition=False): error = False element = locate(selector, wait, predicate) if isinstance(selector, str) else selector - counter = lambda: int(element.get_attribute(identity) or 0) + counter = lambda: int(element.get_attribute(identity) or '0') value = counter() - driver.execute_script("arguments[0].addEventListener('click', () => arguments[0].setAttribute(arguments[1], arguments[2] + 1));", element, identity, value) + driver.execute_script("window.__%s__ = () => { arguments[0].setAttribute('%s', arguments[1] + 1) }" % ((identity,) * 2), element, value) + driver.execute_script("arguments[0].addEventListener('click', __%s__);" % identity, element) - for _ in range(attempts): + for _ in range(parameters['attempts']): try: if not error: element.click() else: driver.execute_script("arguments[0].click();", element) @@ -63,15 +65,16 @@ def click(selector: str|WebElement, wait=True, condition=False): error = True continue try: - WebDriverWait(driver, interval).until(lambda _: counter() > value) + WebDriverWait(driver, parameters['interval']).until(lambda _: counter() > value) break except TimeoutException: continue except: break -def setup(a: WebDriver, b: int, c: float, d: float): - global identity, driver, attempts, timeout, interval + try: driver.execute_script("arguments[0].removeEventListener('click', __%s__);" % identity, element) + except: pass + +def setup(a: WebDriver, b: dict): + global identity, driver identity = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) driver = a - attempts = b - timeout = c - interval = d + parameters.update(b)