nonebot-plugin-zhihu-parser 0.1.0__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.
Files changed (38) hide show
  1. nonebot_plugin_zhihu_parser/__init__.py +331 -0
  2. nonebot_plugin_zhihu_parser/config.py +84 -0
  3. nonebot_plugin_zhihu_parser/core/clean.py +35 -0
  4. nonebot_plugin_zhihu_parser/core/constants.py +24 -0
  5. nonebot_plugin_zhihu_parser/core/cookie.py +438 -0
  6. nonebot_plugin_zhihu_parser/core/data.py +359 -0
  7. nonebot_plugin_zhihu_parser/core/debounce.py +36 -0
  8. nonebot_plugin_zhihu_parser/core/download.py +464 -0
  9. nonebot_plugin_zhihu_parser/core/exception.py +53 -0
  10. nonebot_plugin_zhihu_parser/core/parsers/__init__.py +1 -0
  11. nonebot_plugin_zhihu_parser/core/parsers/base.py +345 -0
  12. nonebot_plugin_zhihu_parser/core/parsers/zhihu/__init__.py +3 -0
  13. nonebot_plugin_zhihu_parser/core/parsers/zhihu/card.py +340 -0
  14. nonebot_plugin_zhihu_parser/core/parsers/zhihu/common.py +6 -0
  15. nonebot_plugin_zhihu_parser/core/parsers/zhihu/content.py +1268 -0
  16. nonebot_plugin_zhihu_parser/core/parsers/zhihu/handlers.py +269 -0
  17. nonebot_plugin_zhihu_parser/core/parsers/zhihu/identity.py +8 -0
  18. nonebot_plugin_zhihu_parser/core/parsers/zhihu/maintenance.py +261 -0
  19. nonebot_plugin_zhihu_parser/core/parsers/zhihu/parser.py +85 -0
  20. nonebot_plugin_zhihu_parser/core/parsers/zhihu/refresher.py +167 -0
  21. nonebot_plugin_zhihu_parser/core/parsers/zhihu/request.py +209 -0
  22. nonebot_plugin_zhihu_parser/core/parsers/zhihu/zse_signer.py +224 -0
  23. nonebot_plugin_zhihu_parser/core/render.py +712 -0
  24. nonebot_plugin_zhihu_parser/core/resources/HYSongYunLangHeiW-1.ttf +0 -0
  25. nonebot_plugin_zhihu_parser/core/resources/logos/bilibili.png +0 -0
  26. nonebot_plugin_zhihu_parser/core/resources/logos/douyin.png +0 -0
  27. nonebot_plugin_zhihu_parser/core/resources/logos/kuaishou.png +0 -0
  28. nonebot_plugin_zhihu_parser/core/resources/logos/tiktok.png +0 -0
  29. nonebot_plugin_zhihu_parser/core/resources/logos/twitter.png +0 -0
  30. nonebot_plugin_zhihu_parser/core/resources/logos/weibo.png +0 -0
  31. nonebot_plugin_zhihu_parser/core/resources/logos/xhs.png +0 -0
  32. nonebot_plugin_zhihu_parser/core/resources/logos/youtube.png +0 -0
  33. nonebot_plugin_zhihu_parser/core/resources/media_button.png +0 -0
  34. nonebot_plugin_zhihu_parser/core/utils.py +242 -0
  35. nonebot_plugin_zhihu_parser-0.1.0.dist-info/METADATA +128 -0
  36. nonebot_plugin_zhihu_parser-0.1.0.dist-info/RECORD +38 -0
  37. nonebot_plugin_zhihu_parser-0.1.0.dist-info/WHEEL +4 -0
  38. nonebot_plugin_zhihu_parser-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,438 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import dataclass
