- 汇总主题/分项条目整行点击(cursor:pointer)弹下级弹窗,撤「查看分项」按钮
- 明细每条来源写进说明文字(source_text),删除 source_button_widget 来源按钮
- 新 opp_item_detail.dspy: 爬虫平台条目全量字段+公告全文+附件列表
- 新 opp_attachment_dl.dspy: 服务端附件下载代理,归属校验防SSRF,
降级链 download_url→/api/attachments/{id}/download→原始url,失败如实报错
- opp_data_capability: tender_attachments/normalize_attachment 容错对接
爬虫平台附件子系统(/api/tenders/{id}/attachments,另一会话开发中,未就绪时降级提示)
- opp_common: crawler_get_raw 原始字节下载辅助; load_path 注册两个新端点
- README/prompt 同步新交互描述
151 lines
6.2 KiB
Python
151 lines
6.2 KiB
Python
"""商机产线数据能力:从数据爬取平台拉取招标数据(只读)。
|
||
|
||
所有函数签名统一 (sor, **params),返回 (ok, payload|错误信息)。
|
||
薄封装:查询条件透传给爬虫平台,本层不做二次加工(热点排序由爬虫平台算)。
|
||
"""
|
||
|
||
from .opp_common import crawler_get
|
||
|
||
|
||
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=500, record_type="tender"):
|
||
"""某软件主题的全部分项(references 可查证)。
|
||
|
||
每条带公告原文/详情页 url + 采集来源 source_url,供界面逐条查证。
|
||
record_type: tender=招标(默认) / demand=众包需求发布(猪八戒/一品威客)。
|
||
"""
|
||
return await crawler_get(sor, "/api/category_references",
|
||
{"category": category, "days": days, "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=50):
|
||
"""众包平台软件需求发布列表(国内:猪八戒/一品威客;海外:Freelancer/PeoplePerHour)。
|
||
|
||
区别于招标:这些是甲方在众包平台直接发布的开发需求,
|
||
反映市场真实需求热度与预算分布。每条带详情页 url(可查证)。
|
||
"""
|
||
params = {"days": days, "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=500):
|
||
"""某海外需求主题的全部分项(references 可查证)。
|
||
|
||
每条带海外平台详情页 url,供界面逐条查证。
|
||
"""
|
||
return await crawler_get(sor, "/api/overseas_references",
|
||
{"category": category, "days": days, "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
|