zendesk_client.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. """Zendesk Help Center 异步客户端。
  2. 封装两类调用:
  3. - list_all_faq_sections:扫描 sections 找出名字含 "faq" 且文章数 > 0 的 sec_id
  4. - search_articles:按 section 列表搜索文章
  5. """
  6. from __future__ import annotations
  7. import logging
  8. from typing import Any
  9. import httpx
  10. from app.config import settings
  11. logger = logging.getLogger(__name__)
  12. class ZendeskError(Exception):
  13. """Zendesk API 调用失败统一异常。"""
  14. async def _get_article_count(
  15. client: httpx.AsyncClient, section_id: int, locale: str
  16. ) -> int:
  17. """获取某 section 的文章数(与 test2.py 中 get_article_count 等价)。"""
  18. url = (
  19. f"{settings.zendesk_base_url}/{locale}/sections/"
  20. f"{section_id}/articles.json"
  21. )
  22. try:
  23. resp = await client.get(url, params={"per_page": 1})
  24. if resp.status_code == 200:
  25. return int(resp.json().get("count", 0))
  26. except httpx.HTTPError as exc:
  27. logger.warning("get_article_count 失败 sec_id=%s: %s", section_id, exc)
  28. return 0
  29. async def list_all_faq_sections(
  30. locales: list[str] | None = None,
  31. ) -> tuple[list[int], list[dict[str, Any]]]:
  32. """完整列出所有 FAQ 相关 Section(异步版本,等价于 test2.list_all_sections_full)。
  33. 返回:
  34. (sec_ids, faq_sections):
  35. sec_ids — 含有文章 (count>0) 的 FAQ Section ID 列表
  36. faq_sections — 详细元数据列表
  37. """
  38. locales = locales or [settings.default_locale]
  39. faq_sections: list[dict[str, Any]] = []
  40. sec_ids: list[int] = []
  41. timeout = httpx.Timeout(settings.http_timeout)
  42. async with httpx.AsyncClient(
  43. auth=settings.zendesk_auth, timeout=timeout
  44. ) as client:
  45. for locale in locales:
  46. url: str | None = (
  47. f"{settings.zendesk_base_url}/{locale}/sections.json"
  48. )
  49. page = 1
  50. logger.info("扫描 Locale: %s", locale)
  51. while url:
  52. params: dict[str, Any] = {"per_page": 100}
  53. # next_page 已包含 page 参数;首次请求才需要显式传
  54. if "page=" not in url:
  55. params["page"] = page
  56. try:
  57. resp = await client.get(url, params=params)
  58. except httpx.HTTPError as exc:
  59. logger.error("sections 请求异常 locale=%s: %s", locale, exc)
  60. break
  61. if resp.status_code != 200:
  62. logger.error(
  63. "sections 请求失败 locale=%s status=%s",
  64. locale,
  65. resp.status_code,
  66. )
  67. break
  68. data = resp.json()
  69. sections = data.get("sections", [])
  70. # logger.info("第 %s 页,共 %s 个 Section", page, len(sections))
  71. for sec in sections:
  72. name = (sec.get("name") or "").strip()
  73. sec_id = sec.get("id")
  74. cat_id = sec.get("category_id")
  75. if "faq" in name.lower():
  76. count = await _get_article_count(client, sec_id, locale)
  77. logger.info(
  78. "✅ FAQ Section id=%s count=%s name=%s cat=%s",
  79. sec_id,
  80. count,
  81. name,
  82. cat_id,
  83. )
  84. if count > 0:
  85. sec_ids.append(sec_id)
  86. faq_sections.append(
  87. {
  88. "id": sec_id,
  89. "name": name,
  90. "category_id": cat_id,
  91. "locale": locale,
  92. "article_count": count,
  93. }
  94. )
  95. url = data.get("next_page")
  96. page += 1
  97. logger.info("总共发现 %s 个 FAQ 相关 Section", len(faq_sections))
  98. return sec_ids, faq_sections
  99. async def search_articles(
  100. query: str,
  101. section_ids: list[int],
  102. locale: str | None = None,
  103. page: int = 1,
  104. per_page: int = 25,
  105. **extra: Any,
  106. ) -> dict[str, Any]:
  107. """搜索 Help Center 文章(异步版本,等价于 test1.search_articles)。
  108. section_ids 为空列表时,不带 section 过滤直接搜索全部。
  109. """
  110. locale = locale or settings.default_locale
  111. url = f"{settings.zendesk_base_url}/articles/search"
  112. params: dict[str, Any] = {
  113. "query": query,
  114. "locale": locale,
  115. "page": page,
  116. "per_page": per_page,
  117. }
  118. if section_ids:
  119. params["section"] = ",".join(map(str, section_ids))
  120. params.update({k: v for k, v in extra.items() if v is not None})
  121. timeout = httpx.Timeout(settings.http_timeout)
  122. async with httpx.AsyncClient(
  123. auth=settings.zendesk_auth, timeout=timeout
  124. ) as client:
  125. try:
  126. resp = await client.get(url, params=params)
  127. resp.raise_for_status()
  128. return resp.json()
  129. except httpx.HTTPStatusError as exc:
  130. logger.error(
  131. "search_articles HTTP %s: %s",
  132. exc.response.status_code,
  133. exc.response.text,
  134. )
  135. raise ZendeskError(
  136. f"Zendesk 返回 {exc.response.status_code}"
  137. ) from exc
  138. except httpx.HTTPError as exc:
  139. logger.error("search_articles 网络错误: %s", exc)
  140. raise ZendeskError(str(exc)) from exc