yumoqing 45cec8987e feat(opp): 数据参考三级整行下钻+明细详情弹窗+附件下载代理
- 汇总主题/分项条目整行点击(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 同步新交互描述
2026-09-08 23:39:16 +08:00

151 lines
6.2 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
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