fix: restore markdown-based summary hero extraction
This commit is contained in:
+64
-113
@@ -10,6 +10,8 @@ import asyncio
|
||||
import re
|
||||
from loguru import logger
|
||||
|
||||
META_KEYWORDS = ["群", "群名", "时间", "日期", "成员", "消息", "统计", "总结", "来源", "生成", "记录"]
|
||||
|
||||
|
||||
async def safe_close_browser(browser, timeout: float = 4.0) -> None:
|
||||
if not browser:
|
||||
@@ -67,35 +69,44 @@ async def safe_close_browser(browser, timeout: float = 4.0) -> None:
|
||||
logger.warning(f"force kill failed: {e}")
|
||||
|
||||
|
||||
def _clean_text(html: str) -> str:
|
||||
return re.sub(r'\s+', ' ', re.sub(r'<.*?>', ' ', html)).strip()
|
||||
|
||||
|
||||
def _looks_like_meta(html: str) -> bool:
|
||||
clean = _clean_text(html)
|
||||
if not clean:
|
||||
return False
|
||||
if any(k in clean for k in META_KEYWORDS):
|
||||
return True
|
||||
return len(clean) <= 80
|
||||
|
||||
|
||||
def _split_hero(html_body: str):
|
||||
title_match = re.search(r'<h1>(.*?)</h1>', html_body, re.S | re.I)
|
||||
hero_title = title_match.group(1).strip() if title_match else "聊天总结"
|
||||
hero_title = _clean_text(title_match.group(1)) if title_match else "聊天总结"
|
||||
remain = re.sub(r'<h1>.*?</h1>', '', html_body, count=1, flags=re.S | re.I).strip()
|
||||
|
||||
paragraphs = re.findall(r'<p>(.*?)</p>', remain, re.S | re.I)
|
||||
meta_parts = []
|
||||
used = 0
|
||||
for para in paragraphs[:3]:
|
||||
clean = re.sub(r'<.*?>', '', para).strip()
|
||||
if not clean:
|
||||
continue
|
||||
if len(clean) <= 80 or any(k in clean for k in ["群", "时间", "日期", "成员", "消息", "统计", "总结", "来源"]):
|
||||
meta_parts.append(para.strip())
|
||||
used += 1
|
||||
else:
|
||||
block_pattern = re.compile(r'^\s*(<(?:p|blockquote|ul|ol)[^>]*>.*?</(?:p|blockquote|ul|ol)>)', re.S | re.I)
|
||||
meta_blocks = []
|
||||
for _ in range(4):
|
||||
m = block_pattern.match(remain)
|
||||
if not m:
|
||||
break
|
||||
block = m.group(1)
|
||||
if not _looks_like_meta(block):
|
||||
break
|
||||
meta_blocks.append(block.strip())
|
||||
remain = remain[m.end():].strip()
|
||||
|
||||
hero_meta = "<br/>".join(meta_parts) if meta_parts else "群聊总结 / 自动生成"
|
||||
if used > 0:
|
||||
remain = re.sub(r'^(\s*<p>.*?</p>){' + str(used) + r'}', '', remain, count=1, flags=re.S | re.I).strip()
|
||||
|
||||
return hero_title, hero_meta, remain
|
||||
hero_meta = ''.join(meta_blocks)
|
||||
hero_enabled = bool(title_match or meta_blocks)
|
||||
return hero_title, hero_meta, remain, hero_enabled
|
||||
|
||||
|
||||
async def md_str_to_html_content(md_content):
|
||||
"""将 Markdown 字符串转换为更美观的 HTML 内容。"""
|
||||
html_body = markdown.markdown(md_content, extensions=['extra', 'codehilite'])
|
||||
hero_title, hero_meta, remain_html = _split_hero(html_body)
|
||||
hero_title, hero_meta, remain_html, hero_enabled = _split_hero(html_body)
|
||||
|
||||
css = """
|
||||
<style>
|
||||
@@ -164,9 +175,7 @@ async def md_str_to_html_content(md_content):
|
||||
height: 200px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
box-shadow:
|
||||
0 0 0 24px rgba(255,255,255,0.04),
|
||||
0 0 0 56px rgba(255,255,255,0.025);
|
||||
box-shadow: 0 0 0 24px rgba(255,255,255,0.04), 0 0 0 56px rgba(255,255,255,0.025);
|
||||
opacity: 0.9;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -181,7 +190,6 @@ async def md_str_to_html_content(md_content):
|
||||
border: 1px solid rgba(255,255,255,0.18);
|
||||
margin-bottom: 14px;
|
||||
letter-spacing: .06em;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
.hero-title {
|
||||
position: relative;
|
||||
@@ -203,13 +211,15 @@ async def md_str_to_html_content(md_content):
|
||||
font-size: 0.84em;
|
||||
line-height: 1.72;
|
||||
}
|
||||
.hero-meta p {
|
||||
.hero-meta p, .hero-meta blockquote, .hero-meta ul, .hero-meta ol {
|
||||
margin: 4px 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
.content {
|
||||
padding: 24px 34px 34px;
|
||||
}
|
||||
.hero-meta ul, .hero-meta ol { list-style: none; padding-left: 0; }
|
||||
.content { padding: 24px 34px 34px; }
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: var(--text);
|
||||
margin-top: 24px;
|
||||
@@ -218,7 +228,7 @@ async def md_str_to_html_content(md_content):
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
h1 { display: none; }
|
||||
.content.hero-active h1:first-of-type { display: none; }
|
||||
h2 {
|
||||
font-size: 1.42em;
|
||||
margin-top: 30px;
|
||||
@@ -234,24 +244,11 @@ async def md_str_to_html_content(md_content):
|
||||
padding-left: 12px;
|
||||
border-left: 3px solid rgba(20,184,166,0.55);
|
||||
}
|
||||
p {
|
||||
margin: 14px 0;
|
||||
color: #334155;
|
||||
line-height: 1.88;
|
||||
}
|
||||
ul, ol {
|
||||
padding-left: 26px;
|
||||
margin: 14px 0 18px;
|
||||
}
|
||||
li {
|
||||
margin: 8px 0;
|
||||
color: #334155;
|
||||
}
|
||||
p { margin: 14px 0; color: #334155; line-height: 1.88; }
|
||||
ul, ol { padding-left: 26px; margin: 14px 0 18px; }
|
||||
li { margin: 8px 0; color: #334155; }
|
||||
li::marker { color: var(--primary); }
|
||||
strong {
|
||||
color: #1e293b;
|
||||
font-weight: 700;
|
||||
}
|
||||
strong { color: #1e293b; font-weight: 700; }
|
||||
em { color: #5b6b84; }
|
||||
code {
|
||||
background: rgba(109,94,252,0.08);
|
||||
@@ -270,12 +267,7 @@ async def md_str_to_html_content(md_content):
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
|
||||
}
|
||||
pre code {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
pre code { background: transparent; color: inherit; border: none; padding: 0; }
|
||||
table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
@@ -287,17 +279,9 @@ async def md_str_to_html_content(md_content):
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 24px rgba(15,23,42,0.05);
|
||||
}
|
||||
th, td {
|
||||
padding: 12px 14px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(148,163,184,0.12);
|
||||
}
|
||||
th, td { padding: 12px 14px; text-align: left; border-bottom: 1px solid rgba(148,163,184,0.12); }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
th {
|
||||
background: linear-gradient(180deg, rgba(109,94,252,0.10), rgba(109,94,252,0.04));
|
||||
color: #334155;
|
||||
font-weight: 700;
|
||||
}
|
||||
th { background: linear-gradient(180deg, rgba(109,94,252,0.10), rgba(109,94,252,0.04)); color: #334155; font-weight: 700; }
|
||||
blockquote {
|
||||
margin: 18px 0;
|
||||
padding: 14px 18px;
|
||||
@@ -307,27 +291,22 @@ async def md_str_to_html_content(md_content):
|
||||
border-radius: 14px;
|
||||
color: #355468;
|
||||
}
|
||||
hr {
|
||||
border: none;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(148,163,184,0.35), transparent);
|
||||
margin: 26px 0;
|
||||
}
|
||||
a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dashed rgba(109,94,252,0.35);
|
||||
}
|
||||
.signature {
|
||||
margin-top: 34px;
|
||||
text-align: right;
|
||||
color: #73849c;
|
||||
font-size: 0.95em;
|
||||
font-style: italic;
|
||||
}
|
||||
hr { border: none; height: 1px; background: linear-gradient(90deg, transparent, rgba(148,163,184,0.35), transparent); margin: 26px 0; }
|
||||
a { color: var(--primary); text-decoration: none; border-bottom: 1px dashed rgba(109,94,252,0.35); }
|
||||
.signature { margin-top: 34px; text-align: right; color: #73849c; font-size: 0.95em; font-style: italic; }
|
||||
</style>
|
||||
"""
|
||||
|
||||
hero_html = ''
|
||||
content_class = 'content hero-active' if hero_enabled else 'content'
|
||||
if hero_enabled:
|
||||
hero_html = f'''
|
||||
<div class="hero">
|
||||
<div class="hero-badge">AI 群聊总结</div>
|
||||
<h1 class="hero-title">{hero_title}</h1>
|
||||
<div class="hero-meta">{hero_meta}</div>
|
||||
</div>'''
|
||||
|
||||
full_html = f'''<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
@@ -335,14 +314,9 @@ async def md_str_to_html_content(md_content):
|
||||
{css}
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="hero">
|
||||
<div class="hero-badge">AI 群聊总结</div>
|
||||
<h1 class="hero-title">{hero_title}</h1>
|
||||
<div class="hero-meta">{hero_meta}</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
{remain_html}
|
||||
<div class="wrap">{hero_html}
|
||||
<div class="{content_class}">
|
||||
{remain_html if hero_enabled else html_body}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
@@ -378,12 +352,7 @@ async def html_to_image(html_content, output_image):
|
||||
browser_path = path
|
||||
break
|
||||
|
||||
launch_args = [
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu"
|
||||
]
|
||||
launch_args = ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"]
|
||||
|
||||
if browser_path:
|
||||
logger.debug(f"Launch chromium with system chrome: {browser_path}")
|
||||
@@ -393,29 +362,17 @@ async def html_to_image(html_content, output_image):
|
||||
browser = await p.chromium.launch(args=launch_args)
|
||||
|
||||
try:
|
||||
context = await browser.new_context(
|
||||
viewport={"width": 780, "height": 960},
|
||||
device_scale_factor=1.2
|
||||
)
|
||||
context = await browser.new_context(viewport={"width": 780, "height": 960}, device_scale_factor=1.2)
|
||||
page = await context.new_page()
|
||||
|
||||
logger.debug("Set page content")
|
||||
await page.set_content(html_content, wait_until='domcontentloaded', timeout=15000)
|
||||
|
||||
logger.debug("Wait for fonts ready")
|
||||
await page.evaluate("document.fonts.ready")
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
logger.debug(f"Take screenshot: output={output_image}")
|
||||
await page.screenshot(
|
||||
path=output_image,
|
||||
full_page=True,
|
||||
timeout=15000,
|
||||
animations="disabled"
|
||||
)
|
||||
await page.screenshot(path=output_image, full_page=True, timeout=15000, animations="disabled")
|
||||
if not os.path.exists(output_image):
|
||||
raise RuntimeError(f"截图失败,输出文件不存在: {output_image}")
|
||||
|
||||
finally:
|
||||
logger.debug("Closing browser")
|
||||
await safe_close_browser(browser)
|
||||
@@ -431,24 +388,18 @@ async def convert_md_str_to_image(md_content: str, output_image: str, max_retrie
|
||||
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))
|
||||
|
||||
full_html = await md_str_to_html_content(md_content)
|
||||
await html_to_image(full_html, str(output_image_path))
|
||||
|
||||
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}")
|
||||
|
||||
Reference in New Issue
Block a user