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