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/models/dto.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from functools import cached_property
|
|
5
|
+
from types import UnionType
|
|
6
|
+
from typing import Any, Literal, Self, get_args, get_origin
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
from ..schemas.fragments import FragAtModel, FragImageModel, Fragment, TypeFragText
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BaseDTO(BaseModel):
|
|
14
|
+
"""
|
|
15
|
+
基础 DTO 类。
|
|
16
|
+
|
|
17
|
+
在保证类型严格的同时,允许从不完整的数据源构造 DTO 对象。
|
|
18
|
+
缺失的字段将自动填充为该类型的零值。
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
model_config = ConfigDict(from_attributes=True)
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_incomplete_data(cls, data: dict[str, Any] | BaseModel | None = None) -> Self:
|
|
25
|
+
"""
|
|
26
|
+
递归补全不完整的数据源缺失的字段并返回 DTO 实例。
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
data: 不完整的数据源,可以是字典或 Pydantic 模型实例。
|
|
30
|
+
Returns:
|
|
31
|
+
补全后的 DTO 实例。
|
|
32
|
+
"""
|
|
33
|
+
if data is None:
|
|
34
|
+
data = {}
|
|
35
|
+
if isinstance(data, BaseModel):
|
|
36
|
+
data = data.model_dump()
|
|
37
|
+
|
|
38
|
+
input_payload = data.copy()
|
|
39
|
+
|
|
40
|
+
for field_name, field_info in cls.model_fields.items():
|
|
41
|
+
field_type = field_info.annotation
|
|
42
|
+
|
|
43
|
+
if field_name in input_payload:
|
|
44
|
+
curr_value = input_payload[field_name]
|
|
45
|
+
|
|
46
|
+
if curr_value is None:
|
|
47
|
+
zero_val = cls._get_zero_value(field_type)
|
|
48
|
+
if zero_val is not None:
|
|
49
|
+
input_payload[field_name] = zero_val
|
|
50
|
+
curr_value = zero_val
|
|
51
|
+
|
|
52
|
+
# 特殊处理:如果字段是 Pydantic 模型,但传入的是 dict,需要递归补全
|
|
53
|
+
# 防止嵌套对象内部缺字段
|
|
54
|
+
if isinstance(curr_value, dict) and isinstance(field_type, type) and issubclass(field_type, BaseModel):
|
|
55
|
+
if issubclass(field_type, BaseDTO):
|
|
56
|
+
input_payload[field_name] = field_type.from_incomplete_data(curr_value)
|
|
57
|
+
# 处理没有继承 BaseDTO 的普通 Pydantic 模型
|
|
58
|
+
else:
|
|
59
|
+
zero_obj = cls._get_zero_value(field_type)
|
|
60
|
+
# 用传入的 value 覆盖 zero_obj
|
|
61
|
+
if isinstance(zero_obj, BaseModel):
|
|
62
|
+
merged_data = zero_obj.model_dump()
|
|
63
|
+
merged_data.update(curr_value)
|
|
64
|
+
input_payload[field_name] = field_type.model_validate(merged_data)
|
|
65
|
+
|
|
66
|
+
else:
|
|
67
|
+
input_payload[field_name] = cls._get_zero_value(field_type)
|
|
68
|
+
|
|
69
|
+
return cls.model_validate(input_payload)
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def _get_zero_value(cls, field_type: Any) -> Any:
|
|
73
|
+
"""根据类型注解生成对应的零值。"""
|
|
74
|
+
# 处理 ForwardRef 或字符串类型的注解
|
|
75
|
+
type_str = None
|
|
76
|
+
if hasattr(field_type, "__forward_arg__"):
|
|
77
|
+
type_str = field_type.__forward_arg__
|
|
78
|
+
elif isinstance(field_type, str):
|
|
79
|
+
type_str = field_type
|
|
80
|
+
|
|
81
|
+
if type_str:
|
|
82
|
+
# 简单的字符串匹配,处理常见的容器类型
|
|
83
|
+
clean_str = type_str.strip()
|
|
84
|
+
if clean_str.startswith("list[") or clean_str == "list":
|
|
85
|
+
return []
|
|
86
|
+
if clean_str.startswith("dict[") or clean_str == "dict":
|
|
87
|
+
return {}
|
|
88
|
+
if clean_str.startswith("set[") or clean_str == "set":
|
|
89
|
+
return set()
|
|
90
|
+
|
|
91
|
+
origin = get_origin(field_type)
|
|
92
|
+
args = get_args(field_type)
|
|
93
|
+
|
|
94
|
+
if origin is Literal:
|
|
95
|
+
return args[0]
|
|
96
|
+
if origin is list:
|
|
97
|
+
return []
|
|
98
|
+
if origin is dict:
|
|
99
|
+
return {}
|
|
100
|
+
if origin is set:
|
|
101
|
+
return set()
|
|
102
|
+
if field_type is int:
|
|
103
|
+
return 0
|
|
104
|
+
if field_type is float:
|
|
105
|
+
return 0.0
|
|
106
|
+
if field_type is str:
|
|
107
|
+
return ""
|
|
108
|
+
if field_type is bool:
|
|
109
|
+
return False
|
|
110
|
+
if field_type is datetime:
|
|
111
|
+
return datetime.fromtimestamp(0)
|
|
112
|
+
|
|
113
|
+
if isinstance(field_type, type) and issubclass(field_type, BaseModel):
|
|
114
|
+
if issubclass(field_type, BaseDTO):
|
|
115
|
+
return field_type.from_incomplete_data({})
|
|
116
|
+
else:
|
|
117
|
+
dummy_data = {name: cls._get_zero_value(f.annotation) for name, f in field_type.model_fields.items()}
|
|
118
|
+
return field_type.model_validate(dummy_data)
|
|
119
|
+
|
|
120
|
+
# 预留复杂类型的处理接口
|
|
121
|
+
if origin is UnionType and type(None) in args:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class BaseForumDTO(BaseDTO):
|
|
128
|
+
fid: int
|
|
129
|
+
fname: str
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class BaseUserDTO(BaseDTO):
|
|
133
|
+
user_id: int
|
|
134
|
+
portrait: str
|
|
135
|
+
user_name: str
|
|
136
|
+
nick_name_new: str
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def nick_name(self) -> str:
|
|
140
|
+
return self.nick_name_new
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def show_name(self) -> str:
|
|
144
|
+
return self.nick_name_new or self.user_name
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class ThreadUserDTO(BaseUserDTO):
|
|
148
|
+
level: int
|
|
149
|
+
glevel: int
|
|
150
|
+
|
|
151
|
+
gender: Literal["UNKNOWN", "MALE", "FEMALE"]
|
|
152
|
+
icons: list[str]
|
|
153
|
+
|
|
154
|
+
is_bawu: bool
|
|
155
|
+
is_vip: bool
|
|
156
|
+
is_god: bool
|
|
157
|
+
|
|
158
|
+
priv_like: Literal["PUBLIC", "FRIEND", "HIDE"]
|
|
159
|
+
priv_reply: Literal["ALL", "FANS", "FOLLOW"]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class PostUserDTO(BaseUserDTO):
|
|
163
|
+
level: int
|
|
164
|
+
glevel: int
|
|
165
|
+
|
|
166
|
+
gender: Literal["UNKNOWN", "MALE", "FEMALE"]
|
|
167
|
+
ip: str
|
|
168
|
+
icons: list[str]
|
|
169
|
+
|
|
170
|
+
is_bawu: bool
|
|
171
|
+
is_vip: bool
|
|
172
|
+
is_god: bool
|
|
173
|
+
|
|
174
|
+
priv_like: Literal["PUBLIC", "FRIEND", "HIDE"]
|
|
175
|
+
priv_reply: Literal["ALL", "FANS", "FOLLOW"]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class CommentUserDTO(BaseUserDTO):
|
|
179
|
+
level: int
|
|
180
|
+
|
|
181
|
+
gender: Literal["UNKNOWN", "MALE", "FEMALE"]
|
|
182
|
+
icons: list[str]
|
|
183
|
+
|
|
184
|
+
is_bawu: bool
|
|
185
|
+
is_vip: bool
|
|
186
|
+
is_god: bool
|
|
187
|
+
|
|
188
|
+
priv_like: Literal["PUBLIC", "FRIEND", "HIDE"]
|
|
189
|
+
priv_reply: Literal["ALL", "FANS", "FOLLOW"]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class UserInfoDTO(BaseUserDTO):
|
|
193
|
+
nick_name_old: str
|
|
194
|
+
tieba_uid: int
|
|
195
|
+
|
|
196
|
+
glevel: int
|
|
197
|
+
gender: Literal["UNKNOWN", "MALE", "FEMALE"]
|
|
198
|
+
age: float
|
|
199
|
+
post_num: int
|
|
200
|
+
agree_num: int
|
|
201
|
+
fan_num: int
|
|
202
|
+
follow_num: int
|
|
203
|
+
forum_num: int
|
|
204
|
+
sign: str
|
|
205
|
+
ip: str
|
|
206
|
+
icons: list[str]
|
|
207
|
+
|
|
208
|
+
is_vip: bool
|
|
209
|
+
is_god: bool
|
|
210
|
+
is_blocked: bool
|
|
211
|
+
|
|
212
|
+
priv_like: Literal["PUBLIC", "FRIEND", "HIDE"]
|
|
213
|
+
priv_reply: Literal["ALL", "FANS", "FOLLOW"]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class BaseThreadDTO(BaseDTO):
|
|
217
|
+
pid: int
|
|
218
|
+
tid: int
|
|
219
|
+
fid: int
|
|
220
|
+
fname: str
|
|
221
|
+
|
|
222
|
+
author_id: int
|
|
223
|
+
|
|
224
|
+
title: str
|
|
225
|
+
contents: list[Fragment] = Field(default_factory=list)
|
|
226
|
+
|
|
227
|
+
@cached_property
|
|
228
|
+
def text(self) -> str:
|
|
229
|
+
text = "".join(frag.text for frag in self.contents if isinstance(frag, TypeFragText))
|
|
230
|
+
return text
|
|
231
|
+
|
|
232
|
+
@cached_property
|
|
233
|
+
def full_text(self) -> str:
|
|
234
|
+
text = "".join(frag.text for frag in self.contents if isinstance(frag, TypeFragText))
|
|
235
|
+
return self.title + "\n" + text
|
|
236
|
+
|
|
237
|
+
@cached_property
|
|
238
|
+
def images(self) -> list[FragImageModel]:
|
|
239
|
+
images = [frag for frag in self.contents if isinstance(frag, FragImageModel)]
|
|
240
|
+
return images
|
|
241
|
+
|
|
242
|
+
@cached_property
|
|
243
|
+
def ats(self) -> list[int]:
|
|
244
|
+
ats = [frag.user_id for frag in self.contents if isinstance(frag, FragAtModel)]
|
|
245
|
+
return ats
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class ThreadpDTO(BaseThreadDTO):
|
|
249
|
+
author: ThreadUserDTO
|
|
250
|
+
|
|
251
|
+
is_share: bool
|
|
252
|
+
|
|
253
|
+
agree_num: int
|
|
254
|
+
disagree_num: int
|
|
255
|
+
reply_num: int
|
|
256
|
+
view_num: int
|
|
257
|
+
share_num: int
|
|
258
|
+
create_time: datetime
|
|
259
|
+
|
|
260
|
+
thread_type: int
|
|
261
|
+
share_origin: BaseThreadDTO
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class ThreadDTO(BaseThreadDTO):
|
|
265
|
+
author: ThreadUserDTO
|
|
266
|
+
|
|
267
|
+
is_good: bool
|
|
268
|
+
is_top: bool
|
|
269
|
+
is_share: bool
|
|
270
|
+
is_hide: bool
|
|
271
|
+
is_livepost: bool
|
|
272
|
+
is_help: bool
|
|
273
|
+
|
|
274
|
+
agree_num: int
|
|
275
|
+
disagree_num: int
|
|
276
|
+
reply_num: int
|
|
277
|
+
view_num: int
|
|
278
|
+
share_num: int
|
|
279
|
+
create_time: datetime
|
|
280
|
+
last_time: datetime
|
|
281
|
+
|
|
282
|
+
thread_type: int
|
|
283
|
+
tab_id: int
|
|
284
|
+
share_origin: BaseThreadDTO
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class PostDTO(BaseDTO):
|
|
288
|
+
pid: int
|
|
289
|
+
tid: int
|
|
290
|
+
fid: int
|
|
291
|
+
fname: str
|
|
292
|
+
|
|
293
|
+
author_id: int
|
|
294
|
+
author: PostUserDTO
|
|
295
|
+
|
|
296
|
+
contents: list[Fragment] = Field(default_factory=list)
|
|
297
|
+
sign: str
|
|
298
|
+
comments: list[CommentDTO] = Field(default_factory=list)
|
|
299
|
+
|
|
300
|
+
is_aimeme: bool
|
|
301
|
+
is_thread_author: bool
|
|
302
|
+
|
|
303
|
+
agree_num: int
|
|
304
|
+
disagree_num: int
|
|
305
|
+
reply_num: int
|
|
306
|
+
create_time: datetime
|
|
307
|
+
|
|
308
|
+
floor: int
|
|
309
|
+
|
|
310
|
+
@cached_property
|
|
311
|
+
def text(self) -> str:
|
|
312
|
+
text = "".join(frag.text for frag in self.contents if isinstance(frag, TypeFragText))
|
|
313
|
+
return text
|
|
314
|
+
|
|
315
|
+
@cached_property
|
|
316
|
+
def full_text(self) -> str:
|
|
317
|
+
return self.text
|
|
318
|
+
|
|
319
|
+
@cached_property
|
|
320
|
+
def images(self) -> list[FragImageModel]:
|
|
321
|
+
images = [frag for frag in self.contents if isinstance(frag, FragImageModel)]
|
|
322
|
+
return images
|
|
323
|
+
|
|
324
|
+
@cached_property
|
|
325
|
+
def ats(self) -> list[int]:
|
|
326
|
+
ats = [frag.user_id for frag in self.contents if isinstance(frag, FragAtModel)]
|
|
327
|
+
return ats
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class CommentDTO(BaseDTO):
|
|
331
|
+
cid: int
|
|
332
|
+
pid: int
|
|
333
|
+
tid: int
|
|
334
|
+
fid: int
|
|
335
|
+
fname: str
|
|
336
|
+
|
|
337
|
+
author_id: int
|
|
338
|
+
author: CommentUserDTO
|
|
339
|
+
|
|
340
|
+
contents: list[Fragment] = Field(default_factory=list)
|
|
341
|
+
reply_to_id: int
|
|
342
|
+
|
|
343
|
+
is_thread_author: bool
|
|
344
|
+
|
|
345
|
+
agree_num: int
|
|
346
|
+
disagree_num: int
|
|
347
|
+
create_time: datetime
|
|
348
|
+
|
|
349
|
+
floor: int
|
|
350
|
+
|
|
351
|
+
@cached_property
|
|
352
|
+
def text(self) -> str:
|
|
353
|
+
text = "".join(frag.text for frag in self.contents if isinstance(frag, TypeFragText))
|
|
354
|
+
return text
|
|
355
|
+
|
|
356
|
+
@cached_property
|
|
357
|
+
def full_text(self) -> str:
|
|
358
|
+
return self.text
|
|
359
|
+
|
|
360
|
+
@cached_property
|
|
361
|
+
def ats(self) -> list[int]:
|
|
362
|
+
ats = [frag.user_id for frag in self.contents if isinstance(frag, FragAtModel)]
|
|
363
|
+
return ats
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class PageInfoDTO(BaseDTO):
|
|
367
|
+
page_size: int = 0
|
|
368
|
+
current_page: int = 0
|
|
369
|
+
total_page: int = 0
|
|
370
|
+
total_count: int = 0
|
|
371
|
+
|
|
372
|
+
has_more: bool = False
|
|
373
|
+
has_prev: bool = False
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class ThreadsDTO(BaseDTO):
|
|
377
|
+
objs: list[ThreadDTO] = Field(default_factory=list)
|
|
378
|
+
page: PageInfoDTO
|
|
379
|
+
forum: BaseForumDTO
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class PostsDTO(BaseDTO):
|
|
383
|
+
objs: list[PostDTO] = Field(default_factory=list)
|
|
384
|
+
page: PageInfoDTO
|
|
385
|
+
forum: BaseForumDTO
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
class CommentsDTO(BaseDTO):
|
|
389
|
+
objs: list[CommentDTO] = Field(default_factory=list)
|
|
390
|
+
page: PageInfoDTO
|
|
391
|
+
forum: BaseForumDTO
|