优化IO问题,使用异步方案进行视频下载等操作。

This commit is contained in:
liuwei
2025-06-16 10:11:43 +08:00
parent 34f9158697
commit 02a387628c
5 changed files with 68 additions and 58 deletions

View File

@@ -45,3 +45,5 @@ opencv-python~=4.11.0.86
pathlib~=1.0.1 pathlib~=1.0.1
Glances~=4.3.1 Glances~=4.3.1
aiofiles~=24.1.0

View File

@@ -6,6 +6,7 @@ from pathlib import Path
from playwright.async_api import async_playwright from playwright.async_api import async_playwright
import os import os
import asyncio import asyncio
import aiofiles
from loguru import logger from loguru import logger
@@ -14,9 +15,9 @@ from loguru import logger
# sudo apt-get install -y fonts-noto-cjk fonts-noto-cjk-extra # sudo apt-get install -y fonts-noto-cjk fonts-noto-cjk-extra
# sudo apt-get install -y fonts-noto-color-emoji fonts-noto-cjk fonts-wqy-microhei # sudo apt-get install -y fonts-noto-color-emoji fonts-noto-cjk fonts-wqy-microhei
# 将 Markdown 字符串转换为 HTML # 将 Markdown 字符串转换为 HTML
def md_str_to_html(md_content, output_html): async def md_str_to_html(md_content, output_html):
""" """
将 Markdown 字符串转换为 HTML 文件,并添加支持中文和 Emoji 的样式。 将 Markdown 字符串转换为 HTML 文件,并添加支持中文和 Emoji 的样式(异步版本)
:param md_content: 输入的 Markdown 字符串 :param md_content: 输入的 Markdown 字符串
:param output_html: 输出的 HTML 文件路径 :param output_html: 输出的 HTML 文件路径
@@ -154,14 +155,14 @@ def md_str_to_html(md_content, output_html):
</style> </style>
""" """
# 写入 HTML 文件 # 异步写入 HTML 文件
with open(output_html, 'w', encoding='utf-8') as f: async with aiofiles.open(output_html, 'w', encoding='utf-8') as f:
f.write('<html><head>') await f.write('<html><head>')
f.write('<meta charset="UTF-8">') # 确保 UTF-8 编码 await f.write('<meta charset="UTF-8">') # 确保 UTF-8 编码
f.write(css) await f.write(css)
f.write('</head><body>') await f.write('</head><body>')
f.write(html_content) await f.write(html_content)
f.write('</body></html>') await f.write('</body></html>')
def check_chromium_installed(path): def check_chromium_installed(path):
@@ -277,7 +278,7 @@ async def convert_md_str_to_image(md_content: str, output_image: str) -> str:
try: try:
# 将 Markdown 转换为 HTML # 将 Markdown 转换为 HTML
md_str_to_html(md_content, str(temp_html_path)) await md_str_to_html(md_content, str(temp_html_path))
# 将 HTML 转换为图片 # 将 HTML 转换为图片
await html_to_image(str(temp_html_path), str(output_image_path)) await html_to_image(str(temp_html_path), str(output_image_path))
@@ -292,7 +293,8 @@ async def convert_md_str_to_image(md_content: str, output_image: str) -> str:
# 可选:清理临时 HTML 文件 # 可选:清理临时 HTML 文件
if temp_html_path.exists(): if temp_html_path.exists():
try: try:
temp_html_path.unlink() # 使用异步方式删除文件
await asyncio.to_thread(os.remove, str(temp_html_path))
logger.debug(f"Deleted temporary HTML file: {temp_html_path}") logger.debug(f"Deleted temporary HTML file: {temp_html_path}")
except Exception as e: except Exception as e:
logger.warning(f"Failed to delete temporary HTML file: {e}") logger.warning(f"Failed to delete temporary HTML file: {e}")
@@ -311,7 +313,7 @@ if __name__ == "__main__":
### 1⃣ 【车辆保险费用上涨】 ⭐⭐⭐⭐⭐ ### 1⃣ 【车辆保险费用上涨】 ⭐⭐⭐⭐⭐
🕒 **聊天时段**11:33 - 13:16 (👥 6人参与 🕒 **聊天时段**11:33 - 13:16 (👥 6人参与
🔍 **话题回顾** 🔍 **话题回顾**
本次讨论围绕 **车辆保险费用上涨** 展开,堪称今日群聊的“流量担当”。一开始,[@Summer✊] 抛出了一个爆炸性问题:今年车辆保费居然比去年贵,瞬间点燃了大家的热情。随后,[@火鸡味锅巴] 表示支持,提出了 **保险改革导致价格上涨**,认为 **保险公司收益未达预期,保费自然水涨船高**,并举了一个让人信服的例子 **自己的保险从8K+只返了170元**。然而,[@达文西] 却持相反意见,抛出 **可以不买车损险**,强调 **认真开车就能省下大头费用**,还顺手甩出一句调侃“车损是大头” 本次讨论围绕 **车辆保险费用上涨** 展开,堪称今日群聊的"流量担当"。一开始,[@Summer✊] 抛出了一个爆炸性问题:"今年车辆保费居然比去年贵",瞬间点燃了大家的热情。随后,[@火鸡味锅巴] 表示支持,提出了 **保险改革导致价格上涨**,认为 **保险公司收益未达预期,保费自然水涨船高**,并举了一个让人信服的例子 **自己的保险从8K+只返了170元**。然而,[@达文西] 却持相反意见,抛出 **可以不买车损险**,强调 **认真开车就能省下大头费用**,还顺手甩出一句调侃"车损是大头"
讨论的高潮出现在 [@啊菜] 的加入,他不仅提出了 **进口车保险确实更贵**,还分享了一段 **奥迪比雷车贵是合理的对比**,让整个话题从抱怨上升到了品牌差异的讨论层面。大家你一言我一语,气氛热烈得像是开了一场线上辩论会! 讨论的高潮出现在 [@啊菜] 的加入,他不仅提出了 **进口车保险确实更贵**,还分享了一段 **奥迪比雷车贵是合理的对比**,让整个话题从抱怨上升到了品牌差异的讨论层面。大家你一言我一语,气氛热烈得像是开了一场线上辩论会!
👍 **金句回顾**"保的少了,保价贵了,主打的就是个减量加价" —— [@火鸡味锅巴] 👍 **金句回顾**"保的少了,保价贵了,主打的就是个减量加价" —— [@火鸡味锅巴]
📌 **额外信息**:讨论中提及了 **保险改革和统一保费政策**,有兴趣的可以去深入研究一下。 📌 **额外信息**:讨论中提及了 **保险改革和统一保费政策**,有兴趣的可以去深入研究一下。
@@ -319,28 +321,28 @@ if __name__ == "__main__":
### 2⃣ 【幼儿园六一活动攀比】 ⭐⭐⭐⭐ ### 2⃣ 【幼儿园六一活动攀比】 ⭐⭐⭐⭐
🕒 **聊天时段**15:17 - 15:25 (👥 5人参与 🕒 **聊天时段**15:17 - 15:25 (👥 5人参与
🔍 **高能讨论** 🔍 **高能讨论**
本话题的火花由 [@暗香] 无意间点燃,他随口提到 **幼儿园六一活动零食大礼包攀比**,没想到立刻引发了一场头脑风暴。[@水牛] 率先下场,详细分析了 **老师组织活动的问题**,从 **统一准备没新意** 到 **自己准备变攀比**,娓娓道来,最后得出一个令人拍案叫绝的结论:这种事情就是老师不会搞。紧接着,[@Summer✊] 不甘示弱,掏出了 **幽默建议** 作为佐证,比如 **带两瓶拉菲或者直接带钱把同学东西全买了**,让讨论瞬间变得硬核起来。 本话题的火花由 [@暗香] 无意间点燃,他随口提到 **幼儿园六一活动零食大礼包攀比**,没想到立刻引发了一场头脑风暴。[@水牛] 率先下场,详细分析了 **老师组织活动的问题**,从 **统一准备没新意** 到 **自己准备变攀比**,娓娓道来,最后得出一个令人拍案叫绝的结论:"这种事情就是老师不会搞"。紧接着,[@Summer✊] 不甘示弱,掏出了 **幽默建议** 作为佐证,比如 **带两瓶拉菲或者直接带钱把同学东西全买了**,让讨论瞬间变得硬核起来。
然而,[@互联网赵括] 却用一贯的幽默风格插话:带15升哇哈哈,搭配一个搞笑表情“猪头”,把严肃的气氛冲淡了不少,引得大家纷纷刷屏“哈哈哈” 然而,[@互联网赵括] 却用一贯的幽默风格插话:"带15升哇哈哈",搭配一个搞笑表情"猪头",把严肃的气氛冲淡了不少,引得大家纷纷刷屏"哈哈哈"
📌 **实用干货**:这次聊出了不少好东西,比如推荐了 **编五彩绳作为活动创意**,实测可用,建议收藏! 📌 **实用干货**:这次聊出了不少好东西,比如推荐了 **编五彩绳作为活动创意**,实测可用,建议收藏!
### 3⃣ 【手工制作高达模型的痛苦】 ⭐⭐⭐⭐ ### 3⃣ 【手工制作高达模型的痛苦】 ⭐⭐⭐⭐
🕒 **聊天时段**09:10 - 09:29 (👥 5人参与 🕒 **聊天时段**09:10 - 09:29 (👥 5人参与
🔍 **讨论亮点** 🔍 **讨论亮点**
这次讨论围绕 **手工制作高达模型的痛苦经历** 展开,简直是群聊中的一场“思想盛宴”。一开始,大家还在轻松闲聊,但 [@火鸡味锅巴] 突然抛出了一个独特的视角:深刻体会了胶佬的痛苦,涂不完的热熔胶,瞬间让话题升温。他还详细补充了 **制作过程中的各种困难**,比如 **热熔胶烫手、时间紧迫、还要上色**,逻辑清晰得让人不得不服。 这次讨论围绕 **手工制作高达模型的痛苦经历** 展开,简直是群聊中的一场"思想盛宴"。一开始,大家还在轻松闲聊,但 [@火鸡味锅巴] 突然抛出了一个独特的视角:"深刻体会了胶佬的痛苦,涂不完的热熔胶",瞬间让话题升温。他还详细补充了 **制作过程中的各种困难**,比如 **热熔胶烫手、时间紧迫、还要上色**,逻辑清晰得让人不得不服。
随后,[@清风] 表示认同,补充了 **可以优化制作比如加LED灯光**,并提到自己如果参与必然“大杀四方”。而 [@Summer✊] 则提出了疑问:“你真弄啊”,引发了一轮新的讨论。大家围绕 **制作难度** 和 **创意想法** 你来我往,聊得不亦乐乎。 随后,[@清风] 表示认同,补充了 **可以优化制作比如加LED灯光**,并提到自己如果参与必然"大杀四方"。而 [@Summer✊] 则提出了疑问:"你真弄啊",引发了一轮新的讨论。大家围绕 **制作难度** 和 **创意想法** 你来我往,聊得不亦乐乎。
👍 **精华总结**"太不容易了,时间又紧,明年请假得了" —— [@火鸡味锅巴] 👍 **精华总结**"太不容易了,时间又紧,明年请假得了" —— [@火鸡味锅巴]
### 4⃣ 【谈恋爱风险与个性妹子】 ⭐⭐⭐ ### 4⃣ 【谈恋爱风险与个性妹子】 ⭐⭐⭐
🕒 **聊天时段**13:39 - 14:00 (👥 5人参与 🕒 **聊天时段**13:39 - 14:00 (👥 5人参与
🔍 **精彩瞬间** 🔍 **精彩瞬间**
这次讨论的焦点是 **谈恋爱的风险**,一开始只是 [@T T] 的随口一问:现在的男生要谈个恋爱风险蛮高,没想到却掀起了一波热议。[@互联网赵括] 率先响应,提出了 **有个性的妹子通常不差**,并分享了一个真实案例:我印象里比较有个性的姑娘不会长得太差,让大家对问题有了更直观的理解。随后,[@火鸡味锅巴] 提出了完全不同的 **观点**,理由是 **何必因为一棵树放弃一片森林**,还顺带调侃了一句:谈恋爱干嘛,互相满足生理需求不就好了 这次讨论的焦点是 **谈恋爱的风险**,一开始只是 [@T T] 的随口一问:"现在的男生要谈个恋爱风险蛮高",没想到却掀起了一波热议。[@互联网赵括] 率先响应,提出了 **有个性的妹子通常不差**,并分享了一个真实案例:"我印象里比较有个性的姑娘不会长得太差",让大家对问题有了更直观的理解。随后,[@火鸡味锅巴] 提出了完全不同的 **观点**,理由是 **何必因为一棵树放弃一片森林**,还顺带调侃了一句:"谈恋爱干嘛,互相满足生理需求不就好了"
讨论中,[@Y] 还搬出了搞笑补充 **榜一大哥的调侃**,试图证明 **恋爱风险确实高**,这让话题从日常闲聊上升到了“情感高度”。虽然最后大家没达成一致,但这场唇枪舌剑真是精彩纷呈! 讨论中,[@Y] 还搬出了搞笑补充 **榜一大哥的调侃**,试图证明 **恋爱风险确实高**,这让话题从日常闲聊上升到了"情感高度"。虽然最后大家没达成一致,但这场唇枪舌剑真是精彩纷呈!
### 5⃣ 【水费欠款离谱事件】 ⭐⭐⭐ ### 5⃣ 【水费欠款离谱事件】 ⭐⭐⭐
🕒 **聊天时段**10:34 - 10:38 (👥 5人参与 🕒 **聊天时段**10:34 - 10:38 (👥 5人参与
🔍 **讨论小结** 🔍 **讨论小结**
相比前面的话题,这次的 **水费欠款事件** 显得轻松不少,但依然趣味横生。话题从 [@雨的回忆] 的一句买的房子原房东欠了2万多吨水费 开始,聊着聊着就跑到了 **如何处理欠款的搞笑讨论**。比如,有人提到 **催前房东交钱**[@互联网赵括] 立马接梗,分享了一段 **调侃原房东可能是干屠宰或发电的**,比如 **拿来发电我都信**,笑点密集,群里瞬间刷屏了一堆“哈哈”表情。 相比前面的话题,这次的 **水费欠款事件** 显得轻松不少,但依然趣味横生。话题从 [@雨的回忆] 的一句"买的房子原房东欠了2万多吨水费" 开始,聊着聊着就跑到了 **如何处理欠款的搞笑讨论**。比如,有人提到 **催前房东交钱**[@互联网赵括] 立马接梗,分享了一段 **调侃原房东可能是干屠宰或发电的**,比如 **"拿来发电我都信"**,笑点密集,群里瞬间刷屏了一堆"哈哈"表情。
[@火鸡味锅巴] 还不忘补刀:欠了多少我也不知道,让这场讨论成了名副其实的“欢乐场”。虽然话题不算深刻,但这种轻松的氛围也让大家放松了不少。 [@火鸡味锅巴] 还不忘补刀:"欠了多少我也不知道",让这场讨论成了名副其实的"欢乐场"。虽然话题不算深刻,但这种轻松的氛围也让大家放松了不少。
## 🎖️ 今日荣誉榜 ## 🎖️ 今日荣誉榜
🏆 **群聊 MVP**[@火鸡味锅巴] 🏆 **群聊 MVP**[@火鸡味锅巴]

View File

@@ -1,5 +1,7 @@
import asyncio
import os import os
import requests import aiohttp
import aiofiles
import uuid import uuid
from typing import Optional from typing import Optional
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -24,7 +26,7 @@ class MediaDownloader:
os.makedirs(self.download_dir, exist_ok=True) os.makedirs(self.download_dir, exist_ok=True)
self.LOG.info(f"媒体下载目录: {self.download_dir}") self.LOG.info(f"媒体下载目录: {self.download_dir}")
def download_media(self, url: str, file_type: str = None) -> Optional[str]: async def download_media(self, url: str, file_type: str = None) -> Optional[str]:
""" """
下载媒体文件 下载媒体文件
@@ -42,7 +44,7 @@ class MediaDownloader:
# 如果没有文件名或扩展名,则生成一个随机文件名 # 如果没有文件名或扩展名,则生成一个随机文件名
if not filename or '.' not in filename: if not filename or '.' not in filename:
ext = file_type if file_type else self._guess_file_type(url) ext = file_type if file_type else await self._guess_file_type(url)
filename = f"{uuid.uuid4().hex}.{ext}" if ext else f"{uuid.uuid4().hex}" filename = f"{uuid.uuid4().hex}.{ext}" if ext else f"{uuid.uuid4().hex}"
local_path = os.path.join(self.download_dir, filename) local_path = os.path.join(self.download_dir, filename)
@@ -50,18 +52,19 @@ class MediaDownloader:
self.LOG.info(f"开始下载媒体文件: {url} -> {local_path}") self.LOG.info(f"开始下载媒体文件: {url} -> {local_path}")
# 下载文件 # 下载文件
response = requests.get(url, stream=True, timeout=30) async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=30) as response:
response.raise_for_status() response.raise_for_status()
with open(local_path, 'wb') as f: async with aiofiles.open(local_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192): async for chunk in response.content.iter_chunked(8192):
if chunk: if chunk:
f.write(chunk) await f.write(chunk)
self.LOG.info(f"媒体文件下载成功: {local_path}") self.LOG.info(f"媒体文件下载成功: {local_path}")
# 下载成功后清理旧文件 # 下载成功后清理旧文件
self.clear_downloads() await self.clear_downloads()
return os.path.abspath(local_path) return os.path.abspath(local_path)
@@ -69,7 +72,7 @@ class MediaDownloader:
self.LOG.error(f"下载媒体文件失败: {url}, 错误: {str(e)}") self.LOG.error(f"下载媒体文件失败: {url}, 错误: {str(e)}")
return None return None
def _guess_file_type(self, url: str) -> Optional[str]: async def _guess_file_type(self, url: str) -> Optional[str]:
""" """
从URL推断文件类型 从URL推断文件类型
@@ -97,7 +100,8 @@ class MediaDownloader:
return 'pdf' return 'pdf'
else: else:
# 检查Content-Type # 检查Content-Type
response = requests.head(url, timeout=5) async with aiohttp.ClientSession() as session:
async with session.head(url, timeout=5) as response:
content_type = response.headers.get('Content-Type', '') content_type = response.headers.get('Content-Type', '')
if 'image/jpeg' in content_type: if 'image/jpeg' in content_type:
@@ -111,7 +115,7 @@ class MediaDownloader:
except: except:
return None return None
def clear_downloads(self, max_age_days: int = 3) -> None: async def clear_downloads(self, max_age_days: int = 3) -> None:
""" """
清理超过指定天数的下载文件 清理超过指定天数的下载文件
@@ -136,7 +140,7 @@ class MediaDownloader:
# 如果文件超过最大保留时间,则删除 # 如果文件超过最大保留时间,则删除
if file_age > max_age_seconds: if file_age > max_age_seconds:
try: try:
os.remove(file_path) await asyncio.to_thread(os.remove, file_path)
cleared_count += 1 cleared_count += 1
self.LOG.debug(f"已删除过期文件: {file_path}") self.LOG.debug(f"已删除过期文件: {file_path}")
except Exception as e: except Exception as e:

View File

@@ -13,6 +13,7 @@ import pysilk
from loguru import logger from loguru import logger
from pydub import AudioSegment from pydub import AudioSegment
from pymediainfo import MediaInfo from pymediainfo import MediaInfo
import aiofiles
from utils.video_utils import get_first_frame, get_first_frame_bytes from utils.video_utils import get_first_frame, get_first_frame_bytes
from wechat_ipad import UserLoggedOut from wechat_ipad import UserLoggedOut
@@ -178,8 +179,8 @@ class MessageMixin(WechatAPIClientBase):
elif isinstance(image, bytes): elif isinstance(image, bytes):
image = base64.b64encode(image).decode() image = base64.b64encode(image).decode()
elif isinstance(image, os.PathLike): elif isinstance(image, os.PathLike):
with open(image, 'rb') as f: async with aiofiles.open(image, 'rb') as f:
image = base64.b64encode(f.read()).decode() image = base64.b64encode(await f.read()).decode()
else: else:
raise ValueError("Argument 'image' can only be str, bytes, or os.PathLike") raise ValueError("Argument 'image' can only be str, bytes, or os.PathLike")
@@ -239,8 +240,8 @@ class MessageMixin(WechatAPIClientBase):
video_path = Path(video) video_path = Path(video)
if not video_path.exists(): if not video_path.exists():
raise ValueError(f"Video file does not exist: {video_path}") raise ValueError(f"Video file does not exist: {video_path}")
with open(video_path, "rb") as f: async with aiofiles.open(video_path, "rb") as f:
video_bytes = f.read() video_bytes = await f.read()
file_len = len(video_bytes) file_len = len(video_bytes)
vid_base64 = base64.b64encode(video_bytes).decode() vid_base64 = base64.b64encode(video_bytes).decode()
media_info = MediaInfo.parse(video_path) media_info = MediaInfo.parse(video_path)
@@ -266,8 +267,8 @@ class MessageMixin(WechatAPIClientBase):
elif isinstance(image, bytes): elif isinstance(image, bytes):
image_base64 = base64.b64encode(image).decode() image_base64 = base64.b64encode(image).decode()
elif isinstance(image, os.PathLike): elif isinstance(image, os.PathLike):
with open(image, "rb") as f: async with aiofiles.open(image, "rb") as f:
image_base64 = base64.b64encode(f.read()).decode() image_base64 = base64.b64encode(await f.read()).decode()
else: else:
raise ValueError("image should be str, bytes, or path") raise ValueError("image should be str, bytes, or path")
# self.logging.debug(f"vid_base64:{vid_base64}") # self.logging.debug(f"vid_base64:{vid_base64}")
@@ -327,8 +328,8 @@ class MessageMixin(WechatAPIClientBase):
elif isinstance(voice, bytes): elif isinstance(voice, bytes):
voice_byte = voice voice_byte = voice
elif isinstance(voice, os.PathLike): elif isinstance(voice, os.PathLike):
with open(voice, "rb") as f: async with aiofiles.open(voice, "rb") as f:
voice_byte = f.read() voice_byte = await f.read()
else: else:
raise ValueError("voice should be str, bytes, or path") raise ValueError("voice should be str, bytes, or path")
voice_type = 0 voice_type = 0

View File

@@ -1,6 +1,7 @@
import base64 import base64
import io import io
import os import os
import aiofiles
import aiohttp import aiohttp
import pysilk import pysilk
@@ -234,7 +235,7 @@ class ToolMixin(WechatAPIClientBase):
return False return False
@staticmethod @staticmethod
def base64_to_file(base64_str: str, file_name: str, file_path: str) -> bool: async def base64_to_file(base64_str: str, file_name: str, file_path: str) -> bool:
"""将base64字符串转换为文件并保存。 """将base64字符串转换为文件并保存。
Args: Args:
@@ -256,8 +257,8 @@ class ToolMixin(WechatAPIClientBase):
base64_str = base64_str.split(',')[1] base64_str = base64_str.split(',')[1]
# 解码 base64 并写入文件 # 解码 base64 并写入文件
with open(full_path, 'wb') as f: async with aiofiles.open(full_path, 'wb') as f:
f.write(base64.b64decode(base64_str)) await f.write(base64.b64decode(base64_str))
return True return True
@@ -265,7 +266,7 @@ class ToolMixin(WechatAPIClientBase):
return False return False
@staticmethod @staticmethod
def file_to_base64(file_path: str) -> str: async def file_to_base64(file_path: str) -> str:
"""将文件转换为base64字符串。 """将文件转换为base64字符串。
Args: Args:
@@ -274,8 +275,8 @@ class ToolMixin(WechatAPIClientBase):
Returns: Returns:
str: base64编码的字符串 str: base64编码的字符串
""" """
with open(file_path, 'rb') as f: async with aiofiles.open(file_path, 'rb') as f:
return base64.b64encode(f.read()).decode() return base64.b64encode(await f.read()).decode()
@staticmethod @staticmethod
def base64_to_byte(base64_str: str) -> bytes: def base64_to_byte(base64_str: str) -> bytes: