@bangdao-ai/zentao-mcp 2.0.0 → 2.0.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bangdao-ai/zentao-mcp",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "禅道项目管理AI中枢 v2.0 — 35+MCP工具覆盖需求·任务·Bug·用例·发布·我的地盘全生命周期,原生API模式,Bug智能分类引擎+CSV批量用例导入+截图上传,一句话驱动禅道",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -36,7 +36,7 @@ def _safe_path(path: str, must_exist: bool = False) -> str:
36
36
  """
37
37
  _init_allowed_dirs()
38
38
  real = os.path.realpath(os.path.expanduser(path))
39
- if not any(real.startswith(d) for d in _ALLOWED_WORK_DIRS):
39
+ if not any(real == d or real.startswith(d + os.sep) for d in _ALLOWED_WORK_DIRS):
40
40
  _security_logger.warning("路径安全拒绝: %s (resolved: %s)", path, real)
41
41
  raise ValueError(f"路径不在允许的工作目录内: {path}")
42
42
  if must_exist and not os.path.exists(real):
@@ -1071,14 +1071,21 @@ class ZenTaoNexus:
1071
1071
  if not gateway_payload.get("mold"):
1072
1072
  gateway_payload["mold"] = "20"
1073
1073
  result = self.client.task_create_by_gateway(gateway_payload)
1074
- if result.get("status") == 1 and result.get("taskID"):
1074
+ if result.get("status") == 1:
1075
+ tid = result.get("taskID")
1076
+ if not tid and task_name:
1077
+ tid = self._find_recent_task_id_by_name(task_name)
1078
+ if tid:
1079
+ result["taskID"] = tid
1075
1080
  result["projectID"] = str(project_id)
1076
1081
  result["create_channel"] = "gateway"
1082
+ if not tid:
1083
+ result["warning"] = "任务已通过网关创建成功,但无法获取 taskID"
1077
1084
  return result
1078
1085
 
1079
1086
  gateway_error = result
1080
1087
 
1081
- # 2) 网关失败时回退到原生 task.create(仍要求必须拿到 taskID)
1088
+ # 2) 网关失败时回退到原生 task.create
1082
1089
  native_result = self.client.task_create(project_id, task_data)
1083
1090
  if native_result.get("status") == 1:
1084
1091
  data = native_result.get("data", {})
@@ -1087,25 +1094,18 @@ class ZenTaoNexus:
1087
1094
  tid = self._find_recent_task_id_by_name(task_name)
1088
1095
  if tid:
1089
1096
  native_result["taskID"] = tid
1090
- native_result["projectID"] = str(project_id)
1091
- native_result["create_channel"] = "native"
1092
- return native_result
1093
- result = {
1094
- "status": 0,
1095
- "message": "fail",
1096
- "info": "任务创建返回 success 但无 taskID,判定为未实际创建",
1097
- "data": data,
1098
- "projectID": str(project_id),
1099
- "gateway_error": gateway_error,
1100
- }
1101
- else:
1102
- result = dict(native_result) if isinstance(native_result, dict) else {
1103
- "status": 0, "message": "fail", "info": str(native_result), "data": {}
1104
- }
1105
- result["gateway_error"] = gateway_error
1097
+ native_result["projectID"] = str(project_id)
1098
+ native_result["create_channel"] = "native"
1099
+ if not tid:
1100
+ native_result["warning"] = "任务已通过原生接口创建成功,但无法获取 taskID"
1101
+ return native_result
1102
+
1103
+ result = dict(native_result) if isinstance(native_result, dict) else {
1104
+ "status": 0, "message": "fail", "info": str(native_result), "data": {}
1105
+ }
1106
+ result["gateway_error"] = gateway_error
1106
1107
 
1107
1108
  if str(project_id) == "0":
1108
- # 便于排查:当无法自动选出项目时返回候选项
1109
1109
  candidates = self.get_product_projects(product_id)
1110
1110
  if candidates.get("status") == 1:
1111
1111
  result["project_candidates"] = candidates.get("data", {}).get("projects", [])
@@ -1333,20 +1333,29 @@ class ZenTaoNexus:
1333
1333
  case_id = self._find_recent_case_id_by_name(case_title)
1334
1334
  if case_id:
1335
1335
  result["caseID"] = case_id
1336
- return result
1336
+ result["create_channel"] = "native"
1337
+ if not case_id:
1338
+ result["warning"] = "用例已通过原生接口创建成功,但无法获取 caseID"
1339
+ return result
1337
1340
 
1338
- # 原生成功但无法拿到 caseID 时,回退网关创建,确保后续流程可追踪 caseID。
1341
+ # 原生创建失败时,回退到网关创建
1339
1342
  gateway_payload = dict(case_data)
1340
1343
  gateway_result = self.client.testcase_create_by_gateway(gateway_payload)
1341
- if gateway_result.get("status") == 1 and gateway_result.get("caseID"):
1344
+ if gateway_result.get("status") == 1:
1342
1345
  gateway_result["create_channel"] = "gateway"
1346
+ if not gateway_result.get("caseID") and case_title:
1347
+ cid = self._find_recent_case_id_by_name(case_title)
1348
+ if cid:
1349
+ gateway_result["caseID"] = cid
1350
+ if not gateway_result.get("caseID"):
1351
+ gateway_result["warning"] = "用例已通过网关创建成功,但无法获取 caseID"
1343
1352
  return gateway_result
1344
1353
 
1345
- return result if result.get("status") != 1 else {
1354
+ return {
1346
1355
  "status": 0,
1347
1356
  "message": "fail",
1348
- "info": "用例创建返回 success 但无 caseID,且网关回退失败",
1349
- "data": result.get("data", {}),
1357
+ "info": "原生和网关均创建失败",
1358
+ "native_error": result,
1350
1359
  "gateway_error": gateway_result,
1351
1360
  }
1352
1361
 
@@ -1518,9 +1527,20 @@ class ZenTaoNexus:
1518
1527
  }
1519
1528
 
1520
1529
  def export_bug_detail(self, bug_id, output_dir=None) -> Dict:
1521
- """导出 Bug 详情到本地文件夹:{bug_id}_bug/{bug_id}_bug.md + 附件 + 图片 + 备注"""
1530
+ """
1531
+ 导出 Bug 完整详情到本地文件夹,结构:
1532
+ bug_{bug_id}/
1533
+ ├── bug_{bug_id}.md # 完整 Markdown 文档
1534
+ ├── images/ # 描述 + 备注中的图片
1535
+ │ ├── step_1.png
1536
+ │ └── comment_1.png
1537
+ └── attachments/ # Bug 附件
1538
+ ├── 需求文档.pdf
1539
+ └── 截图.png
1540
+ """
1522
1541
  if output_dir:
1523
1542
  output_dir = _safe_path(output_dir)
1543
+
1524
1544
  detail = self.client.bug_view_full(bug_id)
1525
1545
  if detail.get("status") != 1:
1526
1546
  return detail
@@ -1530,7 +1550,6 @@ class ZenTaoNexus:
1530
1550
  users = detail.get("users", {})
1531
1551
 
1532
1552
  def _name(field, mapping=None):
1533
- """取友好名称:优先 xxxName 字段,否则用映射表,最后用原始值"""
1534
1553
  name_val = bug.get(f"{field}Name")
1535
1554
  raw = str(bug.get(field, '') or '')
1536
1555
  if name_val and name_val != 'None':
@@ -1538,55 +1557,133 @@ class ZenTaoNexus:
1538
1557
  if mapping and raw in mapping:
1539
1558
  return mapping[raw]
1540
1559
  return raw
1541
- folder_name = f"{bug_id}_bug"
1560
+
1561
+ # ── 创建目录结构 ──
1562
+ folder_name = f"bug_{bug_id}"
1542
1563
  base_dir = os.path.join(output_dir, folder_name) if output_dir else folder_name
1543
- os.makedirs(base_dir, exist_ok=True)
1564
+ images_dir = os.path.join(base_dir, "images")
1565
+ attach_dir = os.path.join(base_dir, "attachments")
1566
+ os.makedirs(images_dir, exist_ok=True)
1567
+ os.makedirs(attach_dir, exist_ok=True)
1544
1568
 
1545
1569
  downloaded_files = []
1546
1570
  downloaded_images = []
1547
1571
 
1548
- # 下载附件(files 可能是 dict{id: info} 或 list[info])
1549
- raw_files = bug.get("files", {})
1550
- file_list = list(raw_files.values()) if isinstance(raw_files, dict) else raw_files
1551
- for file_info in file_list:
1552
- if isinstance(file_info, str):
1572
+ img_pattern = re.compile(
1573
+ r'<img\s+[^>]*src=["\']([^"\']+)["\'][^>]*/?>',
1574
+ re.IGNORECASE,
1575
+ )
1576
+
1577
+ def _guess_ext(url: str, fallback: str = ".png") -> str:
1578
+ for pat in (r't=(png|jpe?g|gif|bmp|webp)',
1579
+ r'\.(png|jpe?g|gif|bmp|webp)(?:[?#]|$)'):
1580
+ m = re.search(pat, url, re.IGNORECASE)
1581
+ if m:
1582
+ e = m.group(1).lower()
1583
+ return f".{'jpg' if e == 'jpeg' else e}"
1584
+ return fallback
1585
+
1586
+ def _download_image(url: str, name: str, category: str) -> bool:
1587
+ save_path = os.path.join(images_dir, name)
1588
+ r = self.client.file_download(url, save_path)
1589
+ ok = r.get("status") == 1
1590
+ downloaded_images.append({
1591
+ "name": name, "type": category,
1592
+ "success": ok, "size": r.get("size", 0),
1593
+ })
1594
+ return ok
1595
+
1596
+ # ── 1. 下载附件 ──
1597
+ raw_files = bug.get("files") or {}
1598
+ file_list = (list(raw_files.values()) if isinstance(raw_files, dict)
1599
+ else raw_files if isinstance(raw_files, list) else [])
1600
+ for fi in file_list:
1601
+ if not isinstance(fi, dict):
1553
1602
  continue
1554
- title = file_info.get("title", "unknown")
1555
- secret_id = file_info.get("secretID", "")
1556
- url = file_info.get("downloadUrl", "")
1557
- if not url and secret_id:
1558
- url = (f"{self.client.base_url}/zentaopms/www/index.php"
1559
- f"?m=file&f=download&t=json&fileID={secret_id}")
1603
+ title = fi.get("title", "unknown")
1604
+ url = fi.get("downloadUrl", "")
1605
+ if not url:
1606
+ ident = fi.get("secretID") or fi.get("id") or ""
1607
+ if ident:
1608
+ url = (f"{self.client.base_url}/zentaopms/www/index.php"
1609
+ f"?m=file&f=download&t=json&fileID={ident}")
1560
1610
  if not url:
1611
+ downloaded_files.append({
1612
+ "name": title, "saved": "", "success": False,
1613
+ "size": 0, "reason": "no_download_url",
1614
+ })
1561
1615
  continue
1562
1616
  safe_name = re.sub(r'[<>:"/\\|?*]', '_', title)
1563
- save_path = os.path.join(base_dir, safe_name)
1617
+ save_path = os.path.join(attach_dir, safe_name)
1564
1618
  r = self.client.file_download(url, save_path)
1565
1619
  downloaded_files.append({
1566
- "name": title, "saved": safe_name,
1620
+ "name": title, "saved": f"attachments/{safe_name}",
1567
1621
  "success": r.get("status") == 1,
1568
1622
  "size": r.get("size", 0),
1569
1623
  })
1570
1624
 
1571
- # steps HTML 提取并下载图片,替换为本地路径
1572
- steps_html = bug.get("steps", "").replace("&amp;", "&")
1573
- img_pattern = re.compile(r'<img\s+[^>]*src="([^"]+)"[^>]*/?>', re.IGNORECASE)
1574
- img_index = 0
1625
+ # ── 2. 下载描述(steps)中的图片 ──
1626
+ steps_html = (bug.get("steps") or "").replace("&amp;", "&")
1627
+ step_img_idx = 0
1575
1628
  for match in img_pattern.finditer(steps_html):
1576
1629
  img_url = match.group(1)
1577
- ext = ".png"
1578
- ext_match = re.search(r't=(png|jpg|jpeg|gif|bmp|webp)', img_url, re.IGNORECASE)
1579
- if ext_match:
1580
- ext = f".{ext_match.group(1).lower()}"
1581
- img_index += 1
1582
- img_name = f"step_img_{img_index}{ext}"
1583
- save_path = os.path.join(base_dir, img_name)
1584
- r = self.client.file_download(img_url, save_path)
1585
- if r.get("status") == 1:
1586
- steps_html = steps_html.replace(img_url, f"./{img_name}")
1587
- downloaded_images.append(img_name)
1588
-
1589
- # 生成 Markdown
1630
+ if img_url.startswith("data:"):
1631
+ continue
1632
+ step_img_idx += 1
1633
+ ext = _guess_ext(img_url)
1634
+ img_name = f"step_{step_img_idx}{ext}"
1635
+ if _download_image(img_url, img_name, "step"):
1636
+ steps_html = steps_html.replace(img_url, f"./images/{img_name}")
1637
+
1638
+ # ── 3. 处理备注 & 下载备注中的图片 ──
1639
+ _ACTION_MAP = {
1640
+ "opened": "创建", "edited": "编辑", "commented": "备注",
1641
+ "resolved": "解决", "closed": "关闭", "activated": "激活",
1642
+ "confirmed": "确认", "assigned": "指派", "bugconfirmed": "确认",
1643
+ }
1644
+ action_list = (list(actions.values()) if isinstance(actions, dict)
1645
+ else actions if isinstance(actions, list) else [])
1646
+
1647
+ comment_img_idx = 0
1648
+ comments_data = []
1649
+
1650
+ for act in action_list:
1651
+ if not isinstance(act, dict):
1652
+ continue
1653
+ action_type = act.get("action", "")
1654
+ actor_id = act.get("actor", "")
1655
+ actor_name = users.get(actor_id, actor_id) if users else actor_id
1656
+ date = act.get("date", "")
1657
+ comment_html = (act.get("comment") or "").strip()
1658
+ processed_comment = ""
1659
+
1660
+ if comment_html:
1661
+ comment_html = comment_html.replace("&amp;", "&").replace("&nbsp;", " ")
1662
+ for img_match in img_pattern.finditer(comment_html):
1663
+ c_url = img_match.group(1)
1664
+ if c_url.startswith("data:"):
1665
+ continue
1666
+ if not c_url.startswith("http"):
1667
+ c_url = f"{self.client.base_url}{c_url}"
1668
+ comment_img_idx += 1
1669
+ ext = _guess_ext(c_url)
1670
+ c_img_name = f"comment_{comment_img_idx}{ext}"
1671
+ if _download_image(c_url, c_img_name, "comment"):
1672
+ comment_html = comment_html.replace(
1673
+ img_match.group(0),
1674
+ f'<img src="./images/{c_img_name}" alt="" />')
1675
+ processed_comment = self._html_to_markdown(comment_html)
1676
+
1677
+ comments_data.append({
1678
+ "date": date,
1679
+ "actor": actor_name,
1680
+ "action": action_type,
1681
+ "action_label": _ACTION_MAP.get(action_type, action_type),
1682
+ "comment": processed_comment,
1683
+ "history": act.get("history") or act.get("histories") or [],
1684
+ })
1685
+
1686
+ # ── 4. 生成完整 Markdown ──
1590
1687
  md_lines = [
1591
1688
  f"# Bug #{bug_id}: {bug.get('title', '')}",
1592
1689
  "",
@@ -1596,6 +1693,7 @@ class ZenTaoNexus:
1596
1693
  "|------|------|",
1597
1694
  f"| ID | {bug_id} |",
1598
1695
  f"| 产品 | {_name('product')} |",
1696
+ f"| 模块 | {_name('module')} |",
1599
1697
  f"| 状态 | {_name('status', self._STATUS_MAP)} |",
1600
1698
  f"| 严重程度 | {_name('severity', self._SEVERITY_MAP)} |",
1601
1699
  f"| 优先级 | P{bug.get('pri', '')} |",
@@ -1603,130 +1701,125 @@ class ZenTaoNexus:
1603
1701
  f"| 指派给 | {_name('assignedTo')} |",
1604
1702
  f"| 创建人 | {_name('openedBy')} |",
1605
1703
  f"| 创建时间 | {bug.get('openedDate', '')} |",
1606
- f"| 截止日期 | {bug.get('deadline', '')} |",
1607
1704
  f"| 解决人 | {_name('resolvedBy') or '-'} |",
1705
+ f"| 解决时间 | {bug.get('resolvedDate', '') or '-'} |",
1608
1706
  f"| 解决方案 | {_name('resolution', self._RESOLUTION_MAP) or '-'} |",
1609
- f"| 关键词 | {bug.get('keywords', '')} |",
1707
+ f"| 关闭人 | {_name('closedBy') or '-'} |",
1708
+ f"| 关闭时间 | {bug.get('closedDate', '') or '-'} |",
1709
+ f"| 截止日期 | {bug.get('deadline', '') or '-'} |",
1710
+ f"| 关键词 | {bug.get('keywords', '') or '-'} |",
1711
+ f"| 关联需求 | {bug.get('story', '') or '-'} |",
1712
+ f"| 关联任务 | {bug.get('task', '') or '-'} |",
1610
1713
  "",
1611
1714
  "## 复现步骤",
1612
1715
  "",
1716
+ self._html_to_markdown(steps_html),
1717
+ "",
1613
1718
  ]
1614
1719
 
1615
- steps_md = self._html_to_markdown(steps_html)
1616
- md_lines.append(steps_md)
1617
- md_lines.append("")
1618
-
1720
+ # ── 附件列表 ──
1619
1721
  if downloaded_files:
1620
1722
  md_lines.append("## 附件")
1621
1723
  md_lines.append("")
1622
1724
  for f in downloaded_files:
1725
+ path_ref = f"./{f['saved']}" if f["saved"] else ""
1623
1726
  if f["success"]:
1624
1727
  is_img = any(f["saved"].lower().endswith(e)
1625
- for e in ('.png','.jpg','.jpeg','.gif','.bmp','.webp'))
1728
+ for e in ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'))
1626
1729
  if is_img:
1627
- md_lines.append(f"![{f['name']}](./{f['saved']})")
1730
+ md_lines.append(f"![{f['name']}]({path_ref})")
1628
1731
  else:
1629
- md_lines.append(f"- [{f['name']}](./{f['saved']})")
1732
+ size_kb = round(f["size"] / 1024, 1) if f["size"] else 0
1733
+ md_lines.append(f"- [{f['name']}]({path_ref}) ({size_kb} KB)")
1630
1734
  else:
1631
- md_lines.append(f"- {f['name']}(下载失败)")
1735
+ md_lines.append(f"- {f['name']}(下载失败: {f.get('reason', 'unknown')})")
1632
1736
  md_lines.append("")
1633
1737
 
1634
- # 备注/操作历史
1635
- _ACTION_MAP = {
1636
- "opened": "创建", "edited": "编辑", "commented": "备注",
1637
- "resolved": "解决", "closed": "关闭", "activated": "激活",
1638
- "confirmed": "确认", "assigned": "指派", "bugconfirmed": "确认",
1639
- }
1640
- action_list = list(actions.values()) if isinstance(actions, dict) else actions
1641
- has_comments = any(
1642
- a.get("comment", "").strip() for a in action_list if isinstance(a, dict)
1643
- )
1644
- if action_list:
1645
- md_lines.append("## 操作历史")
1738
+ # ── 操作历史 & 备注 ──
1739
+ if comments_data:
1740
+ md_lines.append("## 操作历史 & 备注")
1646
1741
  md_lines.append("")
1647
- comment_img_idx = 0
1648
- for act in action_list:
1649
- if not isinstance(act, dict):
1650
- continue
1651
- action_type = act.get("action", "")
1652
- action_label = _ACTION_MAP.get(action_type, action_type)
1653
- actor_id = act.get("actor", "")
1654
- actor_name = users.get(actor_id, actor_id) if users else actor_id
1655
- date = act.get("date", "")
1656
- comment_html = (act.get("comment") or "").strip()
1657
-
1658
- md_lines.append(f"### {date} — {actor_name} [{action_label}]")
1742
+ for c in comments_data:
1743
+ md_lines.append(f"### {c['date']} {c['actor']} [{c['action_label']}]")
1659
1744
  md_lines.append("")
1660
-
1661
- if comment_html:
1662
- comment_html = comment_html.replace("&amp;", "&").replace("&nbsp;", " ")
1663
- for img_match in img_pattern.finditer(comment_html):
1664
- img_url = img_match.group(1)
1665
- if not img_url.startswith('http'):
1666
- img_url = f"{self.client.base_url}{img_url}"
1667
- ext = ".png"
1668
- ext_m = re.search(r't=(png|jpg|jpeg|gif|bmp|webp)', img_url, re.IGNORECASE)
1669
- if ext_m:
1670
- ext = f".{ext_m.group(1).lower()}"
1671
- comment_img_idx += 1
1672
- c_img_name = f"comment_img_{comment_img_idx}{ext}"
1673
- c_save = os.path.join(base_dir, c_img_name)
1674
- r = self.client.file_download(img_url, c_save)
1675
- if r.get("status") == 1:
1676
- comment_html = comment_html.replace(
1677
- img_match.group(0),
1678
- f'<img src="./{c_img_name}" alt="" />')
1679
- downloaded_images.append(c_img_name)
1680
-
1681
- comment_md = self._html_to_markdown(comment_html)
1682
- md_lines.append(comment_md)
1745
+ if c["comment"]:
1746
+ md_lines.append(c["comment"])
1683
1747
  md_lines.append("")
1684
-
1685
- history = act.get("history", [])
1686
- if history:
1748
+ history = c["history"]
1749
+ if isinstance(history, list):
1687
1750
  for h in history:
1751
+ if not isinstance(h, dict):
1752
+ continue
1688
1753
  field = h.get("field", "")
1689
- old_val = h.get("old", "")
1690
- new_val = h.get("new", "")
1754
+ old_val = str(h.get("old", ""))
1755
+ new_val = str(h.get("new", ""))
1691
1756
  if old_val or new_val:
1692
- old_short = (old_val[:80] + '...') if len(old_val) > 80 else old_val
1693
- new_short = (new_val[:80] + '...') if len(new_val) > 80 else new_val
1694
- md_lines.append(f"- **{field}**: `{old_short}` → `{new_short}`")
1695
- md_lines.append("")
1757
+ old_s = (old_val[:100] + '') if len(old_val) > 100 else old_val
1758
+ new_s = (new_val[:100] + '') if len(new_val) > 100 else new_val
1759
+ md_lines.append(f"- **{field}**: `{old_s}` → `{new_s}`")
1760
+ if history:
1761
+ md_lines.append("")
1696
1762
 
1697
1763
  md_content = "\n".join(md_lines)
1698
- md_path = os.path.join(base_dir, f"{bug_id}_bug.md")
1764
+ md_path = os.path.join(base_dir, f"bug_{bug_id}.md")
1699
1765
  with open(md_path, 'w', encoding='utf-8') as fw:
1700
1766
  fw.write(md_content)
1701
1767
 
1768
+ abs_folder = os.path.abspath(base_dir)
1769
+ abs_md = os.path.abspath(md_path)
1770
+
1702
1771
  return {
1703
1772
  "status": 1,
1704
1773
  "message": "success",
1705
- "bug": bug,
1774
+ "data": {
1775
+ "bug": bug,
1776
+ "comments": comments_data,
1777
+ "users": users,
1778
+ },
1706
1779
  "export": {
1707
- "folder": os.path.abspath(base_dir),
1708
- "markdown": os.path.abspath(md_path),
1780
+ "folder": abs_folder,
1781
+ "markdown": abs_md,
1709
1782
  "attachments": downloaded_files,
1710
- "step_images": downloaded_images,
1711
- }
1783
+ "images": downloaded_images,
1784
+ "summary": {
1785
+ "total_attachments": len(downloaded_files),
1786
+ "downloaded_attachments": sum(1 for f in downloaded_files if f["success"]),
1787
+ "total_images": len(downloaded_images),
1788
+ "downloaded_images": sum(1 for i in downloaded_images if i["success"]),
1789
+ "total_comments": sum(1 for c in comments_data if c["comment"]),
1790
+ },
1791
+ },
1712
1792
  }
1713
1793
 
1714
1794
  @staticmethod
1715
1795
  def _html_to_markdown(html: str) -> str:
1716
- """简易 HTML→Markdown:处理 p、strong、img、br 标签"""
1796
+ """HTML Markdown 转换"""
1717
1797
  if not html:
1718
1798
  return ""
1719
1799
  text = html
1720
- text = re.sub(r'<img\s+[^>]*src="([^"]+)"[^>]*alt="([^"]*)"[^>]*/?>',
1800
+ text = re.sub(r'<img\s+[^>]*src=["\']([^"\']+)["\'][^>]*alt=["\']([^"\']*)["\'][^>]*/?>',
1721
1801
  r'![\2](\1)', text)
1722
- text = re.sub(r'<img\s+[^>]*src="([^"]+)"[^>]*/?>',
1802
+ text = re.sub(r'<img\s+[^>]*src=["\']([^"\']+)["\'][^>]*/?>',
1723
1803
  r'![](\1)', text)
1804
+ for i in range(6, 0, -1):
1805
+ text = re.sub(rf'<h{i}[^>]*>(.*?)</h{i}>', r'\n' + '#' * i + r' \1\n', text, flags=re.DOTALL)
1806
+ text = re.sub(r'<a\s+[^>]*href=["\']([^"\']+)["\'][^>]*>(.*?)</a>', r'[\2](\1)', text, flags=re.DOTALL)
1724
1807
  text = re.sub(r'<strong>(.*?)</strong>', r'**\1**', text)
1725
1808
  text = re.sub(r'<b>(.*?)</b>', r'**\1**', text)
1726
1809
  text = re.sub(r'<em>(.*?)</em>', r'*\1*', text)
1810
+ text = re.sub(r'<code>(.*?)</code>', r'`\1`', text)
1811
+ text = re.sub(r'<pre[^>]*>(.*?)</pre>', r'\n```\n\1\n```\n', text, flags=re.DOTALL)
1812
+ text = re.sub(r'<li[^>]*>(.*?)</li>', r'- \1\n', text, flags=re.DOTALL)
1813
+ text = re.sub(r'</?[ou]l[^>]*>', '\n', text)
1727
1814
  text = re.sub(r'<br\s*/?>', '\n', text)
1728
- text = re.sub(r'<p>(.*?)</p>', r'\1\n', text, flags=re.DOTALL)
1815
+ text = re.sub(r'<p[^>]*>(.*?)</p>', r'\1\n', text, flags=re.DOTALL)
1816
+ text = re.sub(r'<hr\s*/?>', '\n---\n', text)
1817
+ text = re.sub(r'</?(?:div|span|table|thead|tbody|tr|td|th|blockquote)[^>]*>', '\n', text)
1729
1818
  text = re.sub(r'<[^>]+>', '', text)
1819
+ text = re.sub(r'&lt;', '<', text)
1820
+ text = re.sub(r'&gt;', '>', text)
1821
+ text = re.sub(r'&quot;', '"', text)
1822
+ text = re.sub(r'&nbsp;', ' ', text)
1730
1823
  text = re.sub(r'\n{3,}', '\n\n', text)
1731
1824
  return text.strip()
1732
1825
 
package/src/mcp/server.py CHANGED
@@ -93,12 +93,12 @@ class ZenTaoMCPServer:
93
93
  ),
94
94
  Tool(
95
95
  name="get_bug_detail",
96
- description="获取Bug详情并自动导出到本地文件夹(含Markdown文档、附件、步骤截图)",
96
+ description="获取Bug完整详情并导出到 bug_{id}/ 文件夹(含Markdown、描述图片、附件、备注图片,完整返回bug数据+备注+下载清单)",
97
97
  inputSchema={
98
98
  "type": "object",
99
99
  "properties": {
100
100
  "bug_id": {"type": "string", "description": "Bug ID"},
101
- "output_dir": {"type": "string", "description": "导出目录(可选,默认当前目录)"},
101
+ "output_dir": {"type": "string", "description": "导出根目录(可选,默认当前目录,实际文件存于 bug_{id}/ 子目录)"},
102
102
  },
103
103
  "required": ["bug_id"],
104
104
  }
package/src/mcp_server.py CHANGED
@@ -88,12 +88,12 @@ class ZenTaoMCPServer:
88
88
  ),
89
89
  Tool(
90
90
  name="get_bug_detail",
91
- description="获取Bug详情并自动导出到本地文件夹(含Markdown文档、附件、步骤截图)",
91
+ description="获取Bug完整详情并导出到 bug_{id}/ 文件夹(含Markdown、描述图片、附件、备注图片,完整返回bug数据+备注+下载清单)",
92
92
  inputSchema={
93
93
  "type": "object",
94
94
  "properties": {
95
95
  "bug_id": {"type": "string", "description": "Bug ID"},
96
- "output_dir": {"type": "string", "description": "导出目录(可选,默认当前目录)"},
96
+ "output_dir": {"type": "string", "description": "导出根目录(可选,默认当前目录,实际文件存于 bug_{id}/ 子目录)"},
97
97
  },
98
98
  "required": ["bug_id"],
99
99
  }
@@ -1346,7 +1346,17 @@ class ZenTaoNexus:
1346
1346
  }
1347
1347
 
1348
1348
  def export_bug_detail(self, bug_id, output_dir=None) -> Dict:
1349
- """导出 Bug 详情到本地文件夹:{bug_id}_bug/{bug_id}_bug.md + 附件 + 图片 + 备注"""
1349
+ """
1350
+ 导出 Bug 完整详情到本地文件夹,结构:
1351
+ bug_{bug_id}/
1352
+ ├── bug_{bug_id}.md # 完整 Markdown 文档
1353
+ ├── images/ # 描述 + 备注中的图片
1354
+ │ ├── step_1.png
1355
+ │ └── comment_1.png
1356
+ └── attachments/ # Bug 附件
1357
+ ├── 需求文档.pdf
1358
+ └── 截图.png
1359
+ """
1350
1360
  detail = self.client.bug_view_full(bug_id)
1351
1361
  if detail.get("status") != 1:
1352
1362
  return detail
@@ -1356,7 +1366,6 @@ class ZenTaoNexus:
1356
1366
  users = detail.get("users", {})
1357
1367
 
1358
1368
  def _name(field, mapping=None):
1359
- """取友好名称:优先 xxxName 字段,否则用映射表,最后用原始值"""
1360
1369
  name_val = bug.get(f"{field}Name")
1361
1370
  raw = str(bug.get(field, '') or '')
1362
1371
  if name_val and name_val != 'None':
@@ -1364,55 +1373,133 @@ class ZenTaoNexus:
1364
1373
  if mapping and raw in mapping:
1365
1374
  return mapping[raw]
1366
1375
  return raw
1367
- folder_name = f"{bug_id}_bug"
1376
+
1377
+ # ── 创建目录结构 ──
1378
+ folder_name = f"bug_{bug_id}"
1368
1379
  base_dir = os.path.join(output_dir, folder_name) if output_dir else folder_name
1369
- os.makedirs(base_dir, exist_ok=True)
1380
+ images_dir = os.path.join(base_dir, "images")
1381
+ attach_dir = os.path.join(base_dir, "attachments")
1382
+ os.makedirs(images_dir, exist_ok=True)
1383
+ os.makedirs(attach_dir, exist_ok=True)
1370
1384
 
1371
1385
  downloaded_files = []
1372
1386
  downloaded_images = []
1373
1387
 
1374
- # 下载附件(files 可能是 dict{id: info} 或 list[info])
1375
- raw_files = bug.get("files", {})
1376
- file_list = list(raw_files.values()) if isinstance(raw_files, dict) else raw_files
1377
- for file_info in file_list:
1378
- if isinstance(file_info, str):
1388
+ img_pattern = re.compile(
1389
+ r'<img\s+[^>]*src=["\']([^"\']+)["\'][^>]*/?>',
1390
+ re.IGNORECASE,
1391
+ )
1392
+
1393
+ def _guess_ext(url: str, fallback: str = ".png") -> str:
1394
+ for pat in (r't=(png|jpe?g|gif|bmp|webp)',
1395
+ r'\.(png|jpe?g|gif|bmp|webp)(?:[?#]|$)'):
1396
+ m = re.search(pat, url, re.IGNORECASE)
1397
+ if m:
1398
+ e = m.group(1).lower()
1399
+ return f".{'jpg' if e == 'jpeg' else e}"
1400
+ return fallback
1401
+
1402
+ def _download_image(url: str, name: str, category: str) -> bool:
1403
+ save_path = os.path.join(images_dir, name)
1404
+ r = self.client.file_download(url, save_path)
1405
+ ok = r.get("status") == 1
1406
+ downloaded_images.append({
1407
+ "name": name, "type": category,
1408
+ "success": ok, "size": r.get("size", 0),
1409
+ })
1410
+ return ok
1411
+
1412
+ # ── 1. 下载附件 ──
1413
+ raw_files = bug.get("files") or {}
1414
+ file_list = (list(raw_files.values()) if isinstance(raw_files, dict)
1415
+ else raw_files if isinstance(raw_files, list) else [])
1416
+ for fi in file_list:
1417
+ if not isinstance(fi, dict):
1379
1418
  continue
1380
- title = file_info.get("title", "unknown")
1381
- secret_id = file_info.get("secretID", "")
1382
- url = file_info.get("downloadUrl", "")
1383
- if not url and secret_id:
1384
- url = (f"{self.client.base_url}/zentaopms/www/index.php"
1385
- f"?m=file&f=download&t=json&fileID={secret_id}")
1419
+ title = fi.get("title", "unknown")
1420
+ url = fi.get("downloadUrl", "")
1386
1421
  if not url:
1422
+ ident = fi.get("secretID") or fi.get("id") or ""
1423
+ if ident:
1424
+ url = (f"{self.client.base_url}/zentaopms/www/index.php"
1425
+ f"?m=file&f=download&t=json&fileID={ident}")
1426
+ if not url:
1427
+ downloaded_files.append({
1428
+ "name": title, "saved": "", "success": False,
1429
+ "size": 0, "reason": "no_download_url",
1430
+ })
1387
1431
  continue
1388
1432
  safe_name = re.sub(r'[<>:"/\\|?*]', '_', title)
1389
- save_path = os.path.join(base_dir, safe_name)
1433
+ save_path = os.path.join(attach_dir, safe_name)
1390
1434
  r = self.client.file_download(url, save_path)
1391
1435
  downloaded_files.append({
1392
- "name": title, "saved": safe_name,
1436
+ "name": title, "saved": f"attachments/{safe_name}",
1393
1437
  "success": r.get("status") == 1,
1394
1438
  "size": r.get("size", 0),
1395
1439
  })
1396
1440
 
1397
- # steps HTML 提取并下载图片,替换为本地路径
1398
- steps_html = bug.get("steps", "").replace("&amp;", "&")
1399
- img_pattern = re.compile(r'<img\s+[^>]*src="([^"]+)"[^>]*/?>', re.IGNORECASE)
1400
- img_index = 0
1441
+ # ── 2. 下载描述(steps)中的图片 ──
1442
+ steps_html = (bug.get("steps") or "").replace("&amp;", "&")
1443
+ step_img_idx = 0
1401
1444
  for match in img_pattern.finditer(steps_html):
1402
1445
  img_url = match.group(1)
1403
- ext = ".png"
1404
- ext_match = re.search(r't=(png|jpg|jpeg|gif|bmp|webp)', img_url, re.IGNORECASE)
1405
- if ext_match:
1406
- ext = f".{ext_match.group(1).lower()}"
1407
- img_index += 1
1408
- img_name = f"step_img_{img_index}{ext}"
1409
- save_path = os.path.join(base_dir, img_name)
1410
- r = self.client.file_download(img_url, save_path)
1411
- if r.get("status") == 1:
1412
- steps_html = steps_html.replace(img_url, f"./{img_name}")
1413
- downloaded_images.append(img_name)
1414
-
1415
- # 生成 Markdown
1446
+ if img_url.startswith("data:"):
1447
+ continue
1448
+ step_img_idx += 1
1449
+ ext = _guess_ext(img_url)
1450
+ img_name = f"step_{step_img_idx}{ext}"
1451
+ if _download_image(img_url, img_name, "step"):
1452
+ steps_html = steps_html.replace(img_url, f"./images/{img_name}")
1453
+
1454
+ # ── 3. 处理备注 & 下载备注中的图片 ──
1455
+ _ACTION_MAP = {
1456
+ "opened": "创建", "edited": "编辑", "commented": "备注",
1457
+ "resolved": "解决", "closed": "关闭", "activated": "激活",
1458
+ "confirmed": "确认", "assigned": "指派", "bugconfirmed": "确认",
1459
+ }
1460
+ action_list = (list(actions.values()) if isinstance(actions, dict)
1461
+ else actions if isinstance(actions, list) else [])
1462
+
1463
+ comment_img_idx = 0
1464
+ comments_data = []
1465
+
1466
+ for act in action_list:
1467
+ if not isinstance(act, dict):
1468
+ continue
1469
+ action_type = act.get("action", "")
1470
+ actor_id = act.get("actor", "")
1471
+ actor_name = users.get(actor_id, actor_id) if users else actor_id
1472
+ date = act.get("date", "")
1473
+ comment_html = (act.get("comment") or "").strip()
1474
+ processed_comment = ""
1475
+
1476
+ if comment_html:
1477
+ comment_html = comment_html.replace("&amp;", "&").replace("&nbsp;", " ")
1478
+ for img_match in img_pattern.finditer(comment_html):
1479
+ c_url = img_match.group(1)
1480
+ if c_url.startswith("data:"):
1481
+ continue
1482
+ if not c_url.startswith("http"):
1483
+ c_url = f"{self.client.base_url}{c_url}"
1484
+ comment_img_idx += 1
1485
+ ext = _guess_ext(c_url)
1486
+ c_img_name = f"comment_{comment_img_idx}{ext}"
1487
+ if _download_image(c_url, c_img_name, "comment"):
1488
+ comment_html = comment_html.replace(
1489
+ img_match.group(0),
1490
+ f'<img src="./images/{c_img_name}" alt="" />')
1491
+ processed_comment = self._html_to_markdown(comment_html)
1492
+
1493
+ comments_data.append({
1494
+ "date": date,
1495
+ "actor": actor_name,
1496
+ "action": action_type,
1497
+ "action_label": _ACTION_MAP.get(action_type, action_type),
1498
+ "comment": processed_comment,
1499
+ "history": act.get("history") or act.get("histories") or [],
1500
+ })
1501
+
1502
+ # ── 4. 生成完整 Markdown ──
1416
1503
  md_lines = [
1417
1504
  f"# Bug #{bug_id}: {bug.get('title', '')}",
1418
1505
  "",
@@ -1422,6 +1509,7 @@ class ZenTaoNexus:
1422
1509
  "|------|------|",
1423
1510
  f"| ID | {bug_id} |",
1424
1511
  f"| 产品 | {_name('product')} |",
1512
+ f"| 模块 | {_name('module')} |",
1425
1513
  f"| 状态 | {_name('status', self._STATUS_MAP)} |",
1426
1514
  f"| 严重程度 | {_name('severity', self._SEVERITY_MAP)} |",
1427
1515
  f"| 优先级 | P{bug.get('pri', '')} |",
@@ -1429,130 +1517,125 @@ class ZenTaoNexus:
1429
1517
  f"| 指派给 | {_name('assignedTo')} |",
1430
1518
  f"| 创建人 | {_name('openedBy')} |",
1431
1519
  f"| 创建时间 | {bug.get('openedDate', '')} |",
1432
- f"| 截止日期 | {bug.get('deadline', '')} |",
1433
1520
  f"| 解决人 | {_name('resolvedBy') or '-'} |",
1521
+ f"| 解决时间 | {bug.get('resolvedDate', '') or '-'} |",
1434
1522
  f"| 解决方案 | {_name('resolution', self._RESOLUTION_MAP) or '-'} |",
1435
- f"| 关键词 | {bug.get('keywords', '')} |",
1523
+ f"| 关闭人 | {_name('closedBy') or '-'} |",
1524
+ f"| 关闭时间 | {bug.get('closedDate', '') or '-'} |",
1525
+ f"| 截止日期 | {bug.get('deadline', '') or '-'} |",
1526
+ f"| 关键词 | {bug.get('keywords', '') or '-'} |",
1527
+ f"| 关联需求 | {bug.get('story', '') or '-'} |",
1528
+ f"| 关联任务 | {bug.get('task', '') or '-'} |",
1436
1529
  "",
1437
1530
  "## 复现步骤",
1438
1531
  "",
1532
+ self._html_to_markdown(steps_html),
1533
+ "",
1439
1534
  ]
1440
1535
 
1441
- steps_md = self._html_to_markdown(steps_html)
1442
- md_lines.append(steps_md)
1443
- md_lines.append("")
1444
-
1536
+ # ── 附件列表 ──
1445
1537
  if downloaded_files:
1446
1538
  md_lines.append("## 附件")
1447
1539
  md_lines.append("")
1448
1540
  for f in downloaded_files:
1541
+ path_ref = f"./{f['saved']}" if f["saved"] else ""
1449
1542
  if f["success"]:
1450
1543
  is_img = any(f["saved"].lower().endswith(e)
1451
- for e in ('.png','.jpg','.jpeg','.gif','.bmp','.webp'))
1544
+ for e in ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'))
1452
1545
  if is_img:
1453
- md_lines.append(f"![{f['name']}](./{f['saved']})")
1546
+ md_lines.append(f"![{f['name']}]({path_ref})")
1454
1547
  else:
1455
- md_lines.append(f"- [{f['name']}](./{f['saved']})")
1548
+ size_kb = round(f["size"] / 1024, 1) if f["size"] else 0
1549
+ md_lines.append(f"- [{f['name']}]({path_ref}) ({size_kb} KB)")
1456
1550
  else:
1457
- md_lines.append(f"- {f['name']}(下载失败)")
1551
+ md_lines.append(f"- {f['name']}(下载失败: {f.get('reason', 'unknown')})")
1458
1552
  md_lines.append("")
1459
1553
 
1460
- # 备注/操作历史
1461
- _ACTION_MAP = {
1462
- "opened": "创建", "edited": "编辑", "commented": "备注",
1463
- "resolved": "解决", "closed": "关闭", "activated": "激活",
1464
- "confirmed": "确认", "assigned": "指派", "bugconfirmed": "确认",
1465
- }
1466
- action_list = list(actions.values()) if isinstance(actions, dict) else actions
1467
- has_comments = any(
1468
- a.get("comment", "").strip() for a in action_list if isinstance(a, dict)
1469
- )
1470
- if action_list:
1471
- md_lines.append("## 操作历史")
1554
+ # ── 操作历史 & 备注 ──
1555
+ if comments_data:
1556
+ md_lines.append("## 操作历史 & 备注")
1472
1557
  md_lines.append("")
1473
- comment_img_idx = 0
1474
- for act in action_list:
1475
- if not isinstance(act, dict):
1476
- continue
1477
- action_type = act.get("action", "")
1478
- action_label = _ACTION_MAP.get(action_type, action_type)
1479
- actor_id = act.get("actor", "")
1480
- actor_name = users.get(actor_id, actor_id) if users else actor_id
1481
- date = act.get("date", "")
1482
- comment_html = (act.get("comment") or "").strip()
1483
-
1484
- md_lines.append(f"### {date} — {actor_name} [{action_label}]")
1558
+ for c in comments_data:
1559
+ md_lines.append(f"### {c['date']} {c['actor']} [{c['action_label']}]")
1485
1560
  md_lines.append("")
1486
-
1487
- if comment_html:
1488
- comment_html = comment_html.replace("&amp;", "&").replace("&nbsp;", " ")
1489
- for img_match in img_pattern.finditer(comment_html):
1490
- img_url = img_match.group(1)
1491
- if not img_url.startswith('http'):
1492
- img_url = f"{self.client.base_url}{img_url}"
1493
- ext = ".png"
1494
- ext_m = re.search(r't=(png|jpg|jpeg|gif|bmp|webp)', img_url, re.IGNORECASE)
1495
- if ext_m:
1496
- ext = f".{ext_m.group(1).lower()}"
1497
- comment_img_idx += 1
1498
- c_img_name = f"comment_img_{comment_img_idx}{ext}"
1499
- c_save = os.path.join(base_dir, c_img_name)
1500
- r = self.client.file_download(img_url, c_save)
1501
- if r.get("status") == 1:
1502
- comment_html = comment_html.replace(
1503
- img_match.group(0),
1504
- f'<img src="./{c_img_name}" alt="" />')
1505
- downloaded_images.append(c_img_name)
1506
-
1507
- comment_md = self._html_to_markdown(comment_html)
1508
- md_lines.append(comment_md)
1561
+ if c["comment"]:
1562
+ md_lines.append(c["comment"])
1509
1563
  md_lines.append("")
1510
-
1511
- history = act.get("history", [])
1512
- if history:
1564
+ history = c["history"]
1565
+ if isinstance(history, list):
1513
1566
  for h in history:
1567
+ if not isinstance(h, dict):
1568
+ continue
1514
1569
  field = h.get("field", "")
1515
- old_val = h.get("old", "")
1516
- new_val = h.get("new", "")
1570
+ old_val = str(h.get("old", ""))
1571
+ new_val = str(h.get("new", ""))
1517
1572
  if old_val or new_val:
1518
- old_short = (old_val[:80] + '...') if len(old_val) > 80 else old_val
1519
- new_short = (new_val[:80] + '...') if len(new_val) > 80 else new_val
1520
- md_lines.append(f"- **{field}**: `{old_short}` → `{new_short}`")
1521
- md_lines.append("")
1573
+ old_s = (old_val[:100] + '') if len(old_val) > 100 else old_val
1574
+ new_s = (new_val[:100] + '') if len(new_val) > 100 else new_val
1575
+ md_lines.append(f"- **{field}**: `{old_s}` → `{new_s}`")
1576
+ if history:
1577
+ md_lines.append("")
1522
1578
 
1523
1579
  md_content = "\n".join(md_lines)
1524
- md_path = os.path.join(base_dir, f"{bug_id}_bug.md")
1580
+ md_path = os.path.join(base_dir, f"bug_{bug_id}.md")
1525
1581
  with open(md_path, 'w', encoding='utf-8') as fw:
1526
1582
  fw.write(md_content)
1527
1583
 
1584
+ abs_folder = os.path.abspath(base_dir)
1585
+ abs_md = os.path.abspath(md_path)
1586
+
1528
1587
  return {
1529
1588
  "status": 1,
1530
1589
  "message": "success",
1531
- "bug": bug,
1590
+ "data": {
1591
+ "bug": bug,
1592
+ "comments": comments_data,
1593
+ "users": users,
1594
+ },
1532
1595
  "export": {
1533
- "folder": os.path.abspath(base_dir),
1534
- "markdown": os.path.abspath(md_path),
1596
+ "folder": abs_folder,
1597
+ "markdown": abs_md,
1535
1598
  "attachments": downloaded_files,
1536
- "step_images": downloaded_images,
1537
- }
1599
+ "images": downloaded_images,
1600
+ "summary": {
1601
+ "total_attachments": len(downloaded_files),
1602
+ "downloaded_attachments": sum(1 for f in downloaded_files if f["success"]),
1603
+ "total_images": len(downloaded_images),
1604
+ "downloaded_images": sum(1 for i in downloaded_images if i["success"]),
1605
+ "total_comments": sum(1 for c in comments_data if c["comment"]),
1606
+ },
1607
+ },
1538
1608
  }
1539
1609
 
1540
1610
  @staticmethod
1541
1611
  def _html_to_markdown(html: str) -> str:
1542
- """简易 HTML→Markdown:处理 p、strong、img、br 标签"""
1612
+ """HTML Markdown 转换"""
1543
1613
  if not html:
1544
1614
  return ""
1545
1615
  text = html
1546
- text = re.sub(r'<img\s+[^>]*src="([^"]+)"[^>]*alt="([^"]*)"[^>]*/?>',
1616
+ text = re.sub(r'<img\s+[^>]*src=["\']([^"\']+)["\'][^>]*alt=["\']([^"\']*)["\'][^>]*/?>',
1547
1617
  r'![\2](\1)', text)
1548
- text = re.sub(r'<img\s+[^>]*src="([^"]+)"[^>]*/?>',
1618
+ text = re.sub(r'<img\s+[^>]*src=["\']([^"\']+)["\'][^>]*/?>',
1549
1619
  r'![](\1)', text)
1620
+ for i in range(6, 0, -1):
1621
+ text = re.sub(rf'<h{i}[^>]*>(.*?)</h{i}>', r'\n' + '#' * i + r' \1\n', text, flags=re.DOTALL)
1622
+ text = re.sub(r'<a\s+[^>]*href=["\']([^"\']+)["\'][^>]*>(.*?)</a>', r'[\2](\1)', text, flags=re.DOTALL)
1550
1623
  text = re.sub(r'<strong>(.*?)</strong>', r'**\1**', text)
1551
1624
  text = re.sub(r'<b>(.*?)</b>', r'**\1**', text)
1552
1625
  text = re.sub(r'<em>(.*?)</em>', r'*\1*', text)
1626
+ text = re.sub(r'<code>(.*?)</code>', r'`\1`', text)
1627
+ text = re.sub(r'<pre[^>]*>(.*?)</pre>', r'\n```\n\1\n```\n', text, flags=re.DOTALL)
1628
+ text = re.sub(r'<li[^>]*>(.*?)</li>', r'- \1\n', text, flags=re.DOTALL)
1629
+ text = re.sub(r'</?[ou]l[^>]*>', '\n', text)
1553
1630
  text = re.sub(r'<br\s*/?>', '\n', text)
1554
- text = re.sub(r'<p>(.*?)</p>', r'\1\n', text, flags=re.DOTALL)
1631
+ text = re.sub(r'<p[^>]*>(.*?)</p>', r'\1\n', text, flags=re.DOTALL)
1632
+ text = re.sub(r'<hr\s*/?>', '\n---\n', text)
1633
+ text = re.sub(r'</?(?:div|span|table|thead|tbody|tr|td|th|blockquote)[^>]*>', '\n', text)
1555
1634
  text = re.sub(r'<[^>]+>', '', text)
1635
+ text = re.sub(r'&lt;', '<', text)
1636
+ text = re.sub(r'&gt;', '>', text)
1637
+ text = re.sub(r'&quot;', '"', text)
1638
+ text = re.sub(r'&nbsp;', ' ', text)
1556
1639
  text = re.sub(r'\n{3,}', '\n\n', text)
1557
1640
  return text.strip()
1558
1641