- 汇总主题/分项条目整行点击(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 同步新交互描述
76 lines
3.2 KiB
Plaintext
76 lines
3.2 KiB
Plaintext
# 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])}}
|