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.
- nonebot_plugin_zhihu_parser/__init__.py +331 -0
- nonebot_plugin_zhihu_parser/config.py +84 -0
- nonebot_plugin_zhihu_parser/core/clean.py +35 -0
- nonebot_plugin_zhihu_parser/core/constants.py +24 -0
- nonebot_plugin_zhihu_parser/core/cookie.py +438 -0
- nonebot_plugin_zhihu_parser/core/data.py +359 -0
- nonebot_plugin_zhihu_parser/core/debounce.py +36 -0
- nonebot_plugin_zhihu_parser/core/download.py +464 -0
- nonebot_plugin_zhihu_parser/core/exception.py +53 -0
- nonebot_plugin_zhihu_parser/core/parsers/__init__.py +1 -0
- nonebot_plugin_zhihu_parser/core/parsers/base.py +345 -0
- nonebot_plugin_zhihu_parser/core/parsers/zhihu/__init__.py +3 -0
- nonebot_plugin_zhihu_parser/core/parsers/zhihu/card.py +340 -0
- nonebot_plugin_zhihu_parser/core/parsers/zhihu/common.py +6 -0
- nonebot_plugin_zhihu_parser/core/parsers/zhihu/content.py +1268 -0
- nonebot_plugin_zhihu_parser/core/parsers/zhihu/handlers.py +269 -0
- nonebot_plugin_zhihu_parser/core/parsers/zhihu/identity.py +8 -0
- nonebot_plugin_zhihu_parser/core/parsers/zhihu/maintenance.py +261 -0
- nonebot_plugin_zhihu_parser/core/parsers/zhihu/parser.py +85 -0
- nonebot_plugin_zhihu_parser/core/parsers/zhihu/refresher.py +167 -0
- nonebot_plugin_zhihu_parser/core/parsers/zhihu/request.py +209 -0
- nonebot_plugin_zhihu_parser/core/parsers/zhihu/zse_signer.py +224 -0
- nonebot_plugin_zhihu_parser/core/render.py +712 -0
- nonebot_plugin_zhihu_parser/core/resources/HYSongYunLangHeiW-1.ttf +0 -0
- nonebot_plugin_zhihu_parser/core/resources/logos/bilibili.png +0 -0
- nonebot_plugin_zhihu_parser/core/resources/logos/douyin.png +0 -0
- nonebot_plugin_zhihu_parser/core/resources/logos/kuaishou.png +0 -0
- nonebot_plugin_zhihu_parser/core/resources/logos/tiktok.png +0 -0
- nonebot_plugin_zhihu_parser/core/resources/logos/twitter.png +0 -0
- nonebot_plugin_zhihu_parser/core/resources/logos/weibo.png +0 -0
- nonebot_plugin_zhihu_parser/core/resources/logos/xhs.png +0 -0
- nonebot_plugin_zhihu_parser/core/resources/logos/youtube.png +0 -0
- nonebot_plugin_zhihu_parser/core/resources/media_button.png +0 -0
- nonebot_plugin_zhihu_parser/core/utils.py +242 -0
- nonebot_plugin_zhihu_parser-0.1.0.dist-info/METADATA +128 -0
- nonebot_plugin_zhihu_parser-0.1.0.dist-info/RECORD +38 -0
- nonebot_plugin_zhihu_parser-0.1.0.dist-info/WHEEL +4 -0
- nonebot_plugin_zhihu_parser-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from nonebot import on_message, on_command, get_bots
|
|
3
|
+
from nonebot.permission import SUPERUSER
|
|
4
|
+
from nonebot.adapters.onebot.v11 import Message, MessageSegment, MessageEvent, Bot, GroupMessageEvent, PrivateMessageEvent
|
|
5
|
+
from nonebot.log import logger
|
|
6
|
+
from nonebot import get_driver
|
|
7
|
+
from nonebot.rule import Rule
|
|
8
|
+
from nonebot.typing import T_State
|
|
9
|
+
|
|
10
|
+
from nonebot.plugin import PluginMetadata
|
|
11
|
+
from nonebot_plugin_zhihu_parser.config import PluginConfig
|
|
12
|
+
from .core.parsers.zhihu.parser import ZhihuParser
|
|
13
|
+
from .core.download import Downloader
|
|
14
|
+
from .core.render import Renderer
|
|
15
|
+
from .core.debounce import Debouncer
|
|
16
|
+
from .core.clean import CacheCleaner
|
|
17
|
+
from .core.exception import ParseException
|
|
18
|
+
|
|
19
|
+
# 洗血专用依赖
|
|
20
|
+
from nonebot import require
|
|
21
|
+
require("nonebot_plugin_apscheduler")
|
|
22
|
+
from nonebot_plugin_apscheduler import scheduler
|
|
23
|
+
from nonebot import on_command
|
|
24
|
+
from .core.parsers.zhihu.refresher import do_refresh_token
|
|
25
|
+
from .core.parsers.zhihu.maintenance import (
|
|
26
|
+
RefreshMonitor, format_refresh_result, refresh_alert_targets, refresh_job_options,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
global_config = get_driver().config
|
|
30
|
+
# 从 .env 读取配置,如果没有则提供默认值(分批阈值默认10,防抖默认86400秒)
|
|
31
|
+
ZHIHU_BATCH_SIZE = getattr(global_config, "zhihu_batch_size", 10)
|
|
32
|
+
ZHIHU_DEBOUNCE_TIME = getattr(global_config, "zhihu_debounce_time", 86400)
|
|
33
|
+
ZHIHU_CACHE_MAX_AGE = getattr(global_config, "zhihu_cache_max_age", 86400 * 7)
|
|
34
|
+
ZHIHU_MAX_IMAGES = getattr(global_config, "zhihu_max_images", 50)
|
|
35
|
+
|
|
36
|
+
cfg = PluginConfig()
|
|
37
|
+
refresh_monitor = RefreshMonitor(
|
|
38
|
+
cfg.data_dir / "zhihu_refresh_state.json",
|
|
39
|
+
refresh_alert_targets(global_config),
|
|
40
|
+
do_refresh_token,
|
|
41
|
+
lambda: [bot for bot in get_bots().values() if isinstance(bot, Bot)],
|
|
42
|
+
)
|
|
43
|
+
zhihu_parser = None
|
|
44
|
+
zhihu_renderer = None
|
|
45
|
+
# 将配置好的防抖时间传给防抖器
|
|
46
|
+
zhihu_debouncer = Debouncer(ttl_seconds=ZHIHU_DEBOUNCE_TIME)
|
|
47
|
+
|
|
48
|
+
__plugin_meta__ = PluginMetadata(
|
|
49
|
+
name="知乎解析",
|
|
50
|
+
description="解析知乎问题、回答、专栏文章与想法链接,生成预览卡片并转发图文内容",
|
|
51
|
+
usage="直接发送知乎链接即可自动解析;引用含知乎链接的消息回复「解析」可重新解析;管理员发送「知乎保活」可手动刷新登录信息",
|
|
52
|
+
type="application",
|
|
53
|
+
homepage="https://github.com/devil233-ui/nonebot-plugin-zhihu-parser",
|
|
54
|
+
supported_adapters={"~onebot.v11"},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@get_driver().on_startup
|
|
58
|
+
async def init_parser():
|
|
59
|
+
global zhihu_parser, zhihu_renderer
|
|
60
|
+
downloader = Downloader(cfg)
|
|
61
|
+
zhihu_parser = ZhihuParser(cfg, downloader)
|
|
62
|
+
Renderer.load_resources()
|
|
63
|
+
zhihu_renderer = Renderer(cfg)
|
|
64
|
+
max_age = int(ZHIHU_CACHE_MAX_AGE)
|
|
65
|
+
zhihu_cleaner = CacheCleaner(temp_dir=cfg.cache_dir, max_age_seconds=max_age)
|
|
66
|
+
zhihu_cleaner.start()
|
|
67
|
+
|
|
68
|
+
days = max_age / 86400
|
|
69
|
+
logger.info(f"知乎解析插件:{days:g}天缓存自动清理任务已挂载启动!")
|
|
70
|
+
|
|
71
|
+
scheduler.add_job(
|
|
72
|
+
auto_refresh_zhihu_cookie,
|
|
73
|
+
**refresh_job_options(
|
|
74
|
+
scheduler.timezone,
|
|
75
|
+
getattr(global_config, "zhihu_refresh_jitter_seconds", 900),
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def auto_refresh_zhihu_cookie():
|
|
81
|
+
logger.info("[知乎保活] 开始自动保活")
|
|
82
|
+
result = await refresh_monitor.run(cfg)
|
|
83
|
+
if result.ok:
|
|
84
|
+
logger.info(format_refresh_result(result))
|
|
85
|
+
else:
|
|
86
|
+
logger.warning(format_refresh_result(result))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@get_driver().on_bot_connect
|
|
90
|
+
async def resume_refresh_alerts(bot):
|
|
91
|
+
if isinstance(bot, Bot):
|
|
92
|
+
await refresh_monitor.notify_pending()
|
|
93
|
+
|
|
94
|
+
def check_zhihu_url():
|
|
95
|
+
async def _check(bot: Bot, event: MessageEvent, state: T_State) -> bool:
|
|
96
|
+
raw_msg = str(event.get_message()).replace("\\/", "/")
|
|
97
|
+
pat = re.compile(r"(https?://(?:www\.)?zhihu\.com/[^\s\"\'\\]+|https?://zhuanlan\.zhihu\.com/[^\s\"\'\\]+)")
|
|
98
|
+
|
|
99
|
+
# 1. 正常情况:直接检测消息里有没有知乎链接
|
|
100
|
+
match = pat.search(raw_msg)
|
|
101
|
+
if match:
|
|
102
|
+
state['zhihu_match'] = match
|
|
103
|
+
state['zhihu_raw'] = raw_msg
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
# 2. 引用解析情况:检查是不是发了“解析”并且带了引用(reply)
|
|
107
|
+
# 兼容这几个词:解析、提取、解析知乎
|
|
108
|
+
if raw_msg.strip() in ["解析", "提取", "解析知乎"] and event.reply:
|
|
109
|
+
# 提取被引用原消息(哪怕是小程序 JSON 卡片,这里也会变成字符串)
|
|
110
|
+
reply_msg = str(event.reply.message).replace("\\/", "/")
|
|
111
|
+
reply_match = pat.search(reply_msg)
|
|
112
|
+
if reply_match:
|
|
113
|
+
# 狸猫换太子:把引用里的链接和文本存进 state,骗过后面的程序
|
|
114
|
+
state['zhihu_match'] = reply_match
|
|
115
|
+
state['zhihu_raw'] = reply_msg
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
return False
|
|
119
|
+
return Rule(_check)
|
|
120
|
+
|
|
121
|
+
zhihu_matcher = on_message(rule=check_zhihu_url(), priority=10, block=False)
|
|
122
|
+
|
|
123
|
+
# 【新增】手动强制换血指令,方便你随时补充存活期
|
|
124
|
+
refresh_cmd = on_command(
|
|
125
|
+
"刷新知乎ck", aliases={"知乎保活", "知乎刷新"},
|
|
126
|
+
permission=SUPERUSER, priority=5, block=True,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
@refresh_cmd.handle()
|
|
130
|
+
async def force_refresh(bot: Bot, event: MessageEvent):
|
|
131
|
+
await refresh_cmd.send("正在刷新知乎登录信息,请稍候。")
|
|
132
|
+
result = await refresh_monitor.run(cfg)
|
|
133
|
+
await refresh_cmd.send(format_refresh_result(result))
|
|
134
|
+
|
|
135
|
+
@zhihu_matcher.handle()
|
|
136
|
+
async def handle_zhihu(bot: Bot, event: MessageEvent, state: T_State):
|
|
137
|
+
if not zhihu_parser or not zhihu_renderer:
|
|
138
|
+
logger.warning("知乎解析器仍在初始化中,请稍后再试")
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
await zhihu_matcher.send("识别到知乎链接,开始解析")
|
|
142
|
+
|
|
143
|
+
text = state['zhihu_raw']
|
|
144
|
+
base_match = state['zhihu_match']
|
|
145
|
+
|
|
146
|
+
# 原始提取的 URL
|
|
147
|
+
raw_url = base_match.group(0).replace("&", "&")
|
|
148
|
+
# [新增清洗] 暴力剔除 URL 尾部可能因为引用而粘连的中文和特殊标点
|
|
149
|
+
import re
|
|
150
|
+
url = re.sub(r'[\u4e00-\u9fa5\u3000-\u303f\uff00-\uffef\]\})】”"。,!?]+$', '', raw_url)
|
|
151
|
+
|
|
152
|
+
text = text.replace("&", "&")
|
|
153
|
+
session_id = event.get_session_id()
|
|
154
|
+
|
|
155
|
+
if zhihu_debouncer.hit_url(session_id, url):
|
|
156
|
+
logger.info(f"[{session_id}] 链接 {url} 在防抖时间内,已跳过重复解析")
|
|
157
|
+
await zhihu_matcher.send("24小时内有人水果了,端下去罢")
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
handlers = getattr(zhihu_parser, "_handlers", {})
|
|
161
|
+
final_keyword = None
|
|
162
|
+
final_match_obj = None
|
|
163
|
+
|
|
164
|
+
for kw, func in handlers.items():
|
|
165
|
+
pat = getattr(func, "__parser_pattern__", None)
|
|
166
|
+
if pat:
|
|
167
|
+
if isinstance(pat, str): pat = re.compile(pat)
|
|
168
|
+
match = pat.search(text)
|
|
169
|
+
if match:
|
|
170
|
+
final_keyword = kw
|
|
171
|
+
final_match_obj = match
|
|
172
|
+
break
|
|
173
|
+
|
|
174
|
+
if not final_match_obj:
|
|
175
|
+
KNOWN_PATTERNS = [
|
|
176
|
+
("/answer/", re.compile(r"zhihu\.com/question/(?P<question_id>\d+)/answer/(?P<answer_id>\d+)")),
|
|
177
|
+
("www.zhihu.com/question/", re.compile(r"zhihu\.com/question/(?P<question_id>\d+)")),
|
|
178
|
+
("zhuanlan.zhihu.com/p/", re.compile(r"zhuanlan\.zhihu\.com/p/(?P<article_id>\d+)")),
|
|
179
|
+
("www.zhihu.com/pin/", re.compile(r"zhihu\.com/pin/(?P<pin_id>\d+)")),
|
|
180
|
+
]
|
|
181
|
+
for exact_kw, pat in KNOWN_PATTERNS:
|
|
182
|
+
match = pat.search(text)
|
|
183
|
+
if match and exact_kw in handlers:
|
|
184
|
+
final_keyword = exact_kw
|
|
185
|
+
final_match_obj = match
|
|
186
|
+
break
|
|
187
|
+
|
|
188
|
+
if not final_keyword or not final_match_obj:
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
logger.warning(f"🔍 [X光机] 开始解析!命中规则: {final_keyword}, 提取参数: {final_match_obj.group(0)}")
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
parse_res = await zhihu_parser.parse(final_keyword, final_match_obj)
|
|
195
|
+
if not parse_res: return
|
|
196
|
+
|
|
197
|
+
# A. 发送预览卡片
|
|
198
|
+
card_path = await zhihu_renderer.render_card(parse_res)
|
|
199
|
+
if card_path and card_path.exists():
|
|
200
|
+
await zhihu_matcher.send(MessageSegment.image(card_path))
|
|
201
|
+
|
|
202
|
+
# B. 分批次合并转发逻辑
|
|
203
|
+
bot_id = int(bot.self_id)
|
|
204
|
+
img_counter = 0
|
|
205
|
+
batch_num = 1
|
|
206
|
+
current_combined_msg = Message()
|
|
207
|
+
total_img_counter = 0
|
|
208
|
+
limit_reached = False
|
|
209
|
+
|
|
210
|
+
# 初始添加标题
|
|
211
|
+
if getattr(parse_res, "title", None):
|
|
212
|
+
current_combined_msg += MessageSegment.text(f"【{parse_res.title}】\n\n")
|
|
213
|
+
|
|
214
|
+
async def send_current_batch(msg: Message, is_last: bool = False):
|
|
215
|
+
if not msg: return
|
|
216
|
+
prefix = f"(第{batch_num}部分) " if not (batch_num == 1 and is_last) else ""
|
|
217
|
+
node = [MessageSegment.node_custom(user_id=bot_id, nickname=f"知乎解析 {prefix}", content=msg)]
|
|
218
|
+
try:
|
|
219
|
+
if isinstance(event, GroupMessageEvent):
|
|
220
|
+
await bot.call_api("send_group_forward_msg", group_id=event.group_id, messages=node)
|
|
221
|
+
else:
|
|
222
|
+
await bot.call_api("send_private_forward_msg", user_id=event.user_id, messages=node)
|
|
223
|
+
except Exception as e:
|
|
224
|
+
logger.error(f"发送第 {batch_num} 部分转发失败: {e}")
|
|
225
|
+
|
|
226
|
+
# [恢复] 降级单发机制:因为现在有总量限制,可以放心拆包单发了
|
|
227
|
+
await zhihu_matcher.send(f"⚠️ 第 {batch_num} 部分触发风控拦截,正在自动拆分单独发送...")
|
|
228
|
+
|
|
229
|
+
# [新增] 专门处理长文本折叠的内部助手函数
|
|
230
|
+
async def safe_send_text(text_msg: Message):
|
|
231
|
+
t_str = str(text_msg).strip()
|
|
232
|
+
if not t_str: return
|
|
233
|
+
|
|
234
|
+
# 纯文字装进合并转发里“折叠”起来
|
|
235
|
+
if len(t_str):
|
|
236
|
+
t_node = [MessageSegment.node_custom(user_id=bot_id, nickname="风控部分纯文字折叠", content=Message(t_str))]
|
|
237
|
+
try:
|
|
238
|
+
if isinstance(event, GroupMessageEvent):
|
|
239
|
+
await bot.call_api("send_group_forward_msg", group_id=event.group_id, messages=t_node)
|
|
240
|
+
else:
|
|
241
|
+
await bot.call_api("send_private_forward_msg", user_id=event.user_id, messages=t_node)
|
|
242
|
+
except Exception:
|
|
243
|
+
# 终极兜底:万一纯文本转发都失败,截断只发前500字
|
|
244
|
+
await zhihu_matcher.send(t_str[:500] + "\n...[字数过多已截断]")
|
|
245
|
+
else:
|
|
246
|
+
# 不满 150 字的短句(比如图注、小标题)直接发,不影响阅读连贯性
|
|
247
|
+
await zhihu_matcher.send(text_msg)
|
|
248
|
+
|
|
249
|
+
fallback_msg = Message()
|
|
250
|
+
for seg in msg:
|
|
251
|
+
if seg.type == 'text':
|
|
252
|
+
fallback_msg += seg
|
|
253
|
+
else:
|
|
254
|
+
# 先发送之前攒着的文字(带折叠判定)
|
|
255
|
+
try: await safe_send_text(fallback_msg)
|
|
256
|
+
except Exception: pass
|
|
257
|
+
fallback_msg = Message()
|
|
258
|
+
|
|
259
|
+
# 单发媒体
|
|
260
|
+
try: await zhihu_matcher.send(Message(seg))
|
|
261
|
+
except Exception as sub_e:
|
|
262
|
+
logger.error(f"单发媒体失败(严重风控死锁): {sub_e}")
|
|
263
|
+
await zhihu_matcher.send("🚫 [一张图片/视频因触发腾讯极其严格的审核被无情抹杀]")
|
|
264
|
+
|
|
265
|
+
# 收尾剩余文字
|
|
266
|
+
try: await safe_send_text(fallback_msg)
|
|
267
|
+
except Exception: pass
|
|
268
|
+
|
|
269
|
+
# 遍历正文内容
|
|
270
|
+
if hasattr(parse_res, "contents") and parse_res.contents:
|
|
271
|
+
for content in parse_res.contents:
|
|
272
|
+
# 1. 处理文字
|
|
273
|
+
if hasattr(content, "text") and content.text:
|
|
274
|
+
current_combined_msg += MessageSegment.text(str(content.text) + "\n")
|
|
275
|
+
|
|
276
|
+
# 2. 处理图片/媒体(增加精准异常捕获)
|
|
277
|
+
if hasattr(content, "get_path"):
|
|
278
|
+
try:
|
|
279
|
+
file_path = await content.get_path()
|
|
280
|
+
if file_path and getattr(file_path, "exists", lambda: False)():
|
|
281
|
+
# 换完后上一行是:
|
|
282
|
+
ext = file_path.suffix.lower()
|
|
283
|
+
if ext in [".jpg", ".jpeg", ".png", ".gif", ".webp"]:
|
|
284
|
+
current_combined_msg += MessageSegment.image(file_path)
|
|
285
|
+
current_combined_msg += MessageSegment.text("\n")
|
|
286
|
+
img_counter += 1
|
|
287
|
+
total_img_counter += 1 # [新增] 累加总数
|
|
288
|
+
elif ext in [".mp4", ".mov", ".avi"]:
|
|
289
|
+
current_combined_msg += MessageSegment.video(file_path)
|
|
290
|
+
current_combined_msg += MessageSegment.text("\n")
|
|
291
|
+
img_counter += 2
|
|
292
|
+
total_img_counter += 2 # [新增] 累加总数
|
|
293
|
+
except RuntimeError:
|
|
294
|
+
pass
|
|
295
|
+
except Exception as e:
|
|
296
|
+
logger.debug(f"读取媒体失败: {e}")
|
|
297
|
+
|
|
298
|
+
# [修改] 达到单批次阈值 或 达到总量限制 时触发发包
|
|
299
|
+
if img_counter >= ZHIHU_BATCH_SIZE or total_img_counter >= ZHIHU_MAX_IMAGES:
|
|
300
|
+
await send_current_batch(current_combined_msg)
|
|
301
|
+
current_combined_msg = Message()
|
|
302
|
+
img_counter = 0
|
|
303
|
+
batch_num += 1
|
|
304
|
+
|
|
305
|
+
# [新增] 总量阻断:超过设定总量直接掐断循环
|
|
306
|
+
if total_img_counter >= ZHIHU_MAX_IMAGES:
|
|
307
|
+
limit_reached = True
|
|
308
|
+
break
|
|
309
|
+
|
|
310
|
+
# [修改] 如果没有被提前阻断,才发送收尾段落
|
|
311
|
+
if current_combined_msg and not limit_reached:
|
|
312
|
+
await send_current_batch(current_combined_msg, is_last=True)
|
|
313
|
+
|
|
314
|
+
# [新增] 触发了截断则发送文案提示
|
|
315
|
+
if limit_reached:
|
|
316
|
+
await zhihu_matcher.send("不刷屏了,剩下的请点击原链接跳转阅读")
|
|
317
|
+
|
|
318
|
+
except ParseException as e:
|
|
319
|
+
err_msg = str(e)
|
|
320
|
+
logger.warning(f"知乎抓取被阻断: {err_msg}")
|
|
321
|
+
if e.__cause__:
|
|
322
|
+
logger.error(f"💥 [X光机] 底层致命报错: {e.__cause__}")
|
|
323
|
+
|
|
324
|
+
# 触发回滚!把毒数据从防抖池踢出去
|
|
325
|
+
zhihu_debouncer.rollback_url(session_id, url)
|
|
326
|
+
|
|
327
|
+
await zhihu_matcher.send(f"⚠️ 解析失败: {err_msg}")
|
|
328
|
+
|
|
329
|
+
except Exception as e:
|
|
330
|
+
logger.exception("解析知乎链接时发生严重错误")
|
|
331
|
+
zhihu_debouncer.rollback_url(session_id, url)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from nonebot.log import logger
|
|
4
|
+
from nonebot import get_driver
|
|
5
|
+
|
|
6
|
+
class ParserItem(dict):
|
|
7
|
+
"""兼容原代码的替身类,允许通过 .属性名 访问字典键值"""
|
|
8
|
+
def __getattr__(self, item):
|
|
9
|
+
if item not in self:
|
|
10
|
+
# 遇到 enable 系列开关默认返回 True
|
|
11
|
+
if isinstance(item, str) and "enable" in item:
|
|
12
|
+
return True
|
|
13
|
+
return ParserItem()
|
|
14
|
+
return self.get(item)
|
|
15
|
+
|
|
16
|
+
def __getitem__(self, item):
|
|
17
|
+
if item not in self:
|
|
18
|
+
if isinstance(item, str) and "enable" in item:
|
|
19
|
+
return True
|
|
20
|
+
return ParserItem()
|
|
21
|
+
return super().__getitem__(item)
|
|
22
|
+
|
|
23
|
+
def __bool__(self):
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
class PluginConfig:
|
|
27
|
+
def __init__(self):
|
|
28
|
+
# --- 严格执行用户定义的路径规范 ---
|
|
29
|
+
self.data_dir = Path.cwd() / "data" / "nonebot_plugin_zhihu_parser"
|
|
30
|
+
self.config_dir = Path.cwd() / "config" / "nonebot_plugin_zhihu_parser"
|
|
31
|
+
self.temp_dir = Path.cwd() / "cache" / "nonebot_plugin_zhihu_parser"
|
|
32
|
+
|
|
33
|
+
self.cache_dir = self.temp_dir
|
|
34
|
+
|
|
35
|
+
# 确保所有目录存在
|
|
36
|
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
|
|
40
|
+
# 补上 Downloader 等组件需要的属性
|
|
41
|
+
self.source_max_size = 50
|
|
42
|
+
self.download_retry_times = 5
|
|
43
|
+
self.common_timeout = 60
|
|
44
|
+
self.download_timeout = 60
|
|
45
|
+
self.max_duration = 3600
|
|
46
|
+
|
|
47
|
+
# --- Cookie 文件管理 (位于 config 目录) ---
|
|
48
|
+
self.cookie_file = self.config_dir / "zhihu_cookies.txt"
|
|
49
|
+
if not self.cookie_file.exists():
|
|
50
|
+
self.cookie_file.write_text("", encoding="utf-8")
|
|
51
|
+
logger.info(f"[知乎解析] 已在 config 目录下创建 CK 文件: {self.cookie_file}")
|
|
52
|
+
|
|
53
|
+
local_cookie = self.cookie_file.read_text(encoding="utf-8").strip()
|
|
54
|
+
|
|
55
|
+
# 读取 .env.dev 配置作为备选
|
|
56
|
+
nb_config = get_driver().config
|
|
57
|
+
env_cookie = getattr(nb_config, "zhihu_cookie", "").strip()
|
|
58
|
+
|
|
59
|
+
# 优先级:config 目录下的 txt 文件 > .env.dev
|
|
60
|
+
final_cookie = local_cookie if local_cookie else env_cookie
|
|
61
|
+
|
|
62
|
+
# 构造 parser.zhihu 结构,供底层解析器通过 self.mycfg 调用
|
|
63
|
+
self.parser = ParserItem({
|
|
64
|
+
"zhihu": ParserItem({
|
|
65
|
+
"cookie": final_cookie
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
# 基础配置项字典
|
|
70
|
+
self.settings = {
|
|
71
|
+
"proxy": getattr(nb_config, "zhihu_proxy", None),
|
|
72
|
+
"zhihu_cookie": final_cookie,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
def get(self, key, default=None):
|
|
76
|
+
return self.settings.get(key, default)
|
|
77
|
+
|
|
78
|
+
def get_config(self, key: str, default=None):
|
|
79
|
+
return self.settings.get(key, default)
|
|
80
|
+
|
|
81
|
+
def __getattr__(self, item):
|
|
82
|
+
if item in self.settings:
|
|
83
|
+
return self.settings[item]
|
|
84
|
+
return ParserItem()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import asyncio
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from nonebot.log import logger
|
|
5
|
+
|
|
6
|
+
class CacheCleaner:
|
|
7
|
+
def __init__(self, temp_dir: str | Path, max_age_seconds: int = 86400*7):
|
|
8
|
+
"""默认清理 一周 前的缓存文件"""
|
|
9
|
+
self.temp_dir = Path(temp_dir)
|
|
10
|
+
self.max_age = max_age_seconds
|
|
11
|
+
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
|
12
|
+
self._running = False
|
|
13
|
+
|
|
14
|
+
def start(self):
|
|
15
|
+
if not self._running:
|
|
16
|
+
self._running = True
|
|
17
|
+
asyncio.create_task(self._cleanup_loop())
|
|
18
|
+
|
|
19
|
+
async def _cleanup_loop(self):
|
|
20
|
+
while self._running:
|
|
21
|
+
try:
|
|
22
|
+
now = time.time()
|
|
23
|
+
count = 0
|
|
24
|
+
# 遍历目录清理过期文件
|
|
25
|
+
for file_path in self.temp_dir.glob('*'):
|
|
26
|
+
if file_path.is_file() and now - file_path.stat().st_mtime > self.max_age:
|
|
27
|
+
file_path.unlink(missing_ok=True)
|
|
28
|
+
count += 1
|
|
29
|
+
if count > 0:
|
|
30
|
+
logger.info(f"[知乎解析] 后台清理了 {count} 个过期缓存文件")
|
|
31
|
+
except Exception as e:
|
|
32
|
+
logger.error(f"[知乎解析] 缓存清理异常: {e}")
|
|
33
|
+
|
|
34
|
+
# 每 12 小时执行一次检查
|
|
35
|
+
await asyncio.sleep(43200)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
|
|
2
|
+
from typing import Final
|
|
3
|
+
|
|
4
|
+
COMMON_HEADER: Final[dict[str, str]] = {
|
|
5
|
+
"User-Agent": (
|
|
6
|
+
"Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
7
|
+
"Chrome/55.0.2883.87 UBrowser/6.2.4098.3 Safari/537.36"
|
|
8
|
+
)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
IOS_HEADER: Final[dict[str, str]] = {
|
|
12
|
+
"User-Agent": (
|
|
13
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) "
|
|
14
|
+
"Version/16.6 Mobile/15E148 Safari/604.1 Edg/132.0.0.0"
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
ANDROID_HEADER: Final[dict[str, str]] = {
|
|
19
|
+
"User-Agent": (
|
|
20
|
+
"Mozilla/5.0 (Linux; Android 15; SM-G998B) AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
21
|
+
"Chrome/132.0.0.0 Mobile Safari/537.36 Edg/132.0.0.0"
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|