diff --git a/.gitignore b/.gitignore index 79e44c1..a8c8c98 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__/ *.pyc config.json data/ +*_TODO.md diff --git a/src/ai_client.py b/src/ai_client.py index 7e6fc0d..3dbb245 100644 --- a/src/ai_client.py +++ b/src/ai_client.py @@ -29,8 +29,13 @@ SYSTEM_PROMPT = """你是一个邮件摘要助手。请分析邮件内容并以 - category: 邮件类型。推广邮件(营销、促销、广告、 newsletter 推广等)标记为 "promotion";验证码邮件标记为 "notification";普通邮件标记为 "normal" - verification_code: 仅当 category 为 "notification" 且邮件包含验证码时填写,提取完整的验证码字符串(纯数字、字母数字混合等均可,如「123456」「abc-789」「701383」)。非验证码邮件留空字符串 - app_name: 仅当 category 为 "notification" 且邮件包含验证码时填写,填写发送验证码的应用/平台名称(如「GitHub」「微信」「Kraken」)。非验证码邮件留空字符串 -- links: 仅提取以下两类链接:1)邮件明确要求用户点击才能完成某操作的链接(如确认邮箱、重置密码、查看账单、审批请求、查看通知详情等);2)退订/取消订阅链接。不要提取推广性质的链接(如下载App、注册领优惠、查看更多商品等营销链接)、追踪像素、邮件签名中的社交链接。每条包含简短描述文字和完整 URL。最多 3 条。没有符合条件的链接时为空数组 -- 退订链接提取规则:推广邮件必须提取退订链接。退订链接通常在邮件最底部,包含以下关键词之一:unsubscribe、退订、取消订阅、opt out、manage preferences、email preferences。即使链接被包裹在 HTML 标签中,也要提取 href 属性里的完整 URL。如果邮件底部同时有多个退订相关链接,只提取最主要的那个 +- links: 提取用户需要点击的链接。其中退订/取消订阅链接按下方「退订链接提取规则」强制执行,其余仅提取邮件明确要求用户点击才能完成某操作的链接(如确认邮箱、重置密码、查看账单、审批请求、查看通知详情等)。不要提取推广性质的链接(如下载App、注册领优惠、查看更多商品等营销链接)、追踪像素、邮件签名中的社交链接。每条包含简短描述文字和完整 URL。最多 3 条。没有符合条件的链接时为空数组 +- 退订链接提取规则(最高优先级,仅适用于 category 为 "promotion" 的邮件): + * 推广邮件必须在 links 中输出退订链接,且放在 links 数组的第一位;只有邮件正文确实不存在任何退订入口时,links 才可以为空 + * 退订链接通常位于邮件最底部/页脚区域,表现形式多样:文字为 unsubscribe、Unsubscribe、退订、取消订阅、点击这里退订、manage preferences、email preferences、update preferences、subscription settings、opt out 等;也可能是图片链接,或包裹在 HTML 标签中的 ,必须提取 href 属性里的完整 URL + * 同一封邮件出现多个退订相关链接时,只提取最主要的那个(优先取正文最后出现的完整 unsubscribe 链接) + * 退订链接固定占用 1 个位置,其余链接最多 2 条,不得挤掉退订链接 + * 严禁编造退订 URL,确实找不到时 links 输出空数组 - plain_text: 将邮件全文转换为可读的纯文本版本。去除所有HTML标签、CSS样式、脚本代码,保留完整的文字内容和原始段落结构。不删减、不修改、不总结任何内容,完整保留原文所有信息 - summary 要简洁,把需要知道的信息浓缩成一句话,不需要分条列出 - 当 category 为 "promotion" 时,summary 直接写成一句话:「[发送方名称] 在推广 [推广的产品/服务/活动]」,例如「腾讯云在推广双十一云服务器折扣」。此时 can_reply 为 false diff --git a/src/tg_bot.py b/src/tg_bot.py index ca6b582..2c8e21d 100644 --- a/src/tg_bot.py +++ b/src/tg_bot.py @@ -191,12 +191,50 @@ CALLBACK_CANCEL = "c" CALLBACK_PROMO_DETAIL = "pd" -def _summary_keyboard(can_reply: bool = True, is_promotion: bool = False, links: list = None) -> dict: +# ── Link buttons ──────────────────────────────────────── + +_UNSUB_KEYWORDS = ( + "unsubscribe", "opt out", "unsub", "退订", "取消订阅", + "manage preferences", "email preferences", "subscription settings", + "subscription preferences", "preferences", "email-pref", "manage-pref", + "偏好设置", "管理偏好", "订阅设置", +) + + +def _is_unsub_link(link: dict) -> bool: + """判断链接是否为退订/取消订阅类链接(按文字或 URL 关键词)""" + text = (link.get("text") or "").lower() + url = (link.get("url") or "").lower() + return any(k in text or k in url for k in _UNSUB_KEYWORDS) + + +def _link_buttons(links: list | None) -> list: + """把 AI 提取的链接转成键盘按钮行;退订链接排最前并用 🔕 标识,其余用 🔗""" + if not links: + return [] + unsub_links = [l for l in links if _is_unsub_link(l)] + other_links = [l for l in links if not _is_unsub_link(l)] + rows = [] + for link in unsub_links + other_links: + url = link.get("url", "") + # 校验 URL:合法 http/https,无多重协议头,长度合理 + if not (url.startswith("http") and url.count("://") == 1 and len(url) < 200): + continue + text = (link.get("text") or "").strip()[:30] + if not text: + text = "退订" if _is_unsub_link(link) else "打开链接" + icon = "🔕" if _is_unsub_link(link) else "🔗" + rows.append([{"text": f"{icon} {text}", "url": url}]) + return rows + + +def _summary_keyboard(can_reply: bool = True, is_promotion: bool = False, links: list | None = None) -> dict: if is_promotion: - return {"inline_keyboard": [ - [{"text": "告诉我更多", "callback_data": CALLBACK_PROMO_DETAIL}], - [{"text": "查看原文", "callback_data": CALLBACK_VIEW_ORIG}], - ]} + kb = [] + kb.extend(_link_buttons(links)) + kb.append([{"text": "告诉我更多", "callback_data": CALLBACK_PROMO_DETAIL}]) + kb.append([{"text": "查看原文", "callback_data": CALLBACK_VIEW_ORIG}]) + return {"inline_keyboard": kb} kb = [ [ {"text": "查看原文", "callback_data": CALLBACK_VIEW_ORIG}, @@ -208,12 +246,7 @@ def _summary_keyboard(can_reply: bool = True, is_promotion: bool = False, links: {"text": "AI完整回复", "callback_data": CALLBACK_AI_REPLY_FULL}, {"text": "AI简洁回复", "callback_data": CALLBACK_AI_REPLY_SHORT}, ]) - for link in (links or []): - url = link.get("url", "") - text = link.get("text", "打开链接")[:30] - # 校验 URL:合法 http/https,无多重协议头,长度合理 - if url.startswith("http") and url.count("://") == 1 and len(url) < 200: - kb.append([{"text": f"🔗 {text}", "url": url}]) + kb.extend(_link_buttons(links)) return {"inline_keyboard": kb} @@ -551,13 +584,14 @@ def _handle_callback(bot_token: str, cfg: Config, cb: dict): f"\n{_escape(summary)}\n" f"\n*📋 详情:*\n{detail_lines}" ) + promo_kb = [] + promo_kb.extend(_link_buttons(ctx.get("summary_data", {}).get("links", []))) + promo_kb.append([{"text": "收起", "callback_data": CALLBACK_VIEW_SUMM}]) + promo_kb.append([{"text": "查看原文", "callback_data": CALLBACK_VIEW_ORIG}]) _tg_req(bot_token, "editMessageText", { "chat_id": chat_id, "message_id": msg_id, "text": expanded, "parse_mode": "MarkdownV2", - "reply_markup": {"inline_keyboard": [ - [{"text": "收起", "callback_data": CALLBACK_VIEW_SUMM}], - [{"text": "查看原文", "callback_data": CALLBACK_VIEW_ORIG}], - ]}, + "reply_markup": {"inline_keyboard": promo_kb}, }) logger.info(" 显示推广详情") diff --git a/tests/test_unsub_keyboard.py b/tests/test_unsub_keyboard.py new file mode 100644 index 0000000..8fe0692 --- /dev/null +++ b/tests/test_unsub_keyboard.py @@ -0,0 +1,86 @@ +"""测试摘要键盘渲染:推广邮件的退订链接按钮必须显示且排最前。""" + +from src.tg_bot import _is_unsub_link, _link_buttons, _summary_keyboard + + +def _rows_of(kb: dict) -> list[list[dict]]: + return kb["inline_keyboard"] + + +def test_promotion_with_unsub_link_shows_button_first(): + """推广邮件:AI 提取到退订链接时必须显示,且排在第一行""" + links = [ + {"text": "Manage Preferences", "url": "https://e.example.com/pref"}, + {"text": "推广活动详情", "url": "https://e.example.com/deal"}, + ] + kb = _summary_keyboard(can_reply=False, is_promotion=True, links=links) + rows = _rows_of(kb) + first = rows[0][0] + assert first["url"] == "https://e.example.com/pref" + assert "🔕" in first["text"] + # 其余按钮仍然存在 + texts = [b["text"] for r in rows for b in r] + assert "告诉我更多" in texts + assert "查看原文" in texts + + +def test_promotion_without_links_keeps_default_buttons(): + """推广邮件:没有链接时保持原有按钮""" + kb = _summary_keyboard(can_reply=False, is_promotion=True, links=None) + rows = _rows_of(kb) + assert len(rows) == 2 + assert rows[0][0]["text"] == "告诉我更多" + assert rows[1][0]["text"] == "查看原文" + + +def test_promotion_with_only_non_unsub_link(): + """推广邮件:只有非退订链接时也正常渲染(🔗),不冒充退订按钮""" + links = [{"text": "查看活动", "url": "https://e.example.com/event"}] + kb = _summary_keyboard(can_reply=False, is_promotion=True, links=links) + rows = _rows_of(kb) + assert rows[0][0]["text"] == "🔗 查看活动" + assert rows[1][0]["text"] == "告诉我更多" + assert rows[2][0]["text"] == "查看原文" + + +def test_normal_email_links_still_rendered(): + """普通邮件:链接按钮行为不变""" + links = [{"text": "查看账单", "url": "https://e.example.com/bill"}] + kb = _summary_keyboard(can_reply=True, is_promotion=False, links=links) + rows = _rows_of(kb) + texts = [b["text"] for r in rows for b in r] + assert "🔗 查看账单" in texts + + +def test_unsub_detection_variants(): + """退订链接识别:文字与 URL 关键词变体""" + assert _is_unsub_link({"text": "Unsubscribe", "url": "https://x.com"}) + assert _is_unsub_link({"text": "退订", "url": "https://x.com"}) + assert _is_unsub_link({"text": "点击这里取消订阅", "url": "https://x.com"}) + assert _is_unsub_link({"text": "管理偏好设置", "url": "https://x.com"}) + assert _is_unsub_link({"text": "更多", "url": "https://x.com/unsubscribe"}) + assert _is_unsub_link({"text": "Preferences", "url": "https://x.com/email-pref"}) + assert not _is_unsub_link({"text": "查看详情", "url": "https://x.com/deal"}) + + +def test_unsub_link_sorted_before_other_links(): + """退订链接即使不在 links 第一位也会被排到最前""" + links = [ + {"text": "查看详情", "url": "https://x.com/a"}, + {"text": "unsubscribe", "url": "https://x.com/b"}, + ] + rows = _link_buttons(links) + assert rows[0][0]["url"] == "https://x.com/b" + assert rows[1][0]["url"] == "https://x.com/a" + + +def test_invalid_url_filtered(): + """非法 URL(非 http、多重协议头、超长)被过滤""" + links = [ + {"text": "退订", "url": "javascript:alert(1)"}, + {"text": "退订", "url": "https://x.com/https://evil.com"}, + {"text": "退订", "url": "https://ok.example.com/unsub"}, + ] + rows = _link_buttons(links) + assert len(rows) == 1 + assert rows[0][0]["url"] == "https://ok.example.com/unsub" \ No newline at end of file