5
+ from http import cookiejar
6
+ from http.cookies import SimpleCookie
7
+ from urllib.parse import urlparse
8
+
9
+ from nonebot.log import logger
10
+
11
+ from nonebot_plugin_zhihu_parser.config import ParserItem, PluginConfig
12
+
13
+ @dataclass(slots=True)
14
+ class Cookie:
15
+ domain: str
16
+ path: str
17
+ name: str
18
+ value: str
19
+ secure: bool
20
+ expires: int
21
+
22
+ def __post_init__(self) -> None:
23
+ self.domain = self.domain.lower()
24
+
25
+ def is_expired(self) -> bool:
26
+ return self.expires != 0 and self.expires < int(time.time())
27
+
28
+ def match(self, domain: str, path: str, secure: bool) -> bool:
29
+ if self.is_expired():
30
+ return False
31
+
32
+ if self.secure and not secure:
33
+ return False
34
+
35
+ if not self._domain_matches(domain):
36
+ return False
37
+
38
+ return self._path_matches(path)
39
+
40
+ def _domain_matches(self, request_domain: str) -> bool:
41
+ cookie_domain = self.domain
42
+
43
+ if cookie_domain.startswith("."):
44
+ suffix = cookie_domain[1:]
45
+ return request_domain == suffix or request_domain.endswith(f".{suffix}")
46
+
47
+ return request_domain == cookie_domain
48
+
49
+ def _path_matches(self, request_path: str) -> bool:
50
+ if request_path == self.path:
51
+ return True
52
+ if not request_path.startswith(self.path):
53
+ return False
54
+ if self.path.endswith("/"):
55
+ return True
56
+ return (
57
+ len(request_path) > len(self.path) and request_path[len(self.path)] == "/"
58
+ )
59
+
60
+
61
+ class CookieJar:
62
+ def __init__(
63
+ self, config: PluginConfig, parser_cfg: ParserItem, domain: str
64
+ ) -> None:
65
+ self.domain = domain.lower()
66
+
67
+ self.cookie_file = config.cookie_dir / f"{parser_cfg.name}_cookies.txt"
68
+ self.cookies: list[Cookie] = []
69
+
70
+ self.raw_cookies = parser_cfg.cookies
71
+ self.cookies_str = ""
72
+
73
+ if self.raw_cookies:
74
+ self.cookies_str = self.clean_cookies_str(self.raw_cookies)
75
+ self._load_from_cookies_str(self.cookies_str)
76
+ self.save_to_file()
77
+
78
+ if self.cookie_file.exists():
79
+ self.load_from_file()
80
+
81
+ # ---------------- public ----------------
82
+
83
+ def file_exists(self) -> bool:
84
+ return self.cookie_file.exists()
85
+
86
+ def get(
87
+ self, path: str = "/", secure: bool = True, domain: str | None = None
88
+ ) -> dict[str, str]:
89
+ cookies: dict[str, str] = {}
90
+ for cookie in self._ordered_matching_cookies(
91
+ path=path, secure=secure, domain=domain
92
+ ):
93
+ cookies.setdefault(cookie.name, cookie.value)
94
+ return cookies
95
+
96
+ def get_cookie_header(
97
+ self, path: str = "/", secure: bool = True, domain: str | None = None
98
+ ) -> str:
99
+ cookies = self._ordered_matching_cookies(
100
+ path=path, secure=secure, domain=domain
101
+ )
102
+ return "; ".join(f"{cookie.name}={cookie.value}" for cookie in cookies)
103
+
104
+ def get_cookie_header_for_url(self, url: str) -> str:
105
+ parsed = urlparse(url)
106
+ if not parsed.hostname:
107
+ return ""
108
+ return self.get_cookie_header(
109
+ domain=parsed.hostname,
110
+ path=parsed.path or "/",
111
+ secure=parsed.scheme == "https",
112
+ )
113
+
114
+ def purge_expired(self) -> None:
115
+ self.cookies = [c for c in self.cookies if not c.is_expired()]
116
+ self._sync_cookies_str()
117
+ if self.cookie_file.exists() or self.cookies:
118
+ self.save_to_file()
119
+
120
+ def _matching_cookies(
121
+ self, path: str = "/", secure: bool = True, domain: str | None = None
122
+ ) -> list[Cookie]:
123
+ request_domain = (domain or self.domain).lower()
124
+ return [c for c in self.cookies if c.match(request_domain, path, secure)]
125
+
126
+ def _ordered_matching_cookies(
127
+ self, path: str = "/", secure: bool = True, domain: str | None = None
128
+ ) -> list[Cookie]:
129
+ return sorted(
130
+ self._matching_cookies(path=path, secure=secure, domain=domain),
131
+ key=lambda cookie: len(cookie.path),
132
+ reverse=True,
133
+ )
134
+
135
+ def to_dict(self) -> dict[str, str]:
136
+ """将 cookies 字符串转换为字典"""
137
+ return self.get()
138
+
139
+ # ---------------- persistence ----------------
140
+
141
+ @staticmethod
142
+ def _normalize_cookie_newlines(cookies_str: str) -> str:
143
+ return cookies_str.replace("\r\n", "\n").replace("\r", "\n")
144
+
145
+ @staticmethod
146
+ def clean_cookies_str(cookies_str: str) -> str:
147
+ return CookieJar._normalize_cookie_newlines(cookies_str).strip(" \n")
148
+
149
+ @staticmethod
150
+ def _normalize_header_cookies_str(cookies_str: str) -> str:
151
+ return CookieJar.clean_cookies_str(cookies_str).replace("\n", "")
152
+
153
+ @staticmethod
154
+ def _is_netscape_cookie_file(cookies_str: str) -> bool:
155
+ valid_row_count = 0
156
+ for line in cookies_str.splitlines():
157
+ if line.strip().lower() == "# netscape http cookie file":
158
+ return True
159
+ if CookieJar._parse_netscape_cookie_line(line) is not None:
160
+ valid_row_count += 1
161
+ if valid_row_count >= 2:
162
+ return True
163
+ return False
164
+
165
+ @staticmethod
166
+ def _parse_netscape_cookie_line(
167
+ line: str,
168
+ ) -> tuple[str, str, str, str, int, str, str] | None:
169
+ if not line.strip():
170
+ return None
171
+
172
+ line = line.lstrip()
173
+ if line.startswith("#") and not line.startswith("#HttpOnly_"):
174
+ return None
175
+
176
+ if line.startswith("#HttpOnly_"):
177
+ line = line.removeprefix("#HttpOnly_")
178
+
179
+ parts = line.split("\t")
180
+ if len(parts) != 7:
181
+ return None
182
+
183
+ domain, include_subdomains, path, secure, expires, name, value = parts
184
+ if not domain or not path or not name:
185
+ return None
186
+ if path[0] != "/":
187
+ return None
188
+ if any(sep in domain for sep in ("=", ";")):
189
+ return None
190
+ if any(char.isspace() for char in domain):
191
+ return None
192
+ if any(sep in name for sep in ("=", ";")):
193
+ return None
194
+ if any(char.isspace() for char in name):
195
+ return None
196
+ if include_subdomains.upper() not in {"TRUE", "FALSE"}:
197
+ return None
198
+ if secure.upper() not in {"TRUE", "FALSE"}:
199
+ return None
200
+
201
+ try:
202
+ expires_at = int(expires)
203
+ except ValueError:
204
+ return None
205
+
206
+ return domain, include_subdomains, path, secure, expires_at, name, value
207
+
208
+ def _sync_cookies_str(self) -> None:
209
+ self.cookies_str = "; ".join(f"{c.name}={c.value}" for c in self.cookies)
210
+
211
+ def _load_from_cookies_str(self, cookies_str: str) -> None:
212
+ cookies_str = self.clean_cookies_str(cookies_str)
213
+ if not cookies_str:
214
+ return
215
+
216
+ if self._is_netscape_cookie_file(cookies_str):
217
+ self._load_from_netscape_cookies_str(cookies_str)
218
+ return
219
+
220
+ self._load_from_header_cookies_str(cookies_str)
221
+
222
+ def _load_from_header_cookies_str(self, cookies_str: str) -> None:
223
+ normalized = self._normalize_header_cookies_str(cookies_str)
224
+
225
+ for item in normalized.split(";"):
226
+ item = item.strip()
227
+ if not item or "=" not in item:
228
+ continue
229
+
230
+ parts = item.split("=", 1)
231
+ if len(parts) != 2:
232
+ continue
233
+
234
+ name, value = parts
235
+ if not name.strip():
236
+ continue
237
+ self.cookies.append(
238
+ Cookie(
239
+ domain=f".{self.domain}",
240
+ path="/",
241
+ name=name.strip(),
242
+ value=value.strip(),
243
+ secure=True,
244
+ expires=0,
245
+ )
246
+ )
247
+
248
+ self._sync_cookies_str()
249
+
250
+ def _load_from_netscape_cookies_str(self, cookies_str: str) -> None:
251
+ for line in cookies_str.splitlines():
252
+ parsed = self._parse_netscape_cookie_line(line)
253
+ if parsed is None:
254
+ continue
255
+
256
+ domain, include_subdomains, path, secure, expires_at, name, value = parsed
257
+
258
+ if include_subdomains.upper() == "TRUE":
259
+ if not domain.startswith("."):
260
+ domain = f".{domain}"
261
+ else:
262
+ domain = domain.lstrip(".")
263
+
264
+ self.cookies.append(
265
+ Cookie(
266
+ domain=domain,
267
+ path=path,
268
+ name=name,
269
+ value=value,
270
+ secure=secure.upper() == "TRUE",
271
+ expires=expires_at,
272
+ )
273
+ )
274
+
275
+ self._sync_cookies_str()
276
+
277
+ def save_to_file(self) -> None:
278
+ cj = cookiejar.MozillaCookieJar(self.cookie_file)
279
+
280
+ for c in self.cookies:
281
+ cj.set_cookie(
282
+ cookiejar.Cookie(
283
+ version=0,
284
+ name=c.name,
285
+ value=c.value,
286
+ port=None,
287
+ port_specified=False,
288
+ domain=c.domain,
289
+ domain_specified=True,
290
+ domain_initial_dot=c.domain.startswith("."),
291
+ path=c.path,
292
+ path_specified=True,
293
+ secure=c.secure,
294
+ expires=c.expires,
295
+ discard=c.expires == 0,
296
+ comment=None,
297
+ comment_url=None,
298
+ rest={"HttpOnly": ""},
299
+ rfc2109=False,
300
+ )
301
+ )
302
+
303
+ cj.save(ignore_discard=True, ignore_expires=True)
304
+ logger.debug(f"已保存 {len(cj)} 个 Cookie 到 {self.cookie_file}")
305
+
306
+ def load_from_file(self) -> None:
307
+ cj = cookiejar.MozillaCookieJar(self.cookie_file)
308
+ try:
309
+ cj.load(ignore_discard=True, ignore_expires=True)
310
+ except Exception:
311
+ logger.warning(f"加载 cookie 文件失败:{self.cookie_file}")
312
+ return
313
+
314
+ self.cookies = []
315
+ for c in cj:
316
+ self.cookies.append(
317
+ Cookie(
318
+ domain=c.domain,
319
+ path=c.path,
320
+ name=c.name,
321
+ value=c.value or "",
322
+ secure=c.secure,
323
+ expires=c.expires or 0,
324
+ )
325
+ )
326
+
327
+ self._sync_cookies_str()
328
+ logger.debug(f"从文件加载 {len(self.cookies)} 个 Cookie")
329
+
330
+ # ---------------- update from response ----------------
331
+
332
+ def update_from_response(self, set_cookie_headers: list[str]) -> None:
333
+ if not set_cookie_headers:
334
+ return
335
+
336
+ logger.debug(
337
+ f"开始更新 cookies,收到 {len(set_cookie_headers)} 个 Set-Cookie 头"
338
+ )
339
+
340
+ updated = False
341
+ updated_items = []
342
+ added_items = []
343
+ ignored_items = []
344
+
345
+ for header in set_cookie_headers:
346
+ logger.debug(f"解析 Set-Cookie: {header}")
347
+
348
+ sc = SimpleCookie()
349
+ sc.load(header)
350
+
351
+ if not sc:
352
+ logger.debug("解析结果为空,跳过该 header")
353
+ continue
354
+
355
+ for name, morsel in sc.items():
356
+ value = morsel.value
357
+ path = morsel["path"] or "/"
358
+ domain = morsel["domain"] or f".{self.domain}"
359
+ secure = bool(morsel["secure"])
360
+
361
+ expires = 0
362
+ if morsel["expires"]:
363
+ try:
364
+ expires = int(
365
+ time.mktime(
366
+ time.strptime(
367
+ morsel["expires"], "%a, %d-%b-%Y %H:%M:%S %Z"
368
+ )
369
+ )
370
+ )
371
+ except Exception as e:
372
+ logger.debug(
373
+ f"解析 expires 失败: {morsel['expires']},错误: {e}"
374
+ )
375
+ expires = 0
376
+
377
+ existing = next(
378
+ (
379
+ c
380
+ for c in self.cookies
381
+ if c.name == name and c.domain == domain and c.path == path
382
+ ),
383
+ None,
384
+ )
385
+
386
+ if existing:
387
+ # 如果值完全一样,仍然记录但标记为“未变更”
388
+ if (
389
+ existing.value == value
390
+ and existing.secure == secure
391
+ and existing.expires == expires
392
+ ):
393
+ ignored_items.append((name, domain, path))
394
+ logger.debug(
395
+ f"Cookie 未变更,忽略: {name} (domain={domain}, path={path})"
396
+ )
397
+ continue
398
+
399
+ old_value = existing.value
400
+ existing.value = value
401
+ existing.secure = secure
402
+ existing.expires = expires
403
+
404
+ updated_items.append(
405
+ (name, domain, path, old_value, value, secure, expires)
406
+ )
407
+ logger.debug(
408
+ f"Cookie 更新: {name} (domain={domain}, path={path}) "
409
+ f"old_value={old_value} new_value={value} secure={secure} expires={expires}"
410
+ )
411
+ else:
412
+ self.cookies.append(
413
+ Cookie(
414
+ domain=domain,
415
+ path=path,
416
+ name=name,
417
+ value=value,
418
+ secure=secure,
419
+ expires=expires,
420
+ )
421
+ )
422
+ added_items.append((name, domain, path, value, secure, expires))
423
+ logger.debug(
424
+ f"Cookie 新增: {name} (domain={domain}, path={path}) "
425
+ f"value={value} secure={secure} expires={expires}"
426
+ )
427
+
428
+ updated = True
429
+
430
+ if updated:
431
+ self.purge_expired()
432
+ self.save_to_file()
433
+ logger.debug(
434
+ "Cookies 已更新并保存 "
435
+ f"(新增 {len(added_items)},更新 {len(updated_items)},忽略 {len(ignored_items)})"
436
+ )
437
+ logger.debug(f"当前 Cookie 总数: {len(self.cookies)}")
438
+ logger.debug(f"当前 cookies_str: {self.cookies_str}")