"""商机产线数据能力:从数据爬取平台拉取招标数据(只读)。 所有函数签名统一 (sor, **params),返回 (ok, payload|错误信息)。 薄封装:查询条件透传给爬虫平台,本层不做二次加工(热点排序由爬虫平台算)。 """ from .opp_common import crawler_get # 明细类工具的 limit 硬上限(2026-09-11):明细是「举例佐证」用,不是「统计」用。 # 实测 agent 为「数排名前三/算平均价」拉 category_references(limit=300) 等全量明细, # 单次 ~2 万字符,连调多次撑爆上下文导致会话超时失败。统计应走聚合工具 #(hot_demands/hot_overseas/hot_software 已返回 tender_count+total_budget_wan)。 # 这是代码硬门禁,不靠工具描述让 LLM 自觉(描述只作引导)。 _DETAIL_LIMIT_MAX = 50 def _clamp_detail_limit(limit, default=20): """把明细类工具的 limit 收敛到 [1, _DETAIL_LIMIT_MAX],缺省用 default。""" try: n = int(float(limit)) except (TypeError, ValueError): n = default if n <= 0: n = default return min(n, _DETAIL_LIMIT_MAX) async def search_tenders(sor, keyword="", days=7, source="", only_it=True, limit=50): """关键词检索招标信息。""" params = {"days": days, "limit": limit} if keyword: params["keyword"] = keyword if source: params["source"] = source params["only_it"] = "1" if only_it else "0" return await crawler_get(sor, "/api/tenders", params) async def tender_detail(sor, tender_id): """单条招标详情(含公告全文、来源 url)。""" return await crawler_get(sor, "/api/tenders/%s" % tender_id) async def daily_ai_tenders(sor, target_date=""): """每日 AI/Agent 软件类招标展示数据。""" params = {} if target_date: params["date"] = target_date return await crawler_get(sor, "/api/ai_tenders", params) async def hot_software(sor, days=30, top=10): """热点软件主题排名(数量+预算+热点地区+样例)。""" return await crawler_get(sor, "/api/hot_software", {"days": days, "top": top}) async def category_references(sor, category, days=30, limit=20, record_type="tender"): """某软件主题的分项明细(references 可查证,举例佐证用)。 每条带公告原文/详情页 url + 采集来源 source_url,供界面逐条查证。 record_type: tender=招标(默认) / demand=众包需求发布(猪八戒/一品威客)。 limit 硬上限 _DETAIL_LIMIT_MAX(50)——统计用 hot_software 聚合,勿拉全量明细。 """ return await crawler_get(sor, "/api/category_references", {"category": category, "days": days, "limit": _clamp_detail_limit(limit), "record_type": record_type}) async def crawler_stats(sor): """爬虫平台采集健康度。""" return await crawler_get(sor, "/api/stats") async def demands(sor, keyword="", days=30, source="", limit=10): """众包平台软件需求发布列表(国内:猪八戒/一品威客;海外:Freelancer/PeoplePerHour)。 区别于招标:这些是甲方在众包平台直接发布的开发需求, 反映市场真实需求热度与预算分布。每条带详情页 url(可查证)。 """ params = {"days": days, "limit": _clamp_detail_limit(limit)} if keyword: params["keyword"] = keyword if source: params["source"] = source return await crawler_get(sor, "/api/demands", params) async def hot_demands(sor, days=30, top=10): """众包需求热点主题排名(需求侧热度)。""" return await crawler_get(sor, "/api/hot_demands", {"days": days, "top": top}) async def hot_overseas(sor, days=30, top=10): """海外众包软件需求热点主题排名(Freelancer/PeoplePerHour,英文主题)。""" return await crawler_get(sor, "/api/hot_overseas", {"days": days, "top": top}) async def overseas_references(sor, category, days=30, limit=20): """某海外需求主题的分项明细(references 可查证,举例佐证用)。 每条带海外平台详情页 url,供界面逐条查证。 limit 硬上限 _DETAIL_LIMIT_MAX(50)——统计用 hot_overseas 聚合,勿拉全量明细。 """ return await crawler_get(sor, "/api/overseas_references", {"category": category, "days": days, "limit": _clamp_detail_limit(limit)}) async def query_tenders(sor, keyword="", region="", buyer="", notice_type="", source="", min_budget="", max_budget="", min_ai="", days=90, group_by="", limit=50, only_it=True): """条件检索 + 汇总(用户口述条件 → LLM 映射为参数)。 过滤:keyword(标题多词AND)/region/buyer 模糊,notice_type/source 精确, min_budget/max_budget(万元)区间,min_ai 评分下限,days 回溯窗口,only_it。 汇总:group_by=region|notice_type|category|source|month → 分组统计+样例。 """ params: dict = {"days": days, "limit": limit} for k, v in (("keyword", keyword), ("region", region), ("buyer", buyer), ("notice_type", notice_type), ("source", source), ("min_budget", min_budget), ("max_budget", max_budget), ("min_ai", min_ai), ("group_by", group_by)): if v not in (None, ""): params[k] = v params["only_it"] = "1" if only_it else "0" return await crawler_get(sor, "/api/tenders_query", params) # ── 条目附件(爬虫平台附件子系统对接,2026-09-08)── # 契约:GET /api/tenders/{id}/attachments(爬虫平台侧由附件子系统提供)。 # 字段做容错归一:名称 filename|name|title、原始地址 url|origin_url、 # 平台副本标记 local_path|file_path|downloaded、平台下载入口 # download_url(有则优先),大小 size|file_size。 def normalize_attachment(a): """单个附件 dict → 统一字段 {name,url,downloaded,download_url,size}。""" if not isinstance(a, dict): return None name = (a.get("filename") or a.get("name") or a.get("title") or a.get("local_path") or "").strip() url = (a.get("url") or a.get("origin_url") or "").strip() dl = (a.get("download_url") or "").strip() downloaded = bool(a.get("local_path") or a.get("file_path") or a.get("downloaded") or dl) size = a.get("size") or a.get("file_size") or 0 try: size = int(size) except (TypeError, ValueError): size = 0 if not name and not url and not dl: return None return {"id": a.get("id") or a.get("attachment_id") or "", "name": name or url.rsplit("/", 1)[-1] or "附件", "url": url, "download_url": dl, "downloaded": downloaded, "size": size} async def tender_attachments(sor, tender_id): """条目附件清单 → (ok, [归一化附件])。端点未就绪/失败时返回 (False, 原因)。""" ok, res = await crawler_get(sor, "/api/tenders/%s/attachments" % tender_id) if not ok or not isinstance(res, dict): return False, res items = res.get("items") or res.get("attachments") or [] out = [] for a in items: n = normalize_attachment(a) if n: out.append(n) return True, out