nonebot-plugin-htmlrender 0.6.7__tar.gz → 0.7.0a1__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.
Files changed (64) hide show
  1. nonebot_plugin_htmlrender-0.7.0a1/.github/workflows/_test-os-arch.yml +67 -0
  2. nonebot_plugin_htmlrender-0.7.0a1/.github/workflows/codecov.yml +20 -0
  3. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/.pre-commit-config.yaml +5 -5
  4. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/PKG-INFO +3 -1
  5. nonebot_plugin_htmlrender-0.7.0a1/nonebot_plugin_htmlrender/__init__.py +64 -0
  6. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/nonebot_plugin_htmlrender/browser.py +128 -42
  7. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/nonebot_plugin_htmlrender/config.py +33 -2
  8. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/nonebot_plugin_htmlrender/data_source.py +5 -5
  9. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/nonebot_plugin_htmlrender/install.py +32 -23
  10. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/nonebot_plugin_htmlrender/process.py +39 -11
  11. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/nonebot_plugin_htmlrender/utils.py +62 -1
  12. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/pyproject.toml +3 -1
  13. nonebot_plugin_htmlrender-0.7.0a1/tests/conftest.py +42 -0
  14. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/tests/test_browser.py +77 -23
  15. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/tests/test_htmlrender.py +5 -4
  16. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/tests/test_process.py +17 -30
  17. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/uv.lock +164 -127
  18. nonebot_plugin_htmlrender-0.6.7/.github/workflows/codecov.yml +0 -43
  19. nonebot_plugin_htmlrender-0.6.7/nonebot_plugin_htmlrender/__init__.py +0 -55
  20. nonebot_plugin_htmlrender-0.6.7/tests/conftest.py +0 -15
  21. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/.dockerignore +0 -0
  22. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/.github/workflows/docker_image.yml +0 -0
  23. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/.github/workflows/publish.yml +0 -0
  24. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/.gitignore +0 -0
  25. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/.python-version +0 -0
  26. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/LICENSE +0 -0
  27. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/README.md +0 -0
  28. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/docker-compose.yaml +0 -0
  29. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/dockerfile +0 -0
  30. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/docs/example.md +0 -0
  31. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/docs/html2pic.png +0 -0
  32. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/docs/md2pic.png +0 -0
  33. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/docs/template2pic.png +0 -0
  34. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/docs/text2pic.png +0 -0
  35. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/entrypoint.sh +0 -0
  36. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/example/docker-compose.yaml +0 -0
  37. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/example/entrypoint.sh +0 -0
  38. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/example/plugins/render/__init__.py +0 -0
  39. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/example/plugins/render/html2pic.html +0 -0
  40. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/example/plugins/render/templates/markdown.css +0 -0
  41. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/example/plugins/render/templates/mystyle.css +0 -0
  42. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/example/plugins/render/templates/progress.html.jinja2 +0 -0
  43. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/example/plugins/render/templates/text.html +0 -0
  44. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/example/plugins/render/utils.py +0 -0
  45. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/example/pyproject.toml +0 -0
  46. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/nonebot_plugin_htmlrender/consts.py +0 -0
  47. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/nonebot_plugin_htmlrender/signal.py +0 -0
  48. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/nonebot_plugin_htmlrender/templates/github-markdown-light.css +0 -0
  49. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/nonebot_plugin_htmlrender/templates/katex/katex.min.b64_fonts.css +0 -0
  50. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/nonebot_plugin_htmlrender/templates/katex/katex.min.js +0 -0
  51. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/nonebot_plugin_htmlrender/templates/katex/mathtex-script-type.min.js +0 -0
  52. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/nonebot_plugin_htmlrender/templates/katex/mhchem.min.js +0 -0
  53. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/nonebot_plugin_htmlrender/templates/markdown.html +0 -0
  54. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/nonebot_plugin_htmlrender/templates/pygments-default.css +0 -0
  55. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/nonebot_plugin_htmlrender/templates/text.css +0 -0
  56. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/nonebot_plugin_htmlrender/templates/text.html +0 -0
  57. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/tests/__init__.py +0 -0
  58. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/tests/resources/test_template_filter.png +0 -0
  59. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/tests/templates/markdown.css +0 -0
  60. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/tests/templates/mystyle.css +0 -0
  61. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/tests/templates/progress.html.jinja2 +0 -0
  62. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/tests/templates/text.html +0 -0
  63. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/tests/test_deprecated_decorator.py +0 -0
  64. {nonebot_plugin_htmlrender-0.6.7 → nonebot_plugin_htmlrender-0.7.0a1}/tests/test_install.py +0 -0
