tiebameow 0.2.8__py3-none-any.whl
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.
- tiebameow/__init__.py +0 -0
- tiebameow/client/__init__.py +4 -0
- tiebameow/client/http_client.py +103 -0
- tiebameow/client/tieba_client.py +517 -0
- tiebameow/models/__init__.py +0 -0
- tiebameow/models/dto.py +391 -0
- tiebameow/models/orm.py +572 -0
- tiebameow/parser/__init__.py +45 -0
- tiebameow/parser/parser.py +362 -0
- tiebameow/parser/rule_parser.py +990 -0
- tiebameow/py.typed +0 -0
- tiebameow/renderer/__init__.py +5 -0
- tiebameow/renderer/config.py +18 -0
- tiebameow/renderer/playwright_core.py +148 -0
- tiebameow/renderer/renderer.py +508 -0
- tiebameow/renderer/static/fonts/NotoSansSC-Regular.woff2 +0 -0
- tiebameow/renderer/style.py +32 -0
- tiebameow/renderer/templates/base.html +270 -0
- tiebameow/renderer/templates/macros.html +100 -0
- tiebameow/renderer/templates/text.html +99 -0
- tiebameow/renderer/templates/text_simple.html +79 -0
- tiebameow/renderer/templates/thread.html +8 -0
- tiebameow/renderer/templates/thread_detail.html +18 -0
- tiebameow/renderer/templates/thread_info.html +35 -0
- tiebameow/schemas/__init__.py +0 -0
- tiebameow/schemas/fragments.py +188 -0
- tiebameow/schemas/rules.py +247 -0
- tiebameow/serializer/__init__.py +15 -0
- tiebameow/serializer/serializer.py +115 -0
- tiebameow/utils/__init__.py +0 -0
- tiebameow/utils/logger.py +129 -0
- tiebameow/utils/time_utils.py +15 -0
- tiebameow-0.2.8.dist-info/METADATA +142 -0
- tiebameow-0.2.8.dist-info/RECORD +36 -0
- tiebameow-0.2.8.dist-info/WHEEL +4 -0
- tiebameow-0.2.8.dist-info/licenses/LICENSE +21 -0
tiebameow/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RenderConfig(BaseModel):
|
|
7
|
+
"""
|
|
8
|
+
渲染配置类
|
|
9
|
+
|
|
10
|
+
Attributes:
|
|
11
|
+
width (int): 渲染宽度,默认为500。
|
|
12
|
+
height (int): 渲染高度,无需手动调整高度,默认为100。
|
|
13
|
+
quality (Literal["low", "medium", "high"]): 渲染质量,输出清晰度,默认为"medium"。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
width: int = 500
|
|
17
|
+
height: int = 100
|
|
18
|
+
quality: Literal["low", "medium", "high"] = "medium"
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from asyncio import Lock
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Literal, cast
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
|
|
9
|
+
from playwright.async_api import Browser, BrowserContext, Playwright, Route
|
|
10
|
+
|
|
11
|
+
from .config import RenderConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
QUALITY_MAP_SCALE = {
|
|
15
|
+
"low": 1,
|
|
16
|
+
"medium": 1.5,
|
|
17
|
+
"high": 2,
|
|
18
|
+
}
|
|
19
|
+
QUALITY_MAP_OUTPUT: dict[str, dict[str, Any]] = {
|
|
20
|
+
"low": {
|
|
21
|
+
"type": "jpeg",
|
|
22
|
+
"quality": 60,
|
|
23
|
+
},
|
|
24
|
+
"medium": {
|
|
25
|
+
"type": "jpeg",
|
|
26
|
+
"quality": 80,
|
|
27
|
+
},
|
|
28
|
+
"high": {"type": "png"},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
VALID_BROWSER_ENGINES = Literal["chromium", "firefox", "webkit"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PlaywrightCore:
|
|
35
|
+
def __init__(self, browser_engine: VALID_BROWSER_ENGINES | None = None) -> None:
|
|
36
|
+
if not self.check_installed():
|
|
37
|
+
raise ImportError(
|
|
38
|
+
"playwright is not installed. Please install it with 'pip install tiebameow[renderer]'.\n"
|
|
39
|
+
"You may also need to run 'playwright install' to install the necessary browsers."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
self.playwright: Playwright | None = None
|
|
43
|
+
self.browser_engine = browser_engine or "chromium"
|
|
44
|
+
self.browser: Browser | None = None
|
|
45
|
+
self.contexts: dict[str, BrowserContext] = {}
|
|
46
|
+
self._lock = Lock()
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def check_installed() -> bool:
|
|
50
|
+
"""检查Playwright包是否已安装。"""
|
|
51
|
+
try:
|
|
52
|
+
import playwright # noqa: F401
|
|
53
|
+
except ImportError:
|
|
54
|
+
return False
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
async def _launch(self) -> None:
|
|
58
|
+
"""在获取锁的前提下,启动Playwright和浏览器实例。"""
|
|
59
|
+
if self.browser is not None:
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
if self.playwright is None:
|
|
63
|
+
from playwright.async_api import async_playwright
|
|
64
|
+
|
|
65
|
+
self.playwright = await async_playwright().start()
|
|
66
|
+
|
|
67
|
+
engine = getattr(self.playwright, self.browser_engine)
|
|
68
|
+
if not engine:
|
|
69
|
+
raise ValueError(f"Invalid browser engine: {self.browser_engine}")
|
|
70
|
+
try:
|
|
71
|
+
self.browser = await engine.launch()
|
|
72
|
+
except AttributeError as e:
|
|
73
|
+
raise ValueError(f"Invalid browser engine: {self.browser_engine}") from e
|
|
74
|
+
|
|
75
|
+
async def launch(self) -> None:
|
|
76
|
+
"""启动Playwright和浏览器实例。"""
|
|
77
|
+
async with self._lock:
|
|
78
|
+
await self._launch()
|
|
79
|
+
|
|
80
|
+
async def close(self) -> None:
|
|
81
|
+
"""关闭所有浏览器上下文和浏览器实例。"""
|
|
82
|
+
async with self._lock:
|
|
83
|
+
for context in self.contexts.values():
|
|
84
|
+
await context.close()
|
|
85
|
+
self.contexts.clear()
|
|
86
|
+
|
|
87
|
+
if self.browser is not None:
|
|
88
|
+
await self.browser.close()
|
|
89
|
+
self.browser = None
|
|
90
|
+
if self.playwright is not None:
|
|
91
|
+
await self.playwright.stop()
|
|
92
|
+
self.playwright = None
|
|
93
|
+
|
|
94
|
+
async def _get_context(self, quality: str) -> BrowserContext:
|
|
95
|
+
"""获取指定渲染图片质量的浏览器上下文。"""
|
|
96
|
+
if quality in self.contexts:
|
|
97
|
+
return self.contexts[quality]
|
|
98
|
+
|
|
99
|
+
async with self._lock:
|
|
100
|
+
if quality in self.contexts:
|
|
101
|
+
return self.contexts[quality]
|
|
102
|
+
|
|
103
|
+
if self.browser is None:
|
|
104
|
+
await self._launch()
|
|
105
|
+
|
|
106
|
+
browser = cast("Browser", self.browser)
|
|
107
|
+
scale = QUALITY_MAP_SCALE.get(quality, 1)
|
|
108
|
+
context = await browser.new_context(device_scale_factor=scale)
|
|
109
|
+
self.contexts[quality] = context
|
|
110
|
+
return context
|
|
111
|
+
|
|
112
|
+
async def render(
|
|
113
|
+
self,
|
|
114
|
+
html: str,
|
|
115
|
+
config: RenderConfig,
|
|
116
|
+
element: str | None = None,
|
|
117
|
+
request_handler: Callable[[Route], Awaitable[None]] | None = None,
|
|
118
|
+
) -> bytes:
|
|
119
|
+
"""使用Playwright渲染HTML为图片。
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
html: 要渲染的HTML内容
|
|
123
|
+
config: 渲染配置
|
|
124
|
+
element: 可选的CSS选择器,指定要截图的元素;如果为None,则截图整个页面
|
|
125
|
+
request_handler: 可选的请求处理函数,用于拦截和处理页面请求
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
渲染后的图片字节内容
|
|
129
|
+
"""
|
|
130
|
+
context = await self._get_context(config.quality)
|
|
131
|
+
page = await context.new_page()
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
await page.set_viewport_size({"width": config.width, "height": config.height})
|
|
135
|
+
|
|
136
|
+
if request_handler:
|
|
137
|
+
await page.route("http://tiebameow.local/**", request_handler)
|
|
138
|
+
|
|
139
|
+
await page.set_content(html)
|
|
140
|
+
await page.wait_for_load_state("networkidle")
|
|
141
|
+
|
|
142
|
+
if element:
|
|
143
|
+
screenshot = await page.locator(element).screenshot(**QUALITY_MAP_OUTPUT[config.quality])
|
|
144
|
+
else:
|
|
145
|
+
screenshot = await page.screenshot(full_page=True, **QUALITY_MAP_OUTPUT[config.quality])
|
|
146
|
+
return screenshot
|
|
147
|
+
finally:
|
|
148
|
+
await page.close()
|
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
6
|
+
|
|
7
|
+
import jinja2
|
|
8
|
+
import yarl
|
|
9
|
+
from aiotieba.api.get_posts._classdef import Comment_p, Thread_p
|
|
10
|
+
from aiotieba.typing import Comment, Post, Thread
|
|
11
|
+
|
|
12
|
+
from ..client import Client
|
|
13
|
+
from ..models.dto import CommentDTO, PostDTO, ThreadDTO, ThreadpDTO
|
|
14
|
+
from ..parser import convert_aiotieba_comment, convert_aiotieba_post, convert_aiotieba_thread, convert_aiotieba_threadp
|
|
15
|
+
from ..utils.logger import logger
|
|
16
|
+
from .config import RenderConfig
|
|
17
|
+
from .playwright_core import PlaywrightCore
|
|
18
|
+
from .style import FONT_URL, font_path, get_font_style
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from playwright.async_api import Route
|
|
25
|
+
|
|
26
|
+
type RenderContentType = (
|
|
27
|
+
ThreadDTO | Thread | ThreadpDTO | Thread_p | PostDTO | Post | CommentDTO | Comment | Comment_p
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def format_date(dt: datetime | int | float) -> str:
|
|
32
|
+
if isinstance(dt, (int, float)):
|
|
33
|
+
if dt > 1e11:
|
|
34
|
+
dt = dt / 1000
|
|
35
|
+
dt = datetime.fromtimestamp(dt)
|
|
36
|
+
return dt.strftime("%Y-%m-%d %H:%M")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Renderer:
|
|
40
|
+
"""
|
|
41
|
+
渲染器,用于将贴子数据渲染为图像
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
client: 用于获取资源的客户端实例,若为 None 则创建新的 Client 实例
|
|
45
|
+
config: 渲染配置,若为 None 则使用默认配置
|
|
46
|
+
template_dir: 自定义模板目录,若为 None 则使用内置模板
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
client: Client | None = None,
|
|
52
|
+
config: RenderConfig | None = None,
|
|
53
|
+
template_dir: str | Path | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
self.core = PlaywrightCore()
|
|
56
|
+
|
|
57
|
+
if config is None:
|
|
58
|
+
config = RenderConfig()
|
|
59
|
+
self.config = config
|
|
60
|
+
|
|
61
|
+
self.client = client or Client()
|
|
62
|
+
self._own_client = client is None
|
|
63
|
+
self._client_entered = False
|
|
64
|
+
|
|
65
|
+
loader: jinja2.BaseLoader
|
|
66
|
+
if template_dir:
|
|
67
|
+
loader = jinja2.FileSystemLoader(str(template_dir))
|
|
68
|
+
else:
|
|
69
|
+
loader = jinja2.PackageLoader("tiebameow.renderer", "templates")
|
|
70
|
+
|
|
71
|
+
self.env = jinja2.Environment(loader=loader, enable_async=True)
|
|
72
|
+
self.env.filters["format_date"] = format_date
|
|
73
|
+
|
|
74
|
+
async def close(self) -> None:
|
|
75
|
+
await self.core.close()
|
|
76
|
+
if self._own_client and self._client_entered:
|
|
77
|
+
await self.client.__aexit__(None, None, None)
|
|
78
|
+
self._client_entered = False
|
|
79
|
+
|
|
80
|
+
async def _ensure_client(self) -> None:
|
|
81
|
+
if self._own_client and not self._client_entered:
|
|
82
|
+
await self.client.__aenter__()
|
|
83
|
+
self._client_entered = True
|
|
84
|
+
|
|
85
|
+
async def __aenter__(self) -> Renderer:
|
|
86
|
+
await self._ensure_client()
|
|
87
|
+
try:
|
|
88
|
+
await self.core.launch()
|
|
89
|
+
except Exception:
|
|
90
|
+
await self.close()
|
|
91
|
+
raise
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
async def __aexit__(
|
|
95
|
+
self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any | None
|
|
96
|
+
) -> None:
|
|
97
|
+
await self.close()
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def _get_portrait_url(portrait: str, size: Literal["s", "m", "l"] = "s") -> str:
|
|
101
|
+
"""获取用户头像的本地URL"""
|
|
102
|
+
return str(
|
|
103
|
+
yarl.URL.build(
|
|
104
|
+
scheme="http", host="tiebameow.local", path="/portrait", query={"id": portrait, "size": size}
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def _get_image_url(image_hash: str, size: Literal["s", "m", "l"] = "s") -> str:
|
|
110
|
+
"""获取图片的本地URL"""
|
|
111
|
+
return str(
|
|
112
|
+
yarl.URL.build(
|
|
113
|
+
scheme="http", host="tiebameow.local", path="/image", query={"hash": image_hash, "size": size}
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _get_forum_icon_url(fname: str) -> str:
|
|
119
|
+
"""获取吧头像的本地URL"""
|
|
120
|
+
return str(yarl.URL.build(scheme="http", host="tiebameow.local", path="/forum", query={"fname": fname}))
|
|
121
|
+
|
|
122
|
+
async def _handle_route(self, route: Route) -> None:
|
|
123
|
+
"""
|
|
124
|
+
处理 Playwright 的路由请求
|
|
125
|
+
|
|
126
|
+
拦截对 tiebameow.local 的请求,并根据请求路径提供相应的资源。
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
route: Playwright 的路由对象
|
|
130
|
+
"""
|
|
131
|
+
url = yarl.URL(route.request.url)
|
|
132
|
+
|
|
133
|
+
if url.host != "tiebameow.local":
|
|
134
|
+
await route.continue_()
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
if str(url) == FONT_URL:
|
|
138
|
+
if font_path.exists():
|
|
139
|
+
await route.fulfill(path=font_path)
|
|
140
|
+
else:
|
|
141
|
+
await route.abort()
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
if url.path == "/portrait":
|
|
146
|
+
portrait = url.query.get("id")
|
|
147
|
+
size = url.query.get("size", "s")
|
|
148
|
+
if not portrait:
|
|
149
|
+
await route.abort()
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
path = ""
|
|
153
|
+
if size == "s":
|
|
154
|
+
path = "n"
|
|
155
|
+
elif size == "l":
|
|
156
|
+
path = "h"
|
|
157
|
+
|
|
158
|
+
real_url = yarl.URL.build(
|
|
159
|
+
scheme="http", host="tb.himg.baidu.com", path=f"/sys/portrait{path}/item/{portrait}"
|
|
160
|
+
)
|
|
161
|
+
await self._proxy_request(route, str(real_url))
|
|
162
|
+
|
|
163
|
+
elif url.path == "/image":
|
|
164
|
+
image_hash = url.query.get("hash")
|
|
165
|
+
size = url.query.get("size", "s")
|
|
166
|
+
|
|
167
|
+
if not image_hash:
|
|
168
|
+
await route.abort()
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
if size == "s":
|
|
172
|
+
real_url = yarl.URL.build(
|
|
173
|
+
scheme="http",
|
|
174
|
+
host="imgsrc.baidu.com",
|
|
175
|
+
path=f"/forum/w=720;q=60;g=0/sign=__/{image_hash}.jpg",
|
|
176
|
+
)
|
|
177
|
+
elif size == "m":
|
|
178
|
+
real_url = yarl.URL.build(
|
|
179
|
+
scheme="http",
|
|
180
|
+
host="imgsrc.baidu.com",
|
|
181
|
+
path=f"/forum/w=960;q=60;g=0/sign=__/{image_hash}.jpg",
|
|
182
|
+
)
|
|
183
|
+
elif size == "l":
|
|
184
|
+
real_url = yarl.URL.build(
|
|
185
|
+
scheme="http", host="imgsrc.baidu.com", path=f"/forum/pic/item/{image_hash}.jpg"
|
|
186
|
+
)
|
|
187
|
+
else:
|
|
188
|
+
await route.abort()
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
await self._proxy_request(route, str(real_url))
|
|
192
|
+
|
|
193
|
+
elif url.path == "/forum":
|
|
194
|
+
fname = url.query.get("fname")
|
|
195
|
+
if not fname:
|
|
196
|
+
await route.abort()
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
forum_info = await self.client.get_forum(fname)
|
|
201
|
+
if forum_info and forum_info.small_avatar:
|
|
202
|
+
await self._proxy_request(route, forum_info.small_avatar)
|
|
203
|
+
else:
|
|
204
|
+
await route.abort()
|
|
205
|
+
except Exception:
|
|
206
|
+
await route.abort()
|
|
207
|
+
|
|
208
|
+
else:
|
|
209
|
+
await route.abort()
|
|
210
|
+
|
|
211
|
+
except Exception as e:
|
|
212
|
+
logger.error(f"Error handling route {url}: {e}")
|
|
213
|
+
await route.abort()
|
|
214
|
+
|
|
215
|
+
async def _proxy_request(self, route: Route, url: str) -> None:
|
|
216
|
+
try:
|
|
217
|
+
response = await self.client.get_image_bytes(url)
|
|
218
|
+
await route.fulfill(body=response.data)
|
|
219
|
+
except Exception as e:
|
|
220
|
+
logger.error(f"Failed to proxy request for {url}: {e}")
|
|
221
|
+
await route.abort()
|
|
222
|
+
|
|
223
|
+
async def _build_content_context(
|
|
224
|
+
self,
|
|
225
|
+
content: ThreadDTO | ThreadpDTO | PostDTO | CommentDTO,
|
|
226
|
+
max_image_count: int = 9,
|
|
227
|
+
show_link: bool = True,
|
|
228
|
+
) -> dict[str, Any]:
|
|
229
|
+
"""
|
|
230
|
+
构建渲染内容上下文字典
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
content: 要构建上下文的内容,可以是 ThreadDTO、PostDTO 或 CommentDTO
|
|
234
|
+
max_image_count: 最大包含的图片数量,默认为 9
|
|
235
|
+
show_link: 是否显示 tid 和 pid,默认为 True
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
dict[str, Any]: 包含渲染内容信息的字典
|
|
239
|
+
"""
|
|
240
|
+
context: dict[str, Any] = {
|
|
241
|
+
"text": content.text,
|
|
242
|
+
"create_time": content.create_time,
|
|
243
|
+
"nick_name": content.author.show_name or f"uid:{content.author.user_id}",
|
|
244
|
+
"level": content.author.level,
|
|
245
|
+
"portrait_url": "",
|
|
246
|
+
"image_url_list": [],
|
|
247
|
+
"remain_image_count": 0,
|
|
248
|
+
"sub_text_list": [],
|
|
249
|
+
"sub_html_list": [],
|
|
250
|
+
"tid": content.tid,
|
|
251
|
+
"pid": content.pid,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if isinstance(content, (ThreadDTO, ThreadpDTO, PostDTO)):
|
|
255
|
+
context["image_hash_list"] = [img.hash for img in content.images]
|
|
256
|
+
else:
|
|
257
|
+
context["image_hash_list"] = []
|
|
258
|
+
|
|
259
|
+
if isinstance(content, (ThreadDTO, ThreadpDTO)):
|
|
260
|
+
context["title"] = content.title
|
|
261
|
+
if show_link:
|
|
262
|
+
context["sub_text_list"].append(f"tid: {content.tid}")
|
|
263
|
+
elif isinstance(content, PostDTO):
|
|
264
|
+
context["floor"] = content.floor
|
|
265
|
+
if show_link:
|
|
266
|
+
context["sub_text_list"].append(f"pid: {content.pid}")
|
|
267
|
+
context["comments"] = [await self._build_content_context(c, max_image_count) for c in content.comments]
|
|
268
|
+
elif isinstance(content, CommentDTO):
|
|
269
|
+
context["pid"] = content.cid
|
|
270
|
+
context["floor"] = content.floor
|
|
271
|
+
if show_link:
|
|
272
|
+
context["sub_text_list"].append(f"pid: {content.cid}")
|
|
273
|
+
|
|
274
|
+
if content.author.portrait:
|
|
275
|
+
size: Literal["s", "m", "l"] = "s" if isinstance(content, CommentDTO) else "m"
|
|
276
|
+
context["portrait_url"] = self._get_portrait_url(content.author.portrait, size=size)
|
|
277
|
+
|
|
278
|
+
if context["image_hash_list"]:
|
|
279
|
+
limit = min(max_image_count, len(context["image_hash_list"]))
|
|
280
|
+
context["image_url_list"] = [self._get_image_url(h, size="s") for h in context["image_hash_list"][:limit]]
|
|
281
|
+
context["remain_image_count"] = max(0, len(context["image_hash_list"]) - limit)
|
|
282
|
+
|
|
283
|
+
return context
|
|
284
|
+
|
|
285
|
+
async def _render_html(self, template_name: str, data: dict[str, Any]) -> str:
|
|
286
|
+
"""
|
|
287
|
+
使用指定模板渲染 HTML
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
template_name: 模板名称
|
|
291
|
+
data: 渲染数据字典
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
str: 渲染后的 HTML 字符串
|
|
295
|
+
"""
|
|
296
|
+
template = self.env.get_template(template_name)
|
|
297
|
+
html = await template.render_async(**data)
|
|
298
|
+
return html
|
|
299
|
+
|
|
300
|
+
async def _render_image(
|
|
301
|
+
self,
|
|
302
|
+
template_name: str,
|
|
303
|
+
config: RenderConfig | None = None,
|
|
304
|
+
data: dict[str, Any] | None = None,
|
|
305
|
+
element: str | None = None,
|
|
306
|
+
) -> bytes:
|
|
307
|
+
"""
|
|
308
|
+
使用指定模板渲染图像
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
template_name: 模板名称
|
|
312
|
+
config: 渲染配置,若为 None 则使用默认配置
|
|
313
|
+
data: 渲染数据字典
|
|
314
|
+
element: 要截图的元素选择器,若为 None 则截图全页
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
bytes: 渲染后的图像字节数据
|
|
318
|
+
"""
|
|
319
|
+
html = await self._render_html(template_name, data or {})
|
|
320
|
+
image_bytes = await self.core.render(
|
|
321
|
+
html, config or self.config, element=element, request_handler=self._handle_route
|
|
322
|
+
)
|
|
323
|
+
return image_bytes
|
|
324
|
+
|
|
325
|
+
async def render_content(
|
|
326
|
+
self,
|
|
327
|
+
content: RenderContentType,
|
|
328
|
+
*,
|
|
329
|
+
max_image_count: int = 9,
|
|
330
|
+
prefix_html: str | None = None,
|
|
331
|
+
suffix_html: str | None = None,
|
|
332
|
+
title: str = "",
|
|
333
|
+
**config: Any,
|
|
334
|
+
) -> bytes:
|
|
335
|
+
"""
|
|
336
|
+
渲染内容(贴子或回复)为图像
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
content: 要渲染的内容,可以是 Thread/Post 相关对象
|
|
340
|
+
max_image_count: 最大包含的图片数量,默认为 9
|
|
341
|
+
prefix_html: 文本前缀,可选,支持 HTML
|
|
342
|
+
suffix_html: 文本后缀,可选,支持 HTML
|
|
343
|
+
title: 覆盖标题,可选
|
|
344
|
+
**config: 其他渲染配置参数
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
生成的图像的字节数据
|
|
348
|
+
"""
|
|
349
|
+
await self._ensure_client()
|
|
350
|
+
|
|
351
|
+
render_config = self.config.model_copy(update=config)
|
|
352
|
+
|
|
353
|
+
if isinstance(content, Thread):
|
|
354
|
+
content = convert_aiotieba_thread(content)
|
|
355
|
+
elif isinstance(content, Thread_p):
|
|
356
|
+
content = convert_aiotieba_threadp(content)
|
|
357
|
+
elif isinstance(content, Post):
|
|
358
|
+
content = convert_aiotieba_post(content)
|
|
359
|
+
elif isinstance(content, Comment | Comment_p):
|
|
360
|
+
content = convert_aiotieba_comment(content)
|
|
361
|
+
|
|
362
|
+
content_context = await self._build_content_context(content, max_image_count)
|
|
363
|
+
|
|
364
|
+
if title and isinstance(content, ThreadDTO):
|
|
365
|
+
content_context["title"] = title
|
|
366
|
+
|
|
367
|
+
if prefix_html:
|
|
368
|
+
content_context["prefix_html"] = prefix_html
|
|
369
|
+
if suffix_html:
|
|
370
|
+
content_context["suffix_html"] = suffix_html
|
|
371
|
+
|
|
372
|
+
forum_icon_url = ""
|
|
373
|
+
if content.fname:
|
|
374
|
+
forum_icon_url = self._get_forum_icon_url(content.fname)
|
|
375
|
+
|
|
376
|
+
data = {
|
|
377
|
+
"content": content_context,
|
|
378
|
+
"forum": content.fname,
|
|
379
|
+
"forum_icon_url": forum_icon_url,
|
|
380
|
+
"prefix_html": prefix_html or "",
|
|
381
|
+
"suffix_html": suffix_html or "",
|
|
382
|
+
"style_list": [get_font_style()],
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
image_bytes = await self._render_image("thread.html", config=render_config, data=data)
|
|
386
|
+
return image_bytes
|
|
387
|
+
|
|
388
|
+
async def render_thread_detail(
|
|
389
|
+
self,
|
|
390
|
+
thread: ThreadDTO | ThreadpDTO | Thread | Thread_p,
|
|
391
|
+
posts: Sequence[PostDTO | Post] | None = None,
|
|
392
|
+
*,
|
|
393
|
+
max_image_count: int = 9,
|
|
394
|
+
prefix_html: str | None = None,
|
|
395
|
+
suffix_html: str | None = None,
|
|
396
|
+
ignore_first_floor: bool = True,
|
|
397
|
+
show_thread_info: bool = True,
|
|
398
|
+
show_link: bool = True,
|
|
399
|
+
**config: Any,
|
|
400
|
+
) -> bytes:
|
|
401
|
+
"""
|
|
402
|
+
渲染贴子详情(包含回复)为图像
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
thread: 要渲染的贴子
|
|
406
|
+
posts: 要渲染的回复列表
|
|
407
|
+
max_image_count: 每个楼层最大包含的图片数量,默认为 9
|
|
408
|
+
prefix_html: 贴子文本前缀,可选,支持 HTML
|
|
409
|
+
suffix_html: 贴子文本后缀,可选,支持 HTML
|
|
410
|
+
ignore_first_floor: 是否忽略渲染第一楼(楼主),默认为 True
|
|
411
|
+
show_thread_info: 是否显示贴子信息(转发、点赞、回复数),默认为 True
|
|
412
|
+
show_link: 是否显示 tid 和 pid,默认为 True
|
|
413
|
+
**config: 其他渲染配置参数
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
生成的图像的字节数据
|
|
417
|
+
"""
|
|
418
|
+
await self._ensure_client()
|
|
419
|
+
render_config = self.config.model_copy(update=config)
|
|
420
|
+
|
|
421
|
+
if isinstance(thread, Thread):
|
|
422
|
+
thread = convert_aiotieba_thread(thread)
|
|
423
|
+
elif isinstance(thread, Thread_p):
|
|
424
|
+
thread = convert_aiotieba_threadp(thread)
|
|
425
|
+
|
|
426
|
+
posts_dtos: list[PostDTO] = []
|
|
427
|
+
if posts:
|
|
428
|
+
for p in posts:
|
|
429
|
+
if isinstance(p, Post):
|
|
430
|
+
posts_dtos.append(convert_aiotieba_post(p))
|
|
431
|
+
else:
|
|
432
|
+
posts_dtos.append(p)
|
|
433
|
+
|
|
434
|
+
if ignore_first_floor:
|
|
435
|
+
posts_dtos = [p for p in posts_dtos if p.floor != 1]
|
|
436
|
+
|
|
437
|
+
thread_context = await self._build_content_context(thread, max_image_count, show_link=show_link)
|
|
438
|
+
posts_contexts = await asyncio.gather(*[
|
|
439
|
+
self._build_content_context(p, max_image_count, show_link=show_link) for p in posts_dtos
|
|
440
|
+
])
|
|
441
|
+
|
|
442
|
+
if show_thread_info:
|
|
443
|
+
info_html = await self._render_html(
|
|
444
|
+
"thread_info.html",
|
|
445
|
+
{
|
|
446
|
+
"share_num": thread.share_num,
|
|
447
|
+
"agree_num": thread.agree_num,
|
|
448
|
+
"reply_num": thread.reply_num,
|
|
449
|
+
},
|
|
450
|
+
)
|
|
451
|
+
thread_context["sub_html_list"].append(info_html)
|
|
452
|
+
|
|
453
|
+
forum_icon_url = ""
|
|
454
|
+
if thread.fname:
|
|
455
|
+
forum_icon_url = self._get_forum_icon_url(thread.fname)
|
|
456
|
+
|
|
457
|
+
data = {
|
|
458
|
+
"thread": thread_context,
|
|
459
|
+
"posts": posts_contexts,
|
|
460
|
+
"forum": thread.fname,
|
|
461
|
+
"forum_icon_url": forum_icon_url,
|
|
462
|
+
"prefix_html": prefix_html or "",
|
|
463
|
+
"suffix_html": suffix_html or "",
|
|
464
|
+
"style_list": [get_font_style()],
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
image_bytes = await self._render_image("thread_detail.html", config=render_config, data=data)
|
|
468
|
+
return image_bytes
|
|
469
|
+
|
|
470
|
+
async def text_to_image(
|
|
471
|
+
self,
|
|
472
|
+
text: str,
|
|
473
|
+
*,
|
|
474
|
+
title: str = "",
|
|
475
|
+
header: str = "",
|
|
476
|
+
footer: str = "",
|
|
477
|
+
simple_mode: bool = False,
|
|
478
|
+
**config: Any,
|
|
479
|
+
) -> bytes:
|
|
480
|
+
"""
|
|
481
|
+
将简单的文本渲染为图片
|
|
482
|
+
|
|
483
|
+
Args:
|
|
484
|
+
text: 要渲染的文本
|
|
485
|
+
title: 标题,可选,显示在头部下方的粗体大号字
|
|
486
|
+
header: 页眉,可选,显示在最上方的灰色小号字
|
|
487
|
+
footer: 页脚文本(如页码),可选,显示在最下方的灰色小号字
|
|
488
|
+
simple_mode: 是否使用极简紧凑样式,默认为 False
|
|
489
|
+
**config: 其他渲染配置参数
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
生成的图像的字节数据
|
|
493
|
+
"""
|
|
494
|
+
render_config = self.config.model_copy(update=config)
|
|
495
|
+
|
|
496
|
+
template_name = "text_simple.html" if simple_mode else "text.html"
|
|
497
|
+
|
|
498
|
+
data = {
|
|
499
|
+
"text": text,
|
|
500
|
+
"title": title,
|
|
501
|
+
"header": header,
|
|
502
|
+
"footer": footer,
|
|
503
|
+
"style_list": [get_font_style()],
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
element = ".container" if simple_mode else None
|
|
507
|
+
image_bytes = await self._render_image(template_name, config=render_config, data=data, element=element)
|
|
508
|
+
return image_bytes
|
|
Binary file
|