Files
abot/utils/markdown_to_image.py
2026-02-27 10:02:24 +08:00

278 lines
15 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import subprocess
import time
import markdown
from pathlib import Path
from playwright.async_api import async_playwright
import os
import asyncio
from loguru import logger
async def safe_close_browser(browser, timeout: float = 5.0) -> None:
if not browser:
return
# Close contexts first to reduce hanging risk.
try:
for ctx in browser.contexts:
try:
await asyncio.wait_for(ctx.close(), timeout=timeout)
except Exception:
pass
except Exception:
pass
# Then close browser with timeout.
try:
await asyncio.wait_for(browser.close(), timeout=timeout)
return
except asyncio.TimeoutError:
logger.warning("Browser close timed out, killing process")
except Exception as e:
logger.warning(f"Browser close failed, killing process: {e}")
# Final fallback: kill underlying process.
try:
proc = browser.process
if proc:
proc.kill()
except Exception:
pass
# ================= 样式与 HTML 处理 =================
async def md_str_to_html_content(md_content):
"""
将 Markdown 字符串转换为 HTML 内容字符串(逻辑保持不变)。
"""
# 转换 Markdown 为 HTML
html_body = markdown.markdown(md_content, extensions=['extra', 'codehilite'])
# 保持你原有的 CSS 样式不变
css = """
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', 'Noto Sans CJK SC', 'Microsoft YaHei', sans-serif;
padding: 20px 25px; line-height: 1.6; max-width: 750px; margin: 0 auto;
background-color: #f9f9f9; color: #333; border: 1px solid #f0f0f0; font-size: 16px;
}
h1, h2, h3, h4, h5, h6 { color: #222; margin-top: 24px; margin-bottom: 16px; font-weight: 600; line-height: 1.3; }
h1 { font-size: 2.2em; padding-bottom: 12px; border-bottom: 1px solid #eee; text-align: center; margin-bottom: 25px; color: #1a1a1a; }
h2 { font-size: 1.8em; padding-bottom: 10px; margin-top: 30px; border-bottom: 1px solid #eee; color: #2c3e50; }
h3 { font-size: 1.5em; margin-top: 25px; padding-left: 12px; border-left: 4px solid #ddd; color: #34495e; }
pre, code { background-color: #f5f5f5; padding: 12px; border-radius: 4px; font-family: 'Courier New', Courier, monospace; font-size: 0.95em; border: 1px solid #eee; }
table { border-collapse: collapse; width: 100%; margin: 18px 0; background-color: white; }
th, td { border: 1px solid #eee; padding: 10px 12px; text-align: left; }
th { background-color: #fafafa; font-weight: 600; }
p { margin: 16px 0; color: #333; line-height: 1.8; font-size: 16px; }
ul, ol { padding-left: 25px; margin: 18px 0; }
li { margin: 8px 0; line-height: 1.7; font-size: 16px; }
blockquote { margin: 18px 0; padding: 12px 18px; background-color: #f8f8f8; border-left: 5px solid #ddd; color: #555; font-size: 1em; }
strong { color: #222; font-weight: 600; }
a { color: #3498db; text-decoration: none; }
h3 em { color: #fa8c16; font-style: normal; font-size: 1.1em; }
.signature { margin-top: 35px; text-align: right; color: #777; font-size: 0.95em; font-style: italic; }
</style>
"""
full_html = f'''<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{css}
</head>
<body>
{html_body}
</body>
</html>'''
return full_html
# ================= 浏览器与图片处理 =================
def check_chromium_installed(path):
return os.path.isfile(path) and os.access(path, os.X_OK)
async def html_to_image(html_content, output_image):
"""
优化版:直接注入 HTML 字符串生成图片。
"""
async with async_playwright() as p:
browser_path = None
# 保持你原有的浏览器路径搜索逻辑
if os.name == 'nt':
possible_chrome_paths = [
r"C:\Users\Liu_WIN10\AppData\Local\Google\Chrome\Application\chrome.exe",
r"C:\Users\Liu-OPEN\AppData\Local\Google\Chrome\Application\chrome.exe",
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe"
]
for path in possible_chrome_paths:
if check_chromium_installed(path):
browser_path = path
break
else:
import glob
user_home = os.path.expanduser("~")
glob_pattern = os.path.join(user_home, ".cache", "ms-playwright", "chromium-*", "chrome-linux", "chrome")
chrome_paths = glob.glob(glob_pattern)
for path in sorted(chrome_paths, reverse=True):
if check_chromium_installed(path):
browser_path = path
break
# 启动浏览器,添加关键的稳定性参数
launch_args = [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage" # 解决 Linux 内存共享问题
]
if browser_path:
logger.debug(f"Launch chromium with system chrome: {browser_path}")
browser = await p.chromium.launch(executable_path=browser_path, args=launch_args)
else:
logger.debug("Launch chromium with bundled browser")
browser = await p.chromium.launch(args=launch_args)
try:
# 使用更高的 device_scale_factor 可以让图片更清晰
context = await browser.new_context(device_scale_factor=1)
page = await context.new_page()
# 3. 动态调整高度:先探测内容实际高度
logger.debug("Measure body height")
body_height = await page.evaluate("document.body.scrollHeight")
await page.set_viewport_size({"width": 750, "height": body_height})
# 2. 【关键】强制等待所有字体和 Emoji 加载完成
# 很多时候卡住就是在等字体渲染计算
logger.debug("Wait for fonts ready")
await page.evaluate("document.fonts.ready")
# 【优化核心】:直接设置 HTML 内容,不走 file:// 协议
# 这样可以彻底避免文件读取超时
logger.debug("Set page content")
await page.set_content(html_content, wait_until='load')
# 稍微等待一下确保 CSS 渲染完成
await asyncio.sleep(0.5)
# 截图full_page=True 会自动处理高度)
# 4. 截图增加超时限制,防止死锁
logger.debug(f"Take screenshot: output={output_image}, height={body_height}")
await page.screenshot(
path=output_image,
full_page=True,
timeout=30000, # 30秒硬超时
animations="disabled" # 禁用可能的 CSS 动画
)
if not os.path.exists(output_image):
raise RuntimeError(f"截图失败,输出文件不存在: {output_image}")
finally:
logger.debug("Closing browser")
await safe_close_browser(browser)
# ================= 主转换函数 =================
async def convert_md_str_to_image(md_content: str, output_image: str, max_retries: int = 3) -> str:
"""
主函数:从字符串转换 Markdown 到图片(异步版)。
"""
if not md_content:
raise ValueError("Markdown content cannot be empty")
# 路径准备
project_root = os.getcwd()
temp_dir = Path(project_root) / "temp" / "md2image"
temp_dir.mkdir(parents=True, exist_ok=True)
output_image_path = temp_dir / output_image
last_error = None
for attempt in range(max_retries):
try:
logger.debug(f"尝试第 {attempt + 1}/{max_retries} 次生成图片")
if output_image_path.exists():
os.remove(str(output_image_path))
# 1. 直接获取生成的 HTML 字符串,不再写临时文件
full_html = await md_str_to_html_content(md_content)
# 2. 转换图片
await html_to_image(full_html, str(output_image_path))
# 3. 验证
image_size = os.path.getsize(str(output_image_path))
if image_size < 1024:
raise RuntimeError(f"图片生成异常,大小仅为: {image_size} bytes")
logger.info(f"图片成功生成:{output_image_path}")
return str(output_image_path.resolve())
except Exception as e:
last_error = e
logger.warning(f"{attempt + 1} 次尝试失败: {e}")
if attempt < max_retries - 1:
await asyncio.sleep((attempt + 1) * 2)
raise RuntimeError(f"图片生成失败,已重试 {max_retries} 次。最后错误: {last_error}")
if __name__ == "__main__":
# 示例 Markdown 字符串(包含中文和 Emoji
md_content = """#🌟「4KED康复训练群 - 05-30 总结」🌟
## 📊 今日数据快报
- **总消息数**:📩 约300条
- **最活跃时段**:🔥 09:00-10:00 (📈 50条/小时)
- **聊天时段**:🕒 08:28 - 16:16
## 🌌 话题总结
### 1⃣ 【车辆保险费用上涨】 ⭐⭐⭐⭐⭐
🕒 **聊天时段**11:33 - 13:16 (👥 6人参与
🔍 **话题回顾**
本次讨论围绕 **车辆保险费用上涨** 展开,堪称今日群聊的"流量担当"。一开始,[@Summer✊] 抛出了一个爆炸性问题:"今年车辆保费居然比去年贵",瞬间点燃了大家的热情。随后,[@火鸡味锅巴] 表示支持,提出了 **保险改革导致价格上涨**,认为 **保险公司收益未达预期,保费自然水涨船高**,并举了一个让人信服的例子 **自己的保险从8K+只返了170元**。然而,[@达文西] 却持相反意见,抛出 **可以不买车损险**,强调 **认真开车就能省下大头费用**,还顺手甩出一句调侃"车损是大头"
讨论的高潮出现在 [@啊菜] 的加入,他不仅提出了 **进口车保险确实更贵**,还分享了一段 **奥迪比雷车贵是合理的对比**,让整个话题从抱怨上升到了品牌差异的讨论层面。大家你一言我一语,气氛热烈得像是开了一场线上辩论会!
👍 **金句回顾**"保的少了,保价贵了,主打的就是个减量加价" —— [@火鸡味锅巴]
📌 **额外信息**:讨论中提及了 **保险改革和统一保费政策**,有兴趣的可以去深入研究一下。
### 2⃣ 【幼儿园六一活动攀比】 ⭐⭐⭐⭐
🕒 **聊天时段**15:17 - 15:25 (👥 5人参与
🔍 **高能讨论**
本话题的火花由 [@暗香] 无意间点燃,他随口提到 **幼儿园六一活动零食大礼包攀比**,没想到立刻引发了一场头脑风暴。[@水牛] 率先下场,详细分析了 **老师组织活动的问题**,从 **统一准备没新意** 到 **自己准备变攀比**,娓娓道来,最后得出一个令人拍案叫绝的结论:"这种事情就是老师不会搞"。紧接着,[@Summer✊] 不甘示弱,掏出了 **幽默建议** 作为佐证,比如 **带两瓶拉菲或者直接带钱把同学东西全买了**,让讨论瞬间变得硬核起来。
然而,[@互联网赵括] 却用一贯的幽默风格插话:"带15升哇哈哈",搭配一个搞笑表情"猪头",把严肃的气氛冲淡了不少,引得大家纷纷刷屏"哈哈哈"
📌 **实用干货**:这次聊出了不少好东西,比如推荐了 **编五彩绳作为活动创意**,实测可用,建议收藏!
### 3⃣ 【手工制作高达模型的痛苦】 ⭐⭐⭐⭐
🕒 **聊天时段**09:10 - 09:29 (👥 5人参与
🔍 **讨论亮点**
这次讨论围绕 **手工制作高达模型的痛苦经历** 展开,简直是群聊中的一场"思想盛宴"。一开始,大家还在轻松闲聊,但 [@火鸡味锅巴] 突然抛出了一个独特的视角:"深刻体会了胶佬的痛苦,涂不完的热熔胶",瞬间让话题升温。他还详细补充了 **制作过程中的各种困难**,比如 **热熔胶烫手、时间紧迫、还要上色**,逻辑清晰得让人不得不服。
随后,[@清风] 表示认同,补充了 **可以优化制作比如加LED灯光**,并提到自己如果参与必然"大杀四方"。而 [@Summer✊] 则提出了疑问:"你真弄啊",引发了一轮新的讨论。大家围绕 **制作难度** 和 **创意想法** 你来我往,聊得不亦乐乎。
👍 **精华总结**"太不容易了,时间又紧,明年请假得了" —— [@火鸡味锅巴]
### 4⃣ 【谈恋爱风险与个性妹子】 ⭐⭐⭐
🕒 **聊天时段**13:39 - 14:00 (👥 5人参与
🔍 **精彩瞬间**
这次讨论的焦点是 **谈恋爱的风险**,一开始只是 [@T T] 的随口一问:"现在的男生要谈个恋爱风险蛮高",没想到却掀起了一波热议。[@互联网赵括] 率先响应,提出了 **有个性的妹子通常不差**,并分享了一个真实案例:"我印象里比较有个性的姑娘不会长得太差",让大家对问题有了更直观的理解。随后,[@火鸡味锅巴] 提出了完全不同的 **观点**,理由是 **何必因为一棵树放弃一片森林**,还顺带调侃了一句:"谈恋爱干嘛,互相满足生理需求不就好了"
讨论中,[@Y] 还搬出了搞笑补充 **榜一大哥的调侃**,试图证明 **恋爱风险确实高**,这让话题从日常闲聊上升到了"情感高度"。虽然最后大家没达成一致,但这场唇枪舌剑真是精彩纷呈!
### 5⃣ 【水费欠款离谱事件】 ⭐⭐⭐
🕒 **聊天时段**10:34 - 10:38 (👥 5人参与
🔍 **讨论小结**
相比前面的话题,这次的 **水费欠款事件** 显得轻松不少,但依然趣味横生。话题从 [@雨的回忆] 的一句"买的房子原房东欠了2万多吨水费" 开始,聊着聊着就跑到了 **如何处理欠款的搞笑讨论**。比如,有人提到 **催前房东交钱**[@互联网赵括] 立马接梗,分享了一段 **调侃原房东可能是干屠宰或发电的**,比如 **"拿来发电我都信"**,笑点密集,群里瞬间刷屏了一堆"哈哈"表情。
[@火鸡味锅巴] 还不忘补刀:"欠了多少我也不知道",让这场讨论成了名副其实的"欢乐场"。虽然话题不算深刻,但这种轻松的氛围也让大家放松了不少。
## 🎖️ 今日荣誉榜
🏆 **群聊 MVP**[@火鸡味锅巴]
👑 **获奖理由**
✅ 发起 3 个热门话题,贡献 5 个表情包/段子
✅ **创新贡献**"高达模型制作痛苦心得"(已申请专利 🎉)
✨ *本总结由 AI 自动生成,快来看看你今天是不是最靓的崽!🔥*"""
spath = asyncio.run(convert_md_str_to_image(md_content, "output.png"))
print(spath)