173 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""商机产线数据能力:从数据爬取平台拉取招标数据(只读)。
所有函数签名统一 (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