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 同步新交互描述
This commit is contained in:
yumoqing 2026-09-08 23:39:16 +08:00
parent 1040eb0bd9
commit 45cec8987e
11 changed files with 584 additions and 213 deletions

View File

@ -46,6 +46,25 @@ agent 禁止自批自审:确认与审批结论只能由人工任务回流驱
角色:`agent.opp_analyst`(商机分析师)、`agent.opp_writer`(研发报告撰写工程师)。
slash 命令:`/hot` `/ai` `/reports` `/oppdiag`。
## 数据参考弹窗(三级下钻,2026-09-08 交互定稿)
菜单「📋 数据参考」→ `opp_references_popup.dspy`,三级结构全部**整行点击**(cursor:pointer,
无「查看分项」按钮):
1. **汇总层**(主题排名:招标/国内众包/海外三板块):点主题条目 → 弹出该主题分项列表
(`opp_references_items` / `opp_demand_items` / `opp_overseas_items.dspy`)。
2. **分项层**(明细条目):每条说明文字标注「来源:xxx」(`source_text()` 解析,
来源按钮已废弃删除);点条目 → 弹出具体内容(`opp_item_detail.dspy`:
爬虫平台 `/api/tenders/{id}` 全量字段 + 公告/需求全文 + 原文页/采集来源页按钮 + 附件列表)。
3. **附件层**:详情弹窗列出条目附件(爬虫平台 `/api/tenders/{id}/attachments`,
字段容错归一 `normalize_attachment`),「下载」按钮走服务端代理
`opp_attachment_dl.dspy`——先回查条目附件清单做归属校验(防 SSRF,前端传任意
att_url 不在清单内即拒绝),再按降级链取字节:记录 download_url →
`/api/attachments/{id}/download` → 附件原始 url;代理失败如实报错不静默。
附件端点未就绪(爬虫平台附件子系统部署中)时:分项列表不显示 📎 角标、详情弹窗
显示「附件服务暂不可用」,其余功能不受影响。
## 爬虫平台接入配置
系统级配置禁硬编码,走 appbase `params` 表(兜底默认值内网地址):

View File

@ -124,7 +124,8 @@ opp_query_tenders / opp_tender_detail),**不要**自己去爬网站,
- **所有分析必须带 references(可查证)**:给排名/统计/推荐结论时,调
opp_category_references / opp_query_tenders / opp_demands / opp_overseas_references
取分项明细,回答末尾附 References 章节——
列出自带的「📋 数据参考」菜单(招标/国内众包需求/海外需求三类主题都可逐条点开查原文),
列出自带的「📋 数据参考」菜单(招标/国内众包需求/海外需求三类主题:点主题条目弹出
全部分项,点分项条目弹出具体内容——公告/需求全文与附件下载,数据均来自爬取平台),
并在正文中给出 3-5 条代表性分项的标题+来源链接(markdown 链接,外链会自动新窗口打开)。
- **凡是回复中出现单个数据项的列表(招标/需求/样例),每一项都必须带来源 URL**:
用 markdown 链接把标题挂到该条的 url(公告原文/需求详情页)上,格式如

View File