@@ -0,0 +1,67 @@
1
+ on:
2
+ workflow_call:
3
+ inputs:
4
+ python-version:
5
+ required: true
6
+ type: string
7
+
8
+ jobs:
9
+ test:
10
+ name: Test (Py ${{ inputs.python-version }} — ${{ matrix.os_label }})
11
+ runs-on: ${{ matrix.runs_on }}
12
+ strategy:
13
+ fail-fast: true
14
+ matrix:
15
+ include:
16
+ - runs_on: ubuntu-latest
17
+ os_label: ubuntu-latest x64
18
+ arch: x64
19
+ platform: linux
20
+
21
+ - runs_on: macos-latest
22
+ os_label: macos-latest arm64
23
+ arch: arm64
24
+ platform: macos
25
+
26
+ - runs_on: windows-latest
27
+ os_label: windows-latest x64
28
+ arch: x64
29
+ platform: windows
30
+
31
+ - runs_on: ubuntu-24.04-arm
32
+ os_label: ubuntu-24.04-arm arm64
33
+ arch: arm64
34
+ platform: linux
35
+
36
+ env:
37
+ OS: ${{ matrix.os_label }}
38
+ PYTHON_VERSION: ${{ inputs.python-version }}
39
+
40
+ steps:
41
+ - uses: actions/checkout@v4
42
+
43
+ - name: Set up uv
44
+ uses: astral-sh/setup-uv@v6
45
+ with:
46
+ python-version: ${{ inputs.python-version }}
47
+ enable-cache: true
48
+ cache-suffix: ${{ matrix.runs_on }}-${{ inputs.python-version }}
49
+
50
+ - name: Sync deps
51
+ run: uv sync --locked --all-extras --dev
52
+
53
+ - name: Install Playwright (Linux)
54
+ if: matrix.platform == 'linux'
55
+ run: uv run playwright install --with-deps && uv run playwright install --with-deps
56
+ - name: Install Playwright (non-Linux)
57
+ if: matrix.platform != 'linux'
58
+ run: uv run playwright install && uv run playwright install
59
+
60
+ - name: Run tests
61
+ run: uv run pytest -s -n auto
62
+
63
+ - name: Upload coverage to Codecov
64
+ uses: codecov/codecov-action@v5
65
+ with:
66
+ env_vars: OS,PYTHON_VERSION
67
+ verbose: true
@@ -0,0 +1,20 @@
1
+ name: Code Coverage
2
+
3
+ on:
4
+ push:
5
+ branches: [ master, dev , feat/*]
6
+ pull_request:
7
+ branches: [ master ]
8
+
9
+ jobs:
10
+ per-python:
11
+ name: Python ${{ matrix.python-version }}
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13" ]
16
+
17
+ uses: ./.github/workflows/_test-os-arch.yml
18
+ with:
19
+ python-version: ${{ matrix.python-version }}
20
+ secrets: inherit
@@ -7,10 +7,10 @@ ci:
7
7
  autoupdate_commit_msg: ":arrow_up: auto update by pre-commit hooks"
8
8
  repos:
9
9
  - repo: https://github.com/astral-sh/ruff-pre-commit
10
- rev: v0.8.2
10
+ rev: v0.12.12
11
11
  hooks:
12
- - id: ruff
13
- args: [--fix]
14
- stages: [pre-commit]
15
12
  - id: ruff-format
16
- stages: [pre-commit]
13
+ - id: ruff-check
14
+ args: [ --fix, --exit-non-zero-on-fix ]
15
+ types_or: [ python, pyi ]
16
+ require_serial: true
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nonebot-plugin-htmlrender
3
- Version: 0.6.7
3
+ Version: 0.7.0a1
4
4
  Summary: 通过浏览器渲染图片
5
5
  Project-URL: Homepage, https://github.com/kexue-z/nonebot-plugin-htmlrender
6
6
  Project-URL: Bug Tracker, https://github.com/kexue-z/nonebot-plugin-htmlrender/issues
@@ -31,11 +31,13 @@ Requires-Python: <4.0,>=3.9
31
31
  Requires-Dist: aiofiles>=0.8.0
32
32
  Requires-Dist: jinja2>=3.0.3
33
33
  Requires-Dist: markdown>=3.3.6
34
+ Requires-Dist: nonebot-plugin-localstore>=0.7.4
34
35
  Requires-Dist: nonebot2>=2.4.2
35
36
  Requires-Dist: playwright>=1.48.0
36
37
  Requires-Dist: pygments>=2.10.0
37
38
  Requires-Dist: pymdown-extensions>=9.1
38
39
  Requires-Dist: python-markdown-math>=0.8
40
+ Requires-Dist: tenacity>=9.1.2
39
41
  Description-Content-Type: text/markdown
40
42
 
41
43
  # nonebot-plugin-htmlrender
@@ -0,0 +1,64 @@
1
+ import nonebot
2
+ from nonebot import require
3
+
4
+ require("nonebot_plugin_localstore")
5
+ from nonebot.log import logger
6
+ from nonebot.plugin import PluginMetadata
7
+
8
+ from nonebot_plugin_htmlrender.browser import (
9
+ get_new_page,
10
+ shutdown_htmlrender,
11
+ startup_htmlrender,
12
+ )
13
+ from nonebot_plugin_htmlrender.config import Config, plugin_config
14
+ from nonebot_plugin_htmlrender.data_source import (
15
+ capture_element,
16
+ html_to_pic,
17
+ md_to_pic,
18
+ template_to_html,
19
+ template_to_pic,
20
+ text_to_pic,
21
+ )
22
+ from nonebot_plugin_htmlrender.utils import _clear_playwright_env_vars
23
+
24
+ __plugin_meta__ = PluginMetadata(
25
+ name="nonebot-plugin-htmlrender",
26
+ description="通过浏览器渲染图片",
27
+ usage="",
28
+ type="library",
29
+ config=Config,
30
+ homepage="https://github.com/kexue-z/nonebot-plugin-htmlrender",
31
+ extra={},
32
+ )
33
+
34
+ driver = nonebot.get_driver()
35
+
36
+
37
+ @driver.on_startup
38
+ async def init(**kwargs):
39
+ logger.info("HTMLRender Starting...")
40
+ await startup_htmlrender(**kwargs)
41
+ logger.opt(colors=True).info(
42
+ f"HTMLRender Started with <cyan>{plugin_config.htmlrender_browser}</cyan>."
43
+ )
44
+
45
+
46
+ @driver.on_shutdown
47
+ async def shutdown():
48
+ logger.info("HTMLRender Shutting down...")
49
+ await shutdown_htmlrender()
50
+ _clear_playwright_env_vars()
51
+ logger.info("HTMLRender Shut down.")
52
+
53
+
54
+ __all__ = [
55
+ "capture_element",
56
+ "get_new_page",
57
+ "html_to_pic",
58
+ "md_to_pic",
59
+ "shutdown_htmlrender",
60
+ "startup_htmlrender",
61
+ "template_to_html",
62
+ "template_to_pic",
63
+ "text_to_pic",
64
+ ]
@@ -1,5 +1,5 @@
1
1
  from collections.abc import AsyncIterator
2
- from contextlib import asynccontextmanager
2
+ from contextlib import AsyncExitStack, asynccontextmanager
3
3
  from typing import Optional
4
4
 
5
5
  from nonebot.log import logger
@@ -10,10 +10,17 @@ from playwright.async_api import (
10
10
  Playwright,
11
11
  async_playwright,
12
12
  )
13
+ from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
13
14
 
14
15
  from nonebot_plugin_htmlrender.config import plugin_config
15
16
  from nonebot_plugin_htmlrender.install import install_browser
16
- from nonebot_plugin_htmlrender.utils import proxy_settings, suppress_and_log, with_lock
17
+ from nonebot_plugin_htmlrender.utils import (
18
+ _prepare_playwright_env_vars,
19
+ clean_playwright_cache,
20
+ proxy_settings,
21
+ suppress_and_log,
22
+ with_lock,
23
+ )
17
24
 
18
25
  _browser: Optional[Browser] = None
19
26
  _playwright: Optional[Playwright] = None
@@ -40,22 +47,6 @@ async def _launch(browser_type: str, **kwargs) -> Browser:
40
47
  return await _browser_cls.launch(**kwargs)
41
48
 
42
49
 
43
- async def init_browser(**kwargs) -> Browser:
44
- """
45
- 初始化浏览器实例。
46
-
47
- Args:
48
- **kwargs: 传递给`playwright.launch`的关键字参数。
49
-
50
- Returns:
51
- Browser: 浏览器实例。
52
-
53
- Raises:
54
- RuntimeError: 如果浏览器无法启动或安装失败。
55
- """
56
- return await start_browser(**kwargs)
57
-
58
-
59
50
  @asynccontextmanager
60
51
  async def get_new_page(device_scale_factor: float = 2, **kwargs) -> AsyncIterator[Page]:
61
52
  """
@@ -88,7 +79,7 @@ async def get_browser(**kwargs) -> Browser:
88
79
  if _browser and _browser.is_connected():
89
80
  return _browser
90
81
 
91
- return await init_browser(**kwargs)
82
+ return await startup_htmlrender(**kwargs)
92
83
 
93
84
 
94
85
  async def _connect_via_cdp(**kwargs) -> Browser:
@@ -106,12 +97,12 @@ async def _connect_via_cdp(**kwargs) -> Browser:
106
97
  """
107
98
  kwargs["endpoint_url"] = plugin_config.htmlrender_connect_over_cdp
108
99
  logger.info(
109
- f"正在使用 CDP 连接 Chromium({plugin_config.htmlrender_connect_over_cdp})"
100
+ f"Connecting to Chromium via CDP ({plugin_config.htmlrender_connect_over_cdp})"
110
101
  )
111
102
  if _playwright is not None:
112
103
  return await _playwright.chromium.connect_over_cdp(**kwargs)
113
104
  else:
114
- raise RuntimeError("Playwright 未初始化")
105
+ raise RuntimeError("Playwright is not initialized")
115
106
 
116
107
 
117
108
  async def _connect(browser_type: str, **kwargs) -> Browser:
@@ -131,16 +122,40 @@ async def _connect(browser_type: str, **kwargs) -> Browser:
131
122
  _browser_cls: BrowserType = getattr(_playwright, browser_type)
132
123
  kwargs["ws_endpoint"] = plugin_config.htmlrender_connect
133
124
  logger.info(
134
- f"正在使用 Playwright 协议连接 {browser_type}({plugin_config.htmlrender_connect})"
125
+ f"Connecting to {browser_type.capitalize()} via "
126
+ f"WebSocket endpoint: {plugin_config.htmlrender_connect}"
135
127
  )
136
128
  if _playwright is not None:
137
129
  return await _browser_cls.connect(**kwargs)
138
130
  else:
139
- raise RuntimeError("Playwright 未初始化")
131
+ raise RuntimeError("Playwright is not initialized")
132
+
133
+
134
+ @retry(
135
+ retry=retry_if_exception_type(RuntimeError),
136
+ stop=stop_after_attempt(4),
137
+ wait=wait_fixed(1),
138
+ reraise=True,
139
+ before_sleep=lambda retry_state: logger.warning(
140
+ f"Attempt {retry_state.attempt_number} failed, retrying..."
141
+ ),
142
+ )
143
+ async def _check_env_with_install_retry(**kwargs):
144
+ try:
145
+ return await check_playwright_env(**kwargs)
146
+ except RuntimeError:
147
+ if plugin_config.htmlrender_ci_mode:
148
+ raise
149
+ try:
150
+ await install_browser()
151
+ except Exception as e:
152
+ logger.error(f"Browser installation failed: {e!s}")
153
+ raise RuntimeError(f"install_browser failed: {e}") from e
154
+ raise
140
155
 
141
156
 
142
157
  @with_lock
143
- async def start_browser(**kwargs) -> Browser:
158
+ async def startup_htmlrender(**kwargs) -> Browser:
144
159
  """
145
160
  启动 Playwright 浏览器实例。
146
161
 
@@ -152,8 +167,14 @@ async def start_browser(**kwargs) -> Browser:
152
167
  """
153
168
  global _browser, _playwright
154
169
 
155
- await shutdown_browser()
170
+ await shutdown_htmlrender()
171
+
172
+ if not plugin_config.htmlrender_ci_mode:
173
+ clean_playwright_cache()
174
+ _prepare_playwright_env_vars()
175
+
156
176
  _playwright = await async_playwright().start()
177
+ logger.debug("Playwright started")
157
178
 
158
179
  if (
159
180
  plugin_config.htmlrender_browser == "chromium"
@@ -174,35 +195,100 @@ async def start_browser(**kwargs) -> Browser:
174
195
 
175
196
  if plugin_config.htmlrender_browser_executable_path:
176
197
  kwargs["executable_path"] = plugin_config.htmlrender_browser_executable_path
177
- else:
178
198
  try:
179
- await check_playwright_env(**kwargs)
180
- except RuntimeError:
181
- await install_browser()
182
- await check_playwright_env(**kwargs)
183
-
184
- _browser = await _launch(plugin_config.htmlrender_browser, **kwargs)
199
+ _browser = await _launch(plugin_config.htmlrender_browser, **kwargs)
200
+ except Exception as e:
201
+ raise RuntimeError(
202
+ f"Failed to launch browser with executable path"
203
+ f" '{plugin_config.htmlrender_browser_executable_path}': {e}"
204
+ ) from e
205
+ else:
206
+ _browser = await _check_env_with_install_retry(**kwargs)
185
207
 
186
208
  return _browser
187
209
 
188
210
 
189
- async def shutdown_browser() -> None:
190
- """关闭浏览器和 Playwright 实例。"""
191
- if _browser:
211
+ async def shutdown_htmlrender() -> None:
212
+ is_remote = bool(
213
+ plugin_config.htmlrender_connect or plugin_config.htmlrender_connect_over_cdp
214
+ )
215
+ async with AsyncExitStack() as stack:
216
+ await _schedule_browser_shutdown(stack, is_remote=is_remote)
217
+ await _schedule_playwright_shutdown(stack)
218
+ _clear_globals()
219
+
220
+
221
+ async def _schedule_browser_shutdown(stack: AsyncExitStack, *, is_remote: bool) -> None:
222
+ global _browser
223
+ if not _browser:
224
+ return
225
+
226
+ should_close = (not is_remote) and plugin_config.htmlrender_shutdown_browser_on_exit
227
+ if not should_close:
228
+ logger.info(
229
+ "Skipping browser shutdown due to configuration or remote connection."
230
+ )
231
+ return
232
+
233
+ browser = _browser
234
+ if not browser.is_connected():
235
+ logger.info("Browser was already disconnected.")
236
+ return
237
+
238
+ logger.debug("Disconnecting browser...")
239
+
240
+ async def _close_browser():
192
241
  with suppress_and_log():
193
- await _browser.close()
194
- if _playwright:
242
+ await browser.close()
243
+ logger.info("Disconnected browser.")
244
+
245
+ stack.push_async_callback(_close_browser)
246
+
247
+
248
+ async def _schedule_playwright_shutdown(stack: AsyncExitStack) -> None:
249
+ global _playwright
250
+ if not _playwright:
251
+ return
252
+
253
+ pw = _playwright
254
+ logger.debug("Stopping Playwright...")
255
+
256
+ async def _stop_pw():
195
257
  with suppress_and_log():
196
- await _playwright.stop()
258
+ await pw.stop()
259
+ logger.info("Playwright stopped.")
260
+
261
+ stack.push_async_callback(_stop_pw)
262
+
263
+
264
+ def _clear_globals() -> None:
265
+ global _browser, _playwright
266
+ _browser = None
267
+ _playwright = None
197
268
 
198
269
 
199
- async def check_playwright_env(**kwargs):
200
- """Check Playwright environment."""
270
+ async def check_playwright_env(**kwargs) -> Browser:
271
+ """
272
+ 检查Playwright环境,复用_launch方法避免逻辑重复。
273
+
274
+ Args:
275
+ **kwargs: 传递给`playwright.launch`的关键字参数。
276
+
277
+ Raises:
278
+ RuntimeError: 如果Playwright环境设置不正确。
279
+ """
201
280
  logger.info("Checking Playwright environment...")
281
+ global _browser, _playwright
282
+
202
283
  try:
203
- async with async_playwright() as p:
204
- await getattr(p, plugin_config.htmlrender_browser).launch(**kwargs)
284
+ _playwright = await async_playwright().start()
285
+ _browser = await _launch(plugin_config.htmlrender_browser, **kwargs)
286
+ logger.success("Playwright environment is set up correctly.")
287
+ return _browser
288
+
205
289
  except Exception as e:
290
+ await shutdown_htmlrender()
291
+
206
292
  raise RuntimeError(
207
293
  "Playwright environment is not set up correctly. "
208
294
  "Refer to https://playwright.dev/python/docs/intro#system-requirements"
@@ -1,11 +1,18 @@
1
+ from pathlib import Path
1
2
  from typing import Any, Optional
2
3
 
3
4
  from nonebot import get_driver, get_plugin_config
4
5
  from nonebot.compat import model_validator
6
+ from nonebot.log import logger
7
+ import nonebot_plugin_localstore as store
5
8
  from pydantic import BaseModel, Field
6
9
 
7
10
  from nonebot_plugin_htmlrender.consts import BROWSER_CHANNEL_TYPES, BROWSER_ENGINE_TYPES
8
11
 
12
+ plugin_cache_dir: Path = store.get_plugin_cache_dir()
13
+ plugin_config_dir: Path = store.get_plugin_config_dir()
14
+ plugin_data_dir: Path = store.get_plugin_data_dir()
15
+
9
16
 
10
17
  class Config(BaseModel):
11
18
  """插件配置类。"""
@@ -14,6 +21,26 @@ class Config(BaseModel):
14
21
  default="chromium",
15
22
  description="Playwright浏览器引擎类型,默认值为 'chromium'。",
16
23
  )
24
+ htmlrender_storage_path: Path = Field(
25
+ default=plugin_data_dir,
26
+ description="存储路径,不填则使用 `nonebot-plugin-localstore` 管理",
27
+ )
28
+ htmlrender_cache_path: Path = Field(
29
+ default=plugin_cache_dir,
30
+ description="缓存路径,不填则使用 `nonebot-plugin-localstore` 管理",
31
+ )
32
+ htmlrender_config_path: Path = Field(
33
+ default=plugin_config_dir,
34
+ description="配置路径,不填则使用 `nonebot-plugin-localstore` 管理",
35
+ )
36
+ htmlrender_shutdown_browser_on_exit: bool = Field(
37
+ default=True,
38
+ description="在插件关闭时关闭浏览器实例,在连接到远程浏览器时默认为 False。",
39
+ )
40
+ htmlrender_ci_mode: bool = Field(
41
+ default=False,
42
+ description="启用CI模式,跳过浏览器安装和环境变量",
43
+ )
17
44
  htmlrender_download_host: Optional[str] = Field(
18
45
  default=None, description="下载Playwright浏览器时的主机地址。"
19
46
  )
@@ -29,7 +56,7 @@ class Config(BaseModel):
29
56
  htmlrender_browser_channel: Optional[str] = Field(
30
57
  default=None, description="Playwright浏览器通道类型。"
31
58
  )
32
- htmlrender_browser_executable_path: Optional[str] = Field(
59
+ htmlrender_browser_executable_path: Optional[Path] = Field(
33
60
  default=None, description="Playwright浏览器可执行文件的路径。"
34
61
  )
35
62
  htmlrender_connect_over_cdp: Optional[str] = Field(
@@ -38,7 +65,6 @@ class Config(BaseModel):
38
65
  htmlrender_connect: Optional[str] = Field(
39
66
  default=None, description="通过Playwright协议连接Playwright浏览器的端点地址。"
40
67
  )
41
-
42
68
  htmlrender_browser_args: Optional[str] = Field(
43
69
  default=None, description="Playwright 浏览器启动参数。"
44
70
  )
@@ -76,3 +102,8 @@ class Config(BaseModel):
76
102
 
77
103
  global_config = get_driver().config
78
104
  plugin_config = get_plugin_config(Config)
105
+
106
+ if plugin_config.htmlrender_ci_mode:
107
+ logger.info(
108
+ "CI mode enabled, skipping browser installation and environment variable setup."
109
+ )
@@ -87,7 +87,7 @@ async def md_to_pic(
87
87
  if md_path:
88
88
  md = await read_file(md_path)
89
89
  else:
90
- raise Exception("必须输入 md md_path")
90
+ raise Exception("md or md_path must be provided")
91
91
  logger.debug(md)
92
92
  md = markdown.markdown(
93
93
  md,
@@ -174,7 +174,7 @@ async def template_to_html(
174
174
  if filters:
175
175
  for filter_name, filter_func in filters.items():
176
176
  template_env.filters[filter_name] = filter_func
177
- logger.debug(f"加载自定义过滤器 {filter_name}")
177
+ logger.debug(f"Custom filter loaded: {filter_name}")
178
178
 
179
179
  template = template_env.get_template(template_name)
180
180
 
@@ -209,9 +209,9 @@ async def html_to_pic(
209
209
  """
210
210
  # logger.debug(f"html:\n{html}")
211
211
  if "file:" not in template_path:
212
- raise Exception("template_path 应该为 file:///path/to/template")
212
+ raise Exception("template_path should be file:///path/to/template")
213
213
  async with get_new_page(device_scale_factor, **kwargs) as page:
214
- page.on("console", lambda msg: logger.debug(f"浏览器控制台: {msg.text}"))
214
+ page.on("console", lambda msg: logger.debug(f"[Browser Console]: {msg.text}"))
215
215
  await page.goto(template_path)
216
216
  await page.set_content(html, wait_until="networkidle")
217
217
  await page.wait_for_timeout(wait)
@@ -266,7 +266,7 @@ async def template_to_pic(
266
266
  if filters:
267
267
  for filter_name, filter_func in filters.items():
268
268
  template_env.filters[filter_name] = filter_func
269
- logger.debug(f"加载自定义过滤器 {filter_name}")
269
+ logger.debug(f"Custom filter loaded: {filter_name}")
270
270
 
271
271
  template = template_env.get_template(template_name)
272
272
 
@@ -86,7 +86,10 @@ async def download_context() -> AsyncIterator[None]:
86
86
  try:
87
87
  best_mirror = await check_mirror_connectivity()
88
88
  if best_mirror is not None:
89
- logger.info(f"Using Mirror source: {best_mirror.name} ({best_mirror.url})")
89
+ logger.opt(colors=True).info(
90
+ f"Using Mirror source: <cyan>{best_mirror.name}</cyan> "
91
+ f"{best_mirror.url}"
92
+ )
90
93
  os.environ["PLAYWRIGHT_DOWNLOAD_HOST"] = best_mirror.url
91
94
  else:
92
95
  logger.info("Mirror source not available, using default")
@@ -125,30 +128,25 @@ async def read_stream(
125
128
  if stream is None:
126
129
  return ""
127
130
 
128
- last_progress = "" # 上一次显示的进度
129
131
  output = [] # 存储读取到的文本内容
130
132
 
131
133
  while True:
132
134
  try:
133
- char = await stream.read(1)
134
- if not char:
135
+ line_data = await stream.readline()
136
+ if not line_data:
135
137
  break
136
138
 
137
- if char == b"\r":
138
- continue
139
+ line = line_data.decode().strip()
139
140
 
140
- line = char + await stream.readuntil(b"\n")
141
- text = line.decode().strip()
141
+ if callback:
142
+ try:
143
+ await callback(line)
144
+ except UnicodeEncodeError:
145
+ safe_text = "".join(c if ord(c) < 128 else "?" for c in line)
146
+ await callback(safe_text)
142
147
 
143
- if "|" in text and "%" in text:
144
- if text != last_progress:
145
- if callback:
146
- await callback(f"Progress: {text}")
147
- last_progress = text
148
- else:
149
- if callback:
150
- await callback(text)
151
- output.append(text)
148
+ if line:
149
+ output.append(line)
152
150
 
153
151
  except asyncio.IncompleteReadError:
154
152
  break
@@ -171,6 +169,7 @@ async def execute_install_command(timeout: int) -> tuple[bool, str]:
171
169
  try:
172
170
  logger.debug("Starting playwright install process...")
173
171
  install_signal_handler()
172
+
174
173
  process = await create_process(
175
174
  "playwright",
176
175
  "install",
@@ -179,13 +178,21 @@ async def execute_install_command(timeout: int) -> tuple[bool, str]:
179
178
  stdout=asyncio.subprocess.PIPE,
180
179
  stderr=asyncio.subprocess.PIPE,
181
180
  )
182
- logger.debug("Process created successfully")
183
181
 
184
182
  async def stdout_callback(line: str) -> None:
185
- logger.info(f"{line.strip()}")
183
+ line_stripped = line.strip()
184
+ if (
185
+ not line_stripped.startswith("Progress:")
186
+ and "|" not in line_stripped
187
+ and "%" not in line_stripped
188
+ and line_stripped
189
+ ):
190
+ logger.info(line_stripped)
186
191
 
187
192
  async def stderr_callback(line: str) -> None:
188
- logger.warning(f"Install error: {line.strip()}")
193
+ line_stripped = line.strip()
194
+ if line_stripped:
195
+ logger.warning(f"Install error: {line_stripped}")
189
196
 
190
197
  stdout_task = asyncio.create_task(read_stream(process.stdout, stdout_callback))
191
198
  stderr_task = asyncio.create_task(read_stream(process.stderr, stderr_callback))
@@ -195,18 +202,20 @@ async def execute_install_command(timeout: int) -> tuple[bool, str]:
195
202
  asyncio.gather(stdout_task, stderr_task), timeout=timeout
196
203
  )
197
204
  except asyncio.TimeoutError:
205
+ logger.error(f"Timed out ({timeout}s)")
198
206
  await terminate_process(process)
199
207
  return False, f"Timed out ({timeout}s)"
200
208
 
201
209
  await process.wait()
202
- returncode = process.returncode
210
+ return_code = process.returncode
203
211
 
204
- if returncode != 0:
205
- return False, f"Exited with code {returncode}"
212
+ if return_code != 0:
213
+ return False, f"Exited with code {return_code}"
206
214
 
207
215
  return True, "Installation completed"
208
216
 
209
217
  except Exception as e:
218
+ logger.error(f"An error occurred during installation: {e!s}")
210
219
  return False, f"An error occurred during installation: {e!s}"
211
220
 
212
221