proceedings_discovery.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. """Tavily 自动发现会议官网 proceedings 域名(无需事先写入 JSON 映射)。"""
  2. from __future__ import annotations
  3. import logging
  4. import re
  5. from functools import lru_cache
  6. from typing import Any
  7. from urllib.parse import urlparse
  8. from .tavily_venue_config import get_official_proceedings_hosts, tavily_include_domains_for_venue
  9. logger = logging.getLogger(__name__)
  10. _PROCEEDINGS_PATH_HINTS = (
  11. "proceedings",
  12. "openaccess",
  13. "/papers/",
  14. "papers.nips",
  15. "thecvf.com",
  16. "mlr.press",
  17. "aclanthology",
  18. "openreview.net",
  19. "program",
  20. "main_conference",
  21. "main-conference",
  22. )
  23. _BAD_DISCOVERY_HOSTS = (
  24. "dblp.org",
  25. "arxiv.org",
  26. "openalex.org",
  27. "semanticscholar.org",
  28. "google.",
  29. "youtube.",
  30. "twitter.",
  31. "x.com",
  32. "facebook.",
  33. "wikipedia.org",
  34. "paperswithcode.com",
  35. "github.com",
  36. "reddit.com",
  37. "medium.com",
  38. "linkedin.com",
  39. "scholar.google",
  40. )
  41. def _normalize_host(url: str) -> str:
  42. try:
  43. host = (urlparse(url).netloc or "").lower().removeprefix("www.")
  44. except ValueError:
  45. return ""
  46. return host
  47. def _score_proceedings_url(url: str, *, venue: str, year: int | None) -> float:
  48. if not url:
  49. return 0.0
  50. low = url.lower()
  51. host = _normalize_host(url)
  52. if not host:
  53. return 0.0
  54. if any(b in host or b in low for b in _BAD_DISCOVERY_HOSTS):
  55. return 0.0
  56. score = 0.0
  57. official = get_official_proceedings_hosts()
  58. if any(h in host for h in official):
  59. score += 50.0
  60. if any(h in low for h in _PROCEEDINGS_PATH_HINTS):
  61. score += 25.0
  62. if year is not None and str(year) in low:
  63. score += 20.0
  64. vl = (venue or "").strip().lower()
  65. if vl and vl in low:
  66. score += 15.0
  67. if re.search(r"/(paper|publication|content|html)/", low):
  68. score += 8.0
  69. if "workshop" in low or "challenge" in low or "ntire" in low:
  70. score -= 30.0
  71. return score
  72. @lru_cache(maxsize=128)
  73. def _discovery_queries(venue: str, year: int | None) -> tuple[str, ...]:
  74. v = (venue or "").strip()
  75. y = f" {year}" if year is not None else ""
  76. return (
  77. f"{v}{y} official proceedings open access papers site",
  78. f"{v}{y} conference accepted papers list main conference",
  79. f"{v}{y} openaccess proceedings {v} papers",
  80. f"{v}{y} main conference track accepted papers",
  81. f"site:papers.nips.cc {v}{y} accepted papers main conference",
  82. f"site:openreview.net {v}{y} accepted papers",
  83. )
  84. async def discover_proceedings_domains(
  85. *,
  86. api_key: str,
  87. venue: str,
  88. year: int | None = None,
  89. httpx_client: Any = None,
  90. max_domains: int = 3,
  91. ) -> list[str]:
  92. """用 Tavily 开放搜索会议名+年份,从结果 URL 推断官方 proceedings 站点域名。"""
  93. venue = (venue or "").strip()
  94. if not venue or not (api_key or "").strip():
  95. return []
  96. static = tavily_include_domains_for_venue(venue)
  97. if static:
  98. return list(static)[:max_domains]
  99. from .web_presearch import tavily_search_async
  100. host_scores: dict[str, float] = {}
  101. for q in _discovery_queries(venue, year):
  102. try:
  103. items = await tavily_search_async(
  104. api_key=api_key,
  105. query=q,
  106. max_results=8,
  107. include_domains=None,
  108. httpx_client=httpx_client,
  109. )
  110. except Exception as e:
  111. logger.debug("[proceedings_discovery] query failed %r: %s", q[:60], e)
  112. continue
  113. for it in items or []:
  114. link = str(it.get("link") or it.get("url") or "").strip()
  115. if not link:
  116. continue
  117. host = _normalize_host(link)
  118. if not host or "." not in host:
  119. continue
  120. sc = _score_proceedings_url(link, venue=venue, year=year)
  121. if sc <= 0:
  122. continue
  123. host_scores[host] = max(host_scores.get(host, 0.0), sc)
  124. ranked = sorted(host_scores.items(), key=lambda x: x[1], reverse=True)
  125. domains = [h for h, sc in ranked if sc >= 20.0][:max_domains]
  126. if domains:
  127. logger.info(
  128. "[proceedings_discovery] venue=%s year=%s → domains %s (scores=%s)",
  129. venue,
  130. year,
  131. domains,
  132. [round(host_scores[d], 1) for d in domains],
  133. )
  134. return domains
  135. async def discover_proceedings_links(
  136. *,
  137. api_key: str,
  138. venue: str,
  139. year: int | None = None,
  140. httpx_client: Any = None,
  141. max_links: int = 5,
  142. ) -> list[dict[str, Any]]:
  143. """用 Tavily 找具体 proceedings/accepted-papers 页面,保留 link/raw_content 供后续抽取。"""
  144. venue = (venue or "").strip()
  145. if not venue or not (api_key or "").strip():
  146. return []
  147. from .web_presearch import tavily_search_async
  148. static_domains = tavily_include_domains_for_venue(venue) or None
  149. scored: dict[str, dict[str, Any]] = {}
  150. domain_passes = [static_domains, None] if static_domains else [None]
  151. for domain_pass in domain_passes:
  152. if scored and max(float(x.get("score") or 0) for x in scored.values()) >= 70:
  153. break
  154. for q in _discovery_queries(venue, year):
  155. try:
  156. items = await tavily_search_async(
  157. api_key=api_key,
  158. query=q,
  159. max_results=8,
  160. include_domains=domain_pass,
  161. httpx_client=httpx_client,
  162. )
  163. except Exception as e:
  164. logger.debug("[proceedings_discovery] link query failed %r: %s", q[:60], e)
  165. continue
  166. for it in items or []:
  167. link = str(it.get("link") or it.get("url") or "").strip()
  168. if not link:
  169. continue
  170. sc = _score_proceedings_url(link, venue=venue, year=year)
  171. if sc <= 0:
  172. continue
  173. prev = scored.get(link)
  174. if prev and float(prev.get("score") or 0) >= sc:
  175. continue
  176. scored[link] = {
  177. "link": link,
  178. "title": str(it.get("title") or "").strip(),
  179. "snippet": str(it.get("snippet") or it.get("content") or "").strip(),
  180. "raw_content": str(it.get("raw_content") or "")[:24000],
  181. "score": sc,
  182. }
  183. ranked = sorted(scored.values(), key=lambda x: float(x.get("score") or 0), reverse=True)
  184. out = ranked[: max(1, int(max_links or 5))]
  185. if out:
  186. logger.info(
  187. "[proceedings_discovery] venue=%s year=%s → links=%s",
  188. venue,
  189. year,
  190. [x.get("link") for x in out],
  191. )
  192. return out