@ -9,6 +9,7 @@
import json
import logging
import re
import urllib.parse
import urllib.request
@ -76,30 +77,17 @@ def infer_source_code(source_url):
return ""
def source_button_widget(code, source_url=""):
"""来源名称按钮:显示来源名称,点击新窗口直跳来源 url。
def source_text(code, source_url=""):
"""来源 code/url → 说明文字用的来源名称(2026-09-08 用户定夺:
明细列表每项的来源写进说明文字,不再渲染来源按钮)。
名称解析顺序:记录 source code → source_url 域名反推 → 空则「采集来源页」。
跳转目标优先记录级 source_url(采集来源页,最精确),缺失回退来源主页;
两者皆无返回 None(不渲染按钮)。
解析顺序:记录 source code → source_url 域名反推;解析不出返回「未知来源」。
"""
code = (code or "").strip()
if not code:
code = infer_source_code(source_url)
name, home = source_display(code)
target = (source_url or "").strip() or home
if not target:
return None
if not name:
name = "采集来源页"
return {
"widgettype": "Button",
"options": {"label": name, "css": "small"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self",
"script": "window.open(%s,'_blank','noopener');"
% json.dumps(target)}],
}
name, _home = source_display(code)
return name or "未知来源"
# ── 报告状态 ──
RP_DRAFT = "draft" # 草稿(agent 编写中/待审阅)
@ -224,6 +212,57 @@ async def crawler_get(sor, path, params=None, timeout=20):
return False, "爬虫平台调用失败: %s" % str(e)[:200]
async def crawler_get_raw(sor, url, timeout=60):
"""下载爬虫平台上的原始字节(附件代理下载用)。
url 为绝对地址(含平台下载端点)。返回 (ok, bytes, content_type, 文件名或错误)。
带 X-API-Token;文件名优先从 Content-Disposition 解析。
"""
base, token = await get_crawler_config(sor)
# 相对下载端点补全为绝对地址(download_url 可能是 /api/... 形态)
if url.startswith("/"):
url = base + url
headers = {"X-API-Token": token} if token else {}
try:
import httpx
except ImportError:
httpx = None
def _parse_disp(cd):
if not cd:
return ""
m = re.search(r"filename\*=UTF-8''([^;]+)", cd, re.I)
if m:
return urllib.parse.unquote(m.group(1)).strip()
m = re.search(r'filename="?([^";]+)"?', cd, re.I)
if m:
return m.group(1).strip()
return ""
try:
if httpx is not None:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as cli:
r = await cli.get(url, headers=headers)
if r.status_code != 200:
return False, b"", "", "爬虫平台返回 %d" % r.status_code
ct = r.headers.get("Content-Type", "")
fn = _parse_disp(r.headers.get("Content-Disposition", ""))
return True, r.content, ct, fn
import asyncio
def _blocking():
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return (resp.read(), resp.headers.get("Content-Type", ""),
_parse_disp(resp.headers.get("Content-Disposition", "")))
data, ct, fn = await asyncio.get_event_loop().run_in_executor(None, _blocking)
return True, data, ct, fn
except Exception as e:
logger.warning("crawler_get_raw failed: %s", str(e)[:200])
return False, b"", "", "附件下载失败: %s" % str(e)[:200]
async def find_human_task(sor, project_id, task_type, status=None):
"""查项目下指定类型的人工任务(供门禁判断与幂等创建)。"""
sql = ("SELECT id, title, status, task_type FROM pipeline_human_tasks "

View File

@ -106,3 +106,45 @@ async def query_tenders(sor, keyword="", region="", buyer="", notice_type="",
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

View File

@ -28,6 +28,8 @@ PATHS_LOGINED = [
"/%s/api/opp_references_items.dspy" % MOD,
"/%s/api/opp_demand_items.dspy" % MOD,
"/%s/api/opp_overseas_items.dspy" % MOD,
"/%s/api/opp_item_detail.dspy" % MOD,
"/%s/api/opp_attachment_dl.dspy" % MOD,
"/%s/api/opp_confirm_report.dspy" % MOD,
"/%s/api/opp_report_ppt.dspy" % MOD,
]

View File

@ -0,0 +1,75 @@
# opp_attachment_dl.dspy - 附件下载代理(服务端带 token 取爬虫平台附件字节回传浏览器)
# 入参:tender_id=<条目id> + (att_id=<附件id> 或 att_url=<附件原始url>二选一)
# 安全:att_url 必须回查该条目的附件清单验证归属,前端不可传任意地址(防 SSRF);
# att_id 同样只用于在下载候选里定位,不作为取文件的路径拼接输入。
# 下载源降级链:记录自带 download_url → 约定端点 /api/attachments/{id}/download → 附件原始 url。
from urllib.parse import quote
from aiohttp import web
uid = await get_user()
if not uid:
return {"widgettype": "Message", "options": {"title": "未登录", "message": "请先登录"}}
tender_id = str((params_kw or {}).get("tender_id") or "").strip()
att_id = str((params_kw or {}).get("att_id") or "").strip()
att_url = str((params_kw or {}).get("att_url") or "").strip()
if not tender_id or not (att_id or att_url):
return {"widgettype": "Message", "options": {
"title": "缺少参数", "message": "需要 tender_id + att_id/att_url"}}
dbname = get_module_dbname('pipeline-opportunity')
async with DBPools().sqlorContext(dbname) as sor:
from pipeline_opportunity.opp_data_capability import tender_attachments
from pipeline_opportunity.opp_common import crawler_get_raw, get_crawler_config
ok, atts = await tender_attachments(sor, tender_id)
await sor.sqlExe("COMMIT", {})
base, _token = await get_crawler_config(sor)
await sor.sqlExe("COMMIT", {})
if not ok:
return {"widgettype": "Message", "options": {
"title": "附件服务不可用", "message": str(atts)[:200]}}
# ── 定位附件(必须在清单内,归属校验)──
target = None
for a in atts:
if att_id and str(a.get("id") or "") == att_id:
target = a
break
if att_url and a.get("url") == att_url:
target = a
break
if target is None:
return {"widgettype": "Message", "options": {
"title": "附件不存在", "message": "该附件不在条目 %s 的附件清单中" % tender_id}}
# ── 下载候选降级链 ──
candidates = []
du = str(target.get("download_url") or "")
if du:
candidates.append(du if du.startswith("http") else base + du)
if att_id or str(target.get("id") or ""):
candidates.append(base + "/api/attachments/%s/download" % (att_id or target.get("id")))
if target.get("url"):
candidates.append(target["url"])
if not candidates:
return {"widgettype": "Message", "options": {
"title": "无可下载地址", "message": "该附件记录缺少可用地址"}}
err_msgs = []
for cand in candidates:
ok2, data, ctype, disp_name = await crawler_get_raw(sor, cand, timeout=120)
if ok2 and data:
name = disp_name or str(target.get("name") or "attachment")
headers = {"Content-Disposition":
'attachment; filename="%s"; filename*=UTF-8\'\'%s' % (quote(name, safe=""), quote(name))}
return web.Response(body=data,
content_type=(ctype or "application/octet-stream").split(";")[0].strip() or None,
headers=headers)
err_msgs.append(str(cand)[:80] + " → " + str(disp_name)[:80])
return {"widgettype": "Message", "options": {
"title": "附件下载失败",
"message": "已尝试 %d 个下载地址均失败:%s" % (len(candidates), ";".join(err_msgs)[:300])}}

View File

@ -1,5 +1,7 @@
# opp_demand_items.dspy - 某软件主题的众包需求分项列表(国内猪八戒/一品威客,references 可查证)
# 入参:category=<主题名>。每条分项:标题+时间+预算+来源平台 + 「需求详情」「来源页」按钮(新窗口)
# opp_demand_items.dspy - 某软件主题的众包需求分项列表(国内猪八戒/一品威客)
# 入参:category=<主题名>。交互(2026-09-08 用户定夺):
# 每条分项整行点击 → opp_item_detail.dspy 弹出具体内容(爬取平台数据+附件下载);
# 来源写进每条说明文字(来源:猪八戒/一品威客),不再渲染来源按钮。
import json as _json
@ -14,14 +16,14 @@ if not category:
dbname = get_module_dbname('pipeline-opportunity')
detail_url = entire_url("/pipeline-opportunity/api/opp_item_detail.dspy")
async with DBPools().sqlorContext(dbname) as sor:
from pipeline_opportunity.opp_data_capability import category_references
from pipeline_opportunity.opp_common import source_button_widget
from pipeline_opportunity.opp_common import source_text
ok, res = await category_references(sor, category, days=days, limit=500, record_type="demand")
await sor.sqlExe("COMMIT", {})
SRC_NAME = {"zbj": "猪八戒", "epwk": "一品威客"}
rows = []
if not ok:
rows.append({"widgettype": "Text", "options": {
@ -33,42 +35,48 @@ else:
rows.append({"widgettype": "Text", "options": {
"text": "该主题近 %d 天无众包需求分项" % days, "color": "#64748b", "padding": "12px", "halign": "left"}})
for it in items:
url = str(it.get("url") or "")
src = str(it.get("source_url") or "")
sub_btns = []
if url:
sub_btns.append({"widgettype": "Button", "options": {"label": "需求详情", "css": "small"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self",
"script": "window.open(" + _json.dumps(url) + ",'_blank','noopener');"}]})
# 来源:显示来源名称,点击直跳来源 url(2026-09-08 用户定夺)
_sb = source_button_widget(str(it.get("source") or ""), src)
if _sb:
sub_btns.append(_sb)
item_id = str(it.get("id") or "")
budget = it.get("budget_wan") or 0
plat = SRC_NAME.get(str(it.get("source") or ""), str(it.get("source") or "众包平台"))
meta = "%s | %s | %s" % (
# 来源写进说明文字(不再做来源按钮)
src_name = source_text(str(it.get("source") or ""), str(it.get("source_url") or ""))
meta = "%s | %s | 来源:%s" % (
str(it.get("publish_time") or "")[:10],
plat,
(("预算 %.0f 万" % budget) if budget else "预算未公开"))
rows.append({
"widgettype": "VBox",
"options": {"width": "100%", "padding": "8px 12px", "gap": "4px",
"border": "1px solid #e2e8f0", "borderRadius": "8px",
"margin": "0 0 6px 0", "bgcolor": "#ffffff"},
"subwidgets": [
{"widgettype": "Text", "options": {
"text": str(it.get("title") or ""), "cfontsize": 0.95,
"color": "#0f172a", "halign": "left", "wrap": True}},
{"widgettype": "HBox", "options": {"width": "100%", "gap": "10px",
"alignItems": "center"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": meta,
"cfontsize": 0.75, "color": "#94a3b8", "halign": "left"}},
{"widgettype": "Filler"}
] + sub_btns}
]
})
(("赏金 %.2f 万" % budget) if budget else "赏金面议"),
src_name)
row_subs = [
{"widgettype": "Text", "options": {
"text": str(it.get("title") or ""), "cfontsize": 0.95,
"color": "#0f172a", "halign": "left", "wrap": True}},
{"widgettype": "HBox", "options": {"width": "100%", "gap": "10px",
"alignItems": "center"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": meta,
"cfontsize": 0.75, "color": "#94a3b8", "halign": "left",
"wrap": True}},
{"widgettype": "Filler"},
{"widgettype": "Text", "options": {"text": "›",
"cfontsize": 1.0, "color": "#94a3b8"}}
]
}
]
row_opts = {"width": "100%", "padding": "8px 12px", "gap": "4px",
"border": "1px solid #e2e8f0", "borderRadius": "8px",
"margin": "0 0 6px 0", "bgcolor": "#ffffff"}
if item_id:
row_opts["cursor"] = "pointer"
script = ("var old=bricks.getWidgetById('opp_item_detail_pw',bricks.app);"
"if(old&&old.destroy){old.destroy();}"
"var rp=await fetch(" + _json.dumps(detail_url) + "+'?id='+encodeURIComponent(" + _json.dumps(item_id) + "));"
"var d=await rp.json();"
"if(d){bricks.widgetBuild(d,bricks.app);}")
rows.append({
"widgettype": "VBox", "options": row_opts,
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self", "script": script}],
"subwidgets": row_subs})
else:
rows.append({"widgettype": "VBox", "options": row_opts,
"subwidgets": row_subs})
total = res.get("count", len(rows)) if ok else 0
return {
@ -82,7 +90,7 @@ return {
"padding": "12px 16px", "gap": "0px", "overflow": "auto"},
"subwidgets": [
{"widgettype": "Text", "options": {
"text": "每条「需求详情」为众包平台原始需求页;来源按钮显示平台名称、点击直达该条采集来源页,均在新窗口打开,可逐条查证。",
"text": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。",
"cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}
] + rows
}]

View File

@ -0,0 +1,193 @@
# opp_item_detail.dspy - 单条数据项的具体内容弹窗(数据来自数据爬取平台)
# 入参:id=<爬虫平台条目id>。展示公告/需求全量字段 + 公告全文 + 来源说明 + 附件列表。
# 附件点击下载:走 opp_attachment_dl.dspy 代理(平台副本优先,缺失回退原始地址)。
import json as _json
uid = await get_user()
if not uid:
return {"widgettype": "Message", "options": {"title": "未登录", "message": "请先登录"}}
item_id = str((params_kw or {}).get("id") or "").strip()
if not item_id:
return {"widgettype": "Message", "options": {"title": "缺少参数", "message": "缺少 id"}}
dbname = get_module_dbname('pipeline-opportunity')
async with DBPools().sqlorContext(dbname) as sor:
from pipeline_opportunity.opp_data_capability import tender_detail, tender_attachments
from pipeline_opportunity.opp_common import source_text
ok, det = await tender_detail(sor, item_id)
await sor.sqlExe("COMMIT", {})
att_ok, atts = await tender_attachments(sor, item_id)
await sor.sqlExe("COMMIT", {})
dl_base = entire_url("/pipeline-opportunity/api/opp_attachment_dl.dspy")
body = []
if not ok or not isinstance(det, dict):
body.append({"widgettype": "Text", "options": {
"text": "获取详情失败:" + str(det)[:200],
"color": "#dc2626", "padding": "12px", "halign": "left"}})
else:
# ── 标题 ──
body.append({"widgettype": "Text", "options": {
"text": str(det.get("title") or ""), "cfontsize": 1.15,
"color": "#0f172a", "halign": "left", "wrap": True,
"margin": "0 0 8px 0"}})
# ── 字段区 ──
rt = str(det.get("record_type") or "tender")
budget = det.get("budget_wan") or 0
fields = []
fields.append(("发布时间", str(det.get("publish_time") or "—")))
if rt == "tender":
fields.append(("公告类型", str(det.get("notice_type") or "—")))
fields.append(("地区", str(det.get("region") or "—")))
fields.append(("采购人", str(det.get("buyer") or "—")))
fields.append(("预算", ("%.2f 万元" % budget) if budget else "未公开"))
if det.get("item_category"):
fields.append(("品目", str(det.get("item_category"))))
if det.get("project_code"):
fields.append(("项目编号", str(det.get("project_code"))))
if det.get("agency"):
fields.append(("代理机构", str(det.get("agency"))))
if det.get("contact") or det.get("contact_phone"):
fields.append(("联系人", ("%s %s" % (str(det.get("contact") or ""),
str(det.get("contact_phone") or ""))).strip()))
else:
fields.append(("需求类型", str(det.get("notice_type") or "—")))
if det.get("item_category"):
fields.append(("站内类目", str(det.get("item_category"))))
fields.append(("赏金/预算", ("%.2f 万元" % budget) if budget else "未公开"))
if det.get("ai_score"):
fields.append(("AI相关度", str(det.get("ai_score"))))
field_rows = []
for k, v in fields:
field_rows.append({
"widgettype": "HBox",
"options": {"width": "100%", "gap": "8px", "padding": "2px 0"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": k, "cfontsize": 0.85,
"color": "#64748b", "halign": "left", "width": "90px"}},
{"widgettype": "Text", "options": {"text": v, "cfontsize": 0.9,
"color": "#0f172a", "halign": "left", "wrap": True}}
]
})
body.append({"widgettype": "VBox", "options": {
"width": "100%", "padding": "8px 12px", "gap": "2px",
"border": "1px solid #e2e8f0", "borderRadius": "8px",
"margin": "0 0 10px 0", "bgcolor": "#f8fafc"},
"subwidgets": field_rows})
# ── 来源说明(来源写进说明,不做来源按钮;2026-09-08 用户定夺)──
src_name = source_text(str(det.get("source") or ""), str(det.get("source_url") or ""))
src_lines = ["来源:" + src_name + "(数据爬取平台采集)"]
url = str(det.get("url") or "")
src_url = str(det.get("source_url") or "")
src_note = "|".join(src_lines)
link_btns = []
if url:
link_btns.append({"widgettype": "Button", "options": {"label": "打开原文页", "css": "small"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self",
"script": "window.open(" + _json.dumps(url) + ",'_blank','noopener');"}]})
if src_url:
link_btns.append({"widgettype": "Button", "options": {"label": "打开采集来源页", "css": "small"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self",
"script": "window.open(" + _json.dumps(src_url) + ",'_blank','noopener');"}]})
body.append({
"widgettype": "HBox",
"options": {"width": "100%", "gap": "10px", "alignItems": "center",
"margin": "0 0 10px 0"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": src_note,
"cfontsize": 0.8, "color": "#64748b", "halign": "left", "wrap": True}},
{"widgettype": "Filler"}
] + link_btns
})
# ── 公告/需求全文(爬取平台保存的正文)──
detail_text = str(det.get("detail_text") or "").strip()
body.append({"widgettype": "Text", "options": {
"text": "全文内容", "cfontsize": 0.95, "color": "#0f172a",
"halign": "left", "margin": "0 0 4px 0"}})
if detail_text:
shown = detail_text[:8000]
if len(detail_text) > 8000:
shown += "\n……(全文 %d 字,已截断显示,完整内容见原文页)" % len(detail_text)
body.append({"widgettype": "Text", "options": {
"text": shown, "cfontsize": 0.85, "color": "#334155",
"halign": "left", "wrap": True, "whiteSpace": "pre-wrap",
"padding": "8px 12px", "border": "1px solid #e2e8f0",
"borderRadius": "8px", "margin": "0 0 10px 0", "bgcolor": "#ffffff"}})
else:
body.append({"widgettype": "Text", "options": {
"text": "爬取平台未保存该条全文,可点「打开原文页」查看。",
"cfontsize": 0.82, "color": "#94a3b8", "halign": "left",
"margin": "0 0 10px 0"}})
# ── 附件区(有附件则逐个可点击下载)──
body.append({"widgettype": "Text", "options": {
"text": "附件(%d)" % (len(atts) if att_ok else 0),
"cfontsize": 0.95, "color": "#0f172a", "halign": "left",
"margin": "0 0 4px 0"}})
if not att_ok:
# 附件端点未就绪(爬虫平台附件子系统部署中)——如实提示,不静默
body.append({"widgettype": "Text", "options": {
"text": "附件服务暂不可用:" + str(atts)[:120],
"cfontsize": 0.8, "color": "#94a3b8", "halign": "left",
"margin": "0 0 10px 0"}})
elif not atts:
body.append({"widgettype": "Text", "options": {
"text": "该条暂无附件", "cfontsize": 0.8, "color": "#94a3b8",
"halign": "left", "margin": "0 0 10px 0"}})
else:
for a in atts:
a_name = str(a.get("name") or "附件")
a_url = str(a.get("url") or "")
a_id = str(a.get("id") or "")
a_size = int(a.get("size") or 0)
size_txt = ""
if a_size >= 1048576:
size_txt = "%.1f MB" % (a_size / 1048576.0)
elif a_size > 0:
size_txt = "%.0f KB" % (a_size / 1024.0)
dl_query = "?tender_id=" + item_id
if a_id:
dl_query += "&att_id=" + a_id
elif a_url:
dl_query += "&att_url=" + a_url
dl_href = dl_base + dl_query
body.append({
"widgettype": "HBox",
"options": {"width": "100%", "gap": "10px", "alignItems": "center",
"padding": "6px 12px", "border": "1px solid #e2e8f0",
"borderRadius": "8px", "margin": "0 0 6px 0", "bgcolor": "#ffffff"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": "📎 " + a_name,
"cfontsize": 0.88, "color": "#0f172a", "halign": "left",
"wrap": True}},
{"widgettype": "Text", "options": {"text": size_txt,
"cfontsize": 0.75, "color": "#94a3b8"}},
{"widgettype": "Filler"},
{"widgettype": "Button", "options": {"label": "下载", "css": "small"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self",
"script": "window.location.href=" + _json.dumps(dl_href) + ";"}]}
]
})
return {
"widgettype": "PopupWindow",
"id": "opp_item_detail_pw",
"options": {"title": "数据项详情", "width": "70%", "height": "85%",
"auto_open": True, "resizable": True},
"subwidgets": [{
"widgettype": "VBox",
"options": {"css": "filler", "width": "100%", "height": "100%",
"padding": "12px 16px", "gap": "0px", "overflow": "auto"},
"subwidgets": body
}]
}

View File

@ -1,5 +1,7 @@
# opp_overseas_items.dspy - 某海外需求主题的分项列表(references 可查证)
# 入参:category=<海外主题名>。每条分项:标题+时间+来源平台 + 「需求详情」按钮(新窗口)
# 入参:category=<海外主题名>。交互(2026-09-08 用户定夺):
# 每条分项整行点击 → opp_item_detail.dspy 弹出具体内容(爬取平台数据+附件下载);
# 来源写进每条说明文字(来源:Freelancer.com / PeoplePerHour),不再渲染来源按钮。
import json as _json
@ -14,14 +16,14 @@ if not category:
dbname = get_module_dbname('pipeline-opportunity')
detail_url = entire_url("/pipeline-opportunity/api/opp_item_detail.dspy")
async with DBPools().sqlorContext(dbname) as sor:
from pipeline_opportunity.opp_data_capability import overseas_references
from pipeline_opportunity.opp_common import source_button_widget
from pipeline_opportunity.opp_common import source_text
ok, res = await overseas_references(sor, category, days=days, limit=500)
await sor.sqlExe("COMMIT", {})
SRC_NAME = {"freelancer": "Freelancer.com", "pph": "PeoplePerHour"}
rows = []
if not ok:
rows.append({"widgettype": "Text", "options": {
@ -33,41 +35,48 @@ else:
rows.append({"widgettype": "Text", "options": {
"text": "该主题近 %d 天无海外分项" % days, "color": "#64748b", "padding": "12px", "halign": "left"}})
for it in items:
url = str(it.get("url") or "")
src = SRC_NAME.get(str(it.get("source") or ""), str(it.get("source") or "海外"))
sub_btns = []
if url:
sub_btns.append({"widgettype": "Button", "options": {"label": "需求详情", "css": "small"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self",
"script": "window.open(" + _json.dumps(url) + ",'_blank','noopener');"}]})
# 来源:显示来源名称,点击直跳来源 url(2026-09-08 用户定夺)
_sb = source_button_widget(str(it.get("source") or ""), str(it.get("source_url") or ""))
if _sb:
sub_btns.append(_sb)
item_id = str(it.get("id") or "")
budget = it.get("budget_wan") or 0
meta = "%s | %s | %s" % (
# 来源写进说明文字(不再做来源按钮)
src_name = source_text(str(it.get("source") or ""), str(it.get("source_url") or ""))
meta = "%s | %s | 来源:%s" % (
str(it.get("publish_time") or "")[:10],
src,
(("预算 %.2f 万(折人民币)" % budget) if budget else "预算未公开"))
rows.append({
"widgettype": "VBox",
"options": {"width": "100%", "padding": "8px 12px", "gap": "4px",
"border": "1px solid #e2e8f0", "borderRadius": "8px",
"margin": "0 0 6px 0", "bgcolor": "#ffffff"},
"subwidgets": [
{"widgettype": "Text", "options": {
"text": str(it.get("title") or ""), "cfontsize": 0.95,
"color": "#0f172a", "halign": "left", "wrap": True}},
{"widgettype": "HBox", "options": {"width": "100%", "gap": "10px",
"alignItems": "center"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": meta,
"cfontsize": 0.75, "color": "#94a3b8", "halign": "left"}},
{"widgettype": "Filler"}
] + sub_btns}
]
})
(("预算 %.2f 万(折人民币)" % budget) if budget else "预算未公开"),
src_name)
row_subs = [
{"widgettype": "Text", "options": {
"text": str(it.get("title") or ""), "cfontsize": 0.95,
"color": "#0f172a", "halign": "left", "wrap": True}},
{"widgettype": "HBox", "options": {"width": "100%", "gap": "10px",
"alignItems": "center"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": meta,
"cfontsize": 0.75, "color": "#94a3b8", "halign": "left",
"wrap": True}},
{"widgettype": "Filler"},
{"widgettype": "Text", "options": {"text": "›",
"cfontsize": 1.0, "color": "#94a3b8"}}
]
}
]
row_opts = {"width": "100%", "padding": "8px 12px", "gap": "4px",
"border": "1px solid #e2e8f0", "borderRadius": "8px",
"margin": "0 0 6px 0", "bgcolor": "#ffffff"}
if item_id:
row_opts["cursor"] = "pointer"
script = ("var old=bricks.getWidgetById('opp_item_detail_pw',bricks.app);"
"if(old&&old.destroy){old.destroy();}"
"var rp=await fetch(" + _json.dumps(detail_url) + "+'?id='+encodeURIComponent(" + _json.dumps(item_id) + "));"
"var d=await rp.json();"
"if(d){bricks.widgetBuild(d,bricks.app);}")
rows.append({
"widgettype": "VBox", "options": row_opts,
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self", "script": script}],
"subwidgets": row_subs})
else:
rows.append({"widgettype": "VBox", "options": row_opts,
"subwidgets": row_subs})
total = res.get("matched", len(rows)) if ok else 0
return {
@ -81,7 +90,7 @@ return {
"padding": "12px 16px", "gap": "0px", "overflow": "auto"},
"subwidgets": [
{"widgettype": "Text", "options": {
"text": "每条「需求详情」为海外众包平台原始需求页;来源按钮显示平台名称、点击直达平台主页,在新窗口打开,可逐条查证。",
"text": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。",
"cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}
] + rows
}]

View File

@ -1,5 +1,7 @@
# opp_references_items.dspy - 某软件主题的分项列表(references 可查证)
# 入参:category=<主题名>。每条分项:标题+时间+预算+地区 + 「公告原文」「来源页」按钮(新窗口)
# 入参:category=<主题名>。交互(2026-09-08 用户定夺):
# 每条分项整行点击 → opp_item_detail.dspy 弹出具体内容(爬取平台全文+附件下载);
# 来源写进每条说明文字(来源:xxx),不再渲染来源按钮。
import json as _json
@ -14,9 +16,11 @@ if not category:
dbname = get_module_dbname('pipeline-opportunity')
detail_url = entire_url("/pipeline-opportunity/api/opp_item_detail.dspy")
async with DBPools().sqlorContext(dbname) as sor:
from pipeline_opportunity.opp_data_capability import category_references
from pipeline_opportunity.opp_common import source_button_widget
from pipeline_opportunity.opp_common import source_text
ok, res = await category_references(sor, category, days=days, limit=500)
await sor.sqlExe("COMMIT", {})
@ -31,41 +35,53 @@ else:
rows.append({"widgettype": "Text", "options": {
"text": "该主题近 %d 天无分项" % days, "color": "#64748b", "padding": "12px", "halign": "left"}})
for it in items:
url = str(it.get("url") or "")
src = str(it.get("source_url") or "")
sub_btns = []
if url:
sub_btns.append({"widgettype": "Button", "options": {"label": "公告原文", "css": "small"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self",
"script": "window.open(" + _json.dumps(url) + ",'_blank','noopener');"}]})
# 来源:显示来源名称,点击直跳来源 url(2026-09-08 用户定夺)
_sb = source_button_widget(str(it.get("source") or ""), src)
if _sb:
sub_btns.append(_sb)
item_id = str(it.get("id") or "")
budget = it.get("budget_wan") or 0
meta = "%s | %s | %s" % (
# 来源写进说明文字(不再做来源按钮)
src_name = source_text(str(it.get("source") or ""), str(it.get("source_url") or ""))
meta = "%s | %s | %s | 来源:%s" % (
str(it.get("publish_time") or "")[:10],
str(it.get("region") or "—"),
(("预算 %.0f 万" % budget) if budget else "预算未公开"))
rows.append({
"widgettype": "VBox",
"options": {"width": "100%", "padding": "8px 12px", "gap": "4px",
"border": "1px solid #e2e8f0", "borderRadius": "8px",
"margin": "0 0 6px 0", "bgcolor": "#ffffff"},
"subwidgets": [
{"widgettype": "Text", "options": {
"text": str(it.get("title") or ""), "cfontsize": 0.95,
"color": "#0f172a", "halign": "left", "wrap": True}},
{"widgettype": "HBox", "options": {"width": "100%", "gap": "10px",
"alignItems": "center"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": meta,
"cfontsize": 0.75, "color": "#94a3b8", "halign": "left"}},
{"widgettype": "Filler"}
] + sub_btns}
]
})
(("预算 %.0f 万" % budget) if budget else "预算未公开"),
src_name)
att_cnt = int(it.get("attachment_count") or 0)
row_subs = [
{"widgettype": "Text", "options": {
"text": str(it.get("title") or ""), "cfontsize": 0.95,
"color": "#0f172a", "halign": "left", "wrap": True}},
{"widgettype": "HBox", "options": {"width": "100%", "gap": "10px",
"alignItems": "center"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": meta,
"cfontsize": 0.75, "color": "#94a3b8", "halign": "left",
"wrap": True}},
{"widgettype": "Filler"}
] + ([{"widgettype": "Text", "options": {
"text": "📎%d" % att_cnt, "cfontsize": 0.75, "color": "#0ea5e9"}}]
if att_cnt else []) + [
{"widgettype": "Text", "options": {"text": "›",
"cfontsize": 1.0, "color": "#94a3b8"}}]
}
]
row_opts = {"width": "100%", "padding": "8px 12px", "gap": "4px",
"border": "1px solid #e2e8f0", "borderRadius": "8px",
"margin": "0 0 6px 0", "bgcolor": "#ffffff"}
if item_id:
row_opts["cursor"] = "pointer"
script = ("var old=bricks.getWidgetById('opp_item_detail_pw',bricks.app);"
"if(old&&old.destroy){old.destroy();}"
"var rp=await fetch(" + _json.dumps(detail_url) + "+'?id='+encodeURIComponent(" + _json.dumps(item_id) + "));"
"var d=await rp.json();"
"if(d){bricks.widgetBuild(d,bricks.app);}")
rows.append({
"widgettype": "VBox", "options": row_opts,
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self", "script": script}],
"subwidgets": row_subs})
else:
# 无 id 的记录(异常数据)不可点,如实降级为静态行
rows.append({"widgettype": "VBox", "options": row_opts,
"subwidgets": row_subs})
total = res.get("count", len(rows)) if ok else 0
return {
@ -79,7 +95,7 @@ return {
"padding": "12px 16px", "gap": "0px", "overflow": "auto"},
"subwidgets": [
{"widgettype": "Text", "options": {
"text": "每条「公告原文」为招标公告原链接;来源按钮显示数据来源名称、点击直达该条采集来源页,均在新窗口打开,可逐条查证。",
"text": "点击任一条目弹出具体内容(公告全文来自数据爬取平台,有附件的可直接下载);每条说明中已标注数据来源。",
"cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}
] + rows
}]

View File

@ -1,7 +1,8 @@
# opp_references_popup.dspy - 数据参考(references):主题列表(agent_menu_open 弹窗内嵌内容)
# 点主题「查看分项」→ 对应分项列表(每条带来源,点击新窗口查证):
# 交互(2026-09-08 用户定夺):每个汇总主题条目整行点击 → 弹窗显示分项/明细项:
# 招标主题 → opp_references_items.dspy;国内众包需求主题 → opp_demand_items.dspy;
# 海外众包需求主题 → opp_overseas_items.dspy
# (原「查看分项」按钮已撤,整行可点,cursor:pointer 提示)
import json as _json
@ -24,6 +25,42 @@ async with DBPools().sqlorContext(dbname) as sor:
ok_ov, res_ov = await hot_overseas(sor, days=30, top=10)
await sor.sqlExe("COMMIT", {})
def _topic_row(cat, cnt, budget, target_url, popup_id, extra_txt=""):
"""汇总主题行:整行点击 → 弹出该主题的分项明细列表。"""
script = ("var old=bricks.getWidgetById(" + _json.dumps(popup_id) + ",bricks.app);"
"if(old&&old.destroy){old.destroy();}"
"var rp=await fetch(" + _json.dumps(target_url) + "+'?category='+encodeURIComponent(" + _json.dumps(cat) + "));"
"var d=await rp.json();"
"if(d){bricks.widgetBuild(d,bricks.app);}")
subs = [
{"widgettype": "Text", "options": {"text": cat, "cfontsize": 1.0,
"color": "#0f172a", "halign": "left"}},
{"widgettype": "Filler"},
{"widgettype": "Text", "options": {"text": "%d 条" % cnt,
"cfontsize": 0.85, "color": "#64748b"}},
]
if extra_txt:
subs.append({"widgettype": "Text", "options": {"text": extra_txt,
"cfontsize": 0.75, "color": "#94a3b8"}})
else:
subs.append({"widgettype": "Text", "options": {
"text": "预算 %s 万" % ("{:,.0f}".format(budget) if budget else "—"),
"cfontsize": 0.85, "color": "#64748b"}})
subs.append({"widgettype": "Text", "options": {"text": "›",
"cfontsize": 1.1, "color": "#94a3b8"}})
return {
"widgettype": "HBox",
"options": {"width": "100%", "gap": "10px", "alignItems": "center",
"padding": "8px 14px", "border": "1px solid #e2e8f0",
"borderRadius": "8px", "margin": "0 0 8px 0", "bgcolor": "#ffffff",
"cursor": "pointer"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self", "script": script}],
"subwidgets": subs
}
rows = []
if not ok:
rows.append({"widgettype": "Text", "options": {
@ -34,33 +71,9 @@ else:
if not ranking:
rows.append({"widgettype": "Text", "options": {
"text": "近30天暂无招标数据", "color": "#64748b", "padding": "12px", "halign": "left"}})
for i, r in enumerate(ranking):
cat = str(r.get("category", ""))
cnt = r.get("tender_count", 0)
budget = r.get("total_budget_wan", 0)
script = ("var old=bricks.getWidgetById('opp_refs_items_pw',bricks.app);"
"if(old&&old.destroy){old.destroy();}"
"var rp=await fetch(" + _json.dumps(items_url) + "+'?category='+encodeURIComponent(" + _json.dumps(cat) + "));"
"var d=await rp.json();"
"if(d){bricks.widgetBuild(d,bricks.app);}")
rows.append({
"widgettype": "HBox",
"options": {"width": "100%", "gap": "10px", "alignItems": "center",
"padding": "8px 14px", "border": "1px solid #e2e8f0",
"borderRadius": "8px", "margin": "0 0 8px 0", "bgcolor": "#ffffff"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": cat, "cfontsize": 1.0,
"color": "#0f172a", "halign": "left"}},
{"widgettype": "Filler"},
{"widgettype": "Text", "options": {"text": "%d 条" % cnt,
"cfontsize": 0.85, "color": "#64748b"}},
{"widgettype": "Text", "options": {"text": "预算 %s 万" % ("{:,.0f}".format(budget) if budget else "—"),
"cfontsize": 0.85, "color": "#64748b"}},
{"widgettype": "Button", "options": {"label": "查看分项", "css": "small"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self", "script": script}]}
]
})
for r in ranking:
rows.append(_topic_row(str(r.get("category", "")), r.get("tender_count", 0),
r.get("total_budget_wan", 0), items_url, "opp_refs_items_pw"))
# ── 国内众包需求板块(猪八戒/一品威客)──
dm_rows = []
@ -73,32 +86,8 @@ if ok_dm:
"margin": "14px 0 6px 0", "padding": "6px 0 0 0",
"border": "1px solid #f1f5f9", "borderRadius": "6px", "bgcolor": "#f8fafc"}})
for r in dm_ranking:
cat = str(r.get("category", ""))
cnt = r.get("tender_count", 0)
budget = r.get("total_budget_wan", 0)
script = ("var old=bricks.getWidgetById('opp_demand_items_pw',bricks.app);"
"if(old&&old.destroy){old.destroy();}"
"var rp=await fetch(" + _json.dumps(demand_items_url) + "+'?category='+encodeURIComponent(" + _json.dumps(cat) + "));"
"var d=await rp.json();"
"if(d){bricks.widgetBuild(d,bricks.app);}")
dm_rows.append({
"widgettype": "HBox",
"options": {"width": "100%", "gap": "10px", "alignItems": "center",
"padding": "8px 14px", "border": "1px solid #e2e8f0",
"borderRadius": "8px", "margin": "0 0 8px 0", "bgcolor": "#ffffff"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": cat, "cfontsize": 1.0,
"color": "#0f172a", "halign": "left"}},
{"widgettype": "Filler"},
{"widgettype": "Text", "options": {"text": "%d 条" % cnt,
"cfontsize": 0.85, "color": "#64748b"}},
{"widgettype": "Text", "options": {"text": "预算 %s 万" % ("{:,.0f}".format(budget) if budget else "—"),
"cfontsize": 0.85, "color": "#64748b"}},
{"widgettype": "Button", "options": {"label": "查看分项", "css": "small"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self", "script": script}]}
]
})
dm_rows.append(_topic_row(str(r.get("category", "")), r.get("tender_count", 0),
r.get("total_budget_wan", 0), demand_items_url, "opp_demand_items_pw"))
# ── 海外众包需求板块(Freelancer.com / PeoplePerHour)──
ov_rows = []
@ -111,33 +100,11 @@ if ok_ov:
"margin": "14px 0 6px 0", "padding": "6px 0 0 0",
"border": "1px solid #f1f5f9", "borderRadius": "6px", "bgcolor": "#f8fafc"}})
for r in ov_ranking:
cat = str(r.get("category", ""))
cnt = r.get("tender_count", 0)
by_src = r.get("by_source") or {}
src_txt = " / ".join("%s:%d" % (k, v) for k, v in by_src.items())
script = ("var old=bricks.getWidgetById('opp_overseas_items_pw',bricks.app);"
"if(old&&old.destroy){old.destroy();}"
"var rp=await fetch(" + _json.dumps(overseas_items_url) + "+'?category='+encodeURIComponent(" + _json.dumps(cat) + "));"
"var d=await rp.json();"
"if(d){bricks.widgetBuild(d,bricks.app);}")
ov_rows.append({
"widgettype": "HBox",
"options": {"width": "100%", "gap": "10px", "alignItems": "center",
"padding": "8px 14px", "border": "1px solid #e2e8f0",
"borderRadius": "8px", "margin": "0 0 8px 0", "bgcolor": "#ffffff"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": cat, "cfontsize": 1.0,
"color": "#0f172a", "halign": "left"}},
{"widgettype": "Filler"},
{"widgettype": "Text", "options": {"text": "%d 条" % cnt,
"cfontsize": 0.85, "color": "#64748b"}},
{"widgettype": "Text", "options": {"text": src_txt,
"cfontsize": 0.75, "color": "#94a3b8"}},
{"widgettype": "Button", "options": {"label": "查看分项", "css": "small"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self", "script": script}]}
]
})
ov_rows.append(_topic_row(str(r.get("category", "")), r.get("tender_count", 0),
r.get("total_budget_wan", 0), overseas_items_url, "opp_overseas_items_pw",
extra_txt=src_txt))
return {
"widgettype": "VBox",
@ -145,7 +112,7 @@ return {
"gap": "6px", "overflow": "auto"},
"subwidgets": [
{"widgettype": "Text", "options": {
"text": "点击「查看分项」列出该主题的全部数据项;每条数据项带来源链接(公告原文/需求详情/来源页),可逐条点开查证。",
"text": "点击任一主题条目,弹出该主题的全部分项明细;明细中再点击任一条,弹出具体内容(数据来自数据爬取平台,含公告全文与附件下载)。",
"cfontsize": 0.8, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}
] + rows + dm_rows + ov_rows
}