diff --git a/pipeline_opportunity/opp_ability.py b/pipeline_opportunity/opp_ability.py index 7a8a77f..a1265ad 100644 --- a/pipeline_opportunity/opp_ability.py +++ b/pipeline_opportunity/opp_ability.py @@ -49,9 +49,10 @@ OPP_TOOLS = [ parameters={"tender_id": "招标信息ID"}, category="data"), ToolDefinition(name="opp_crawler_stats", description="查看数据爬取平台采集健康度(总量/24h新增/最近运行)", parameters={}, category="data"), - ToolDefinition(name="opp_category_references", description="查某软件主题的分项明细(每条带公告原文URL+采集来源,供逐条查证/举例)。⚠️ 明细是「举例佐证」用的,不是「统计」用的——排名/数量/金额统计先用 opp_hot_software(已返回 tender_count+total_budget_wan),只在需要给2~5个样例链接时才调本工具且 limit≤10", + ToolDefinition(name="opp_category_references", description="查某软件主题的分项明细(每条带公告原文URL+采集来源,供逐条查证/举例;sort=budget 按标的金额大到小 / time 按发布时间后到前)。⚠️ 明细是「举例佐证」用的,不是「统计」用的——排名/数量/金额统计先用 opp_hot_software(已返回 tender_count+total_budget_wan),只在需要给2~5个样例链接时才调本工具且 limit≤10", parameters={"category": "软件主题名(须与热点排名中的category一致)", - "days": "统计窗口天数(默认30)", "limit": "条数(默认20,最大50——只取样例不要拉全量)"}, category="data"), + "days": "统计窗口天数(默认30)", "limit": "条数(默认20,最大50——只取样例不要拉全量)", + "sort": "排序(可选: budget=标的金额大到小/time=时间后到前,默认time)"}, category="data"), ToolDefinition(name="opp_demands", description="查众包平台需求明细列表(国内猪八戒zbj/一品威客epwk + 海外freelancer/pph,每条带详情页URL可查证)。⚠️ 主题排名/数量/金额统计先用 opp_hot_demands(国内)/opp_hot_overseas(海外)——它们已聚合好排名;本工具只用于按关键词找具体需求样例,limit≤10", parameters={"keyword": "标题关键词(建议必填以缩小范围)", "days": "统计窗口天数(默认30)", "source": "来源(可选: zbj猪八戒/epwk一品威客/freelancer海外/pph海外,不传=全部)", @@ -60,9 +61,10 @@ OPP_TOOLS = [ parameters={"days": "统计窗口天数(默认30)", "top": "Top条数(默认10)"}, category="data"), ToolDefinition(name="opp_hot_overseas", description="【统计首选】海外众包需求热点主题排名(Freelancer/PeoplePerHour,英文标题按8大主题归类)——直接返回各主题数量+预算合计+按来源分布,无需拉明细自己数。用户问国外/海外市场、中外众包排名时用本工具", parameters={"days": "统计窗口天数(默认30)", "top": "Top条数(默认10)"}, category="data"), - ToolDefinition(name="opp_overseas_references", description="查某海外需求主题的分项明细(每条带Freelancer/PeoplePerHour详情页URL,供逐条查证/举例)。⚠️ 统计先用 opp_hot_overseas(已返回 tender_count+total_budget_wan+by_source),只在需要2~5个样例链接时才调本工具且 limit≤10", + ToolDefinition(name="opp_overseas_references", description="查某海外需求主题的分项明细(每条带Freelancer/PeoplePerHour详情页URL,供逐条查证/举例;sort=budget 按赏金大到小 / time 按发布时间后到前)。⚠️ 统计先用 opp_hot_overseas(已返回 tender_count+total_budget_wan+by_source),只在需要2~5个样例链接时才调本工具且 limit≤10", parameters={"category": "海外主题名(须与 opp_hot_overseas 排名中的category一致)", - "days": "统计窗口天数(默认30)", "limit": "条数(默认20,最大50——只取样例不要拉全量)"}, category="data"), + "days": "统计窗口天数(默认30)", "limit": "条数(默认20,最大50——只取样例不要拉全量)", + "sort": "排序(可选: budget=赏金大到小/time=时间后到前,默认time)"}, category="data"), # ── 报告与审批(商机闭环)── ToolDefinition(name="opp_create_report", description="创建软件研发报告草稿。从热门软件中选定的方向写入报告,数据基础章节自动从爬取平台拉取。正文必须按四节模板组织(## 研发场景 / ## 输入 / ## 输出 / ## 成果),商店详情页按节展示", @@ -337,13 +339,16 @@ async def _h_category_references(sor, p, ctx): return _fmt(False, "缺少 category(软件主题名)") ok, res = await category_references( sor, category, days=int(p.get("days") or 30), - limit=min(int(p.get("limit") or 200), 1000)) + limit=min(int(p.get("limit") or 200), 1000), + sort=str(p.get("sort") or "").strip()) if not ok: return _fmt(False, res) return json.dumps({ "category": res.get("category"), "窗口": "%d天" % res.get("window_days"), "分项总数": res.get("count"), + "排序": {"budget": "标的金额大到小", "time": "发布时间后到前"}.get( + res.get("sort") or "time", "发布时间后到前"), "分项": res.get("items"), "说明": "每条分项的 url 为公告原文、source_url 为采集来源,均可点击查证", }, ensure_ascii=False, default=str) @@ -393,7 +398,8 @@ async def _h_overseas_references(sor, p, ctx): return _fmt(False, {"error": "缺少 category 参数"}) ok, res = await overseas_references( sor, category=category, days=int(p.get("days") or 30), - limit=min(int(p.get("limit") or 200), 500)) + limit=min(int(p.get("limit") or 200), 500), + sort=str(p.get("sort") or "").strip()) if not ok: return _fmt(False, res) return json.dumps(res, ensure_ascii=False, default=str) diff --git a/pipeline_opportunity/opp_data_capability.py b/pipeline_opportunity/opp_data_capability.py index c3a9cb6..c052251 100644 --- a/pipeline_opportunity/opp_data_capability.py +++ b/pipeline_opportunity/opp_data_capability.py @@ -55,17 +55,22 @@ async def hot_software(sor, days=30, top=10): {"days": days, "top": top}) -async def category_references(sor, category, days=30, limit=20, record_type="tender"): +async def category_references(sor, category, days=30, limit=20, record_type="tender", + sort=""): """某软件主题的分项明细(references 可查证,举例佐证用)。 每条带公告原文/详情页 url + 采集来源 source_url,供界面逐条查证。 record_type: tender=招标(默认) / demand=众包需求发布(猪八戒/一品威客)。 + sort: ""=缺省(时间后到前) / time=发布时间后到前 / budget=标的金额大到小。 + 排序在数据源侧全量执行后再截断(「金额前 N」是全窗口真 TopN)。 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}) + params = {"category": category, "days": days, + "limit": _clamp_detail_limit(limit), + "record_type": record_type} + if sort: + params["sort"] = sort + return await crawler_get(sor, "/api/category_references", params) async def crawler_stats(sor): @@ -99,15 +104,18 @@ async def hot_overseas(sor, days=30, top=10): {"days": days, "top": top}) -async def overseas_references(sor, category, days=30, limit=20): +async def overseas_references(sor, category, days=30, limit=20, sort=""): """某海外需求主题的分项明细(references 可查证,举例佐证用)。 每条带海外平台详情页 url,供界面逐条查证。 + sort: ""=缺省(时间后到前) / time=发布时间后到前 / budget=赏金(折人民币)大到小。 limit 硬上限 _DETAIL_LIMIT_MAX(50)——统计用 hot_overseas 聚合,勿拉全量明细。 """ - return await crawler_get(sor, "/api/overseas_references", - {"category": category, "days": days, - "limit": _clamp_detail_limit(limit)}) + params = {"category": category, "days": days, + "limit": _clamp_detail_limit(limit)} + if sort: + params["sort"] = sort + return await crawler_get(sor, "/api/overseas_references", params) async def query_tenders(sor, keyword="", region="", buyer="", notice_type="", diff --git a/wwwroot/api/opp_demand_items.dspy b/wwwroot/api/opp_demand_items.dspy index 10e4f0e..9e811e2 100644 --- a/wwwroot/api/opp_demand_items.dspy +++ b/wwwroot/api/opp_demand_items.dspy @@ -1,5 +1,7 @@ # opp_demand_items.dspy - 某软件主题的众包需求分项列表(国内猪八戒/一品威客) -# 入参:category=<主题名>。交互(2026-09-08 用户定夺): +# 入参:category=<主题名>,sort=time|budget(2026-09-15 用户需求:支持按赏金 +# 大到小 / 按时间后到前排序;排序在数据源侧全量执行后再截断,缺省 time)。 +# 交互(2026-09-08 用户定夺): # 每条分项整行点击 → opp_item_detail.dspy 弹出具体内容(爬取平台数据+附件下载); # 来源写进每条说明文字(来源:猪八戒/一品威客),不再渲染来源按钮。 @@ -13,17 +15,44 @@ category = ((params_kw or {}).get("category") or "").strip() days = int((params_kw or {}).get("days") or 30) if not category: return {"widgettype": "Message", "options": {"title": "缺少参数", "message": "缺少 category"}} +# 排序白名单收敛(非法/缺省一律 time=发布时间后到前,与历史行为一致) +sort = ((params_kw or {}).get("sort") or "time").strip() +if sort not in ("time", "budget"): + sort = "time" dbname = get_module_dbname('pipeline-opportunity') detail_url = entire_url("/pipeline-opportunity/api/opp_item_detail.dspy") +self_url = entire_url("/pipeline-opportunity/api/opp_demand_items.dspy") async with DBPools().sqlorContext(dbname) as sor: from pipeline_opportunity.opp_data_capability import category_references from pipeline_opportunity.opp_common import source_text - ok, res = await category_references(sor, category, days=days, limit=500, record_type="demand") + ok, res = await category_references(sor, category, days=days, limit=500, + record_type="demand", sort=sort) await sor.sqlExe("COMMIT", {}) +# ── 排序切换按钮(点击重建弹窗带 sort 参数;对齐 opp_mining_popup 刷新范式)── +def _sort_btn(label, target_sort, active): + script = ("var old=bricks.getWidgetById('opp_demand_items_pw',bricks.app);" + "if(old&&old.destroy){old.destroy();}" + "var rp=await fetch(" + _json.dumps(self_url) + "+'?category='+encodeURIComponent(" + + _json.dumps(category) + ")+'&days=" + str(days) + "&sort=" + target_sort + "');" + "var d=await rp.json();" + "if(d){bricks.widgetBuild(d,bricks.app);}") + return {"widgettype": "Button", + "options": {"label": label, "css": ("primary" if active else "small")}, + "binds": [{"wid": "self", "event": "click", "actiontype": "script", + "target": "self", "script": script}]} + +sort_bar = {"widgettype": "HBox", "options": {"width": "100%", "gap": "8px", + "alignItems": "center", "margin": "0 0 8px 0"}, + "subwidgets": [ + _sort_btn("💰 赏金(大到小)", "budget", sort == "budget"), + _sort_btn("⏱ 发布时间(后到前)", "time", sort == "time"), + {"widgettype": "Filler"}, + ]} + rows = [] if not ok: rows.append({"widgettype": "Text", "options": { @@ -79,16 +108,17 @@ else: "subwidgets": row_subs}) total = res.get("count", len(rows)) if ok else 0 +_sort_label = "赏金大到小" if sort == "budget" else "时间后到前" return { "widgettype": "PopupWindow", "id": "opp_demand_items_pw", - "options": {"title": "众包需求分项:%s(%d 条)" % (category, total), + "options": {"title": "众包需求分项:%s(%d 条 · %s)" % (category, total, _sort_label), "width": "80%", "height": "82%", "auto_open": True, "resizable": True}, "subwidgets": [{ "widgettype": "VBox", "options": {"css": "filler", "width": "100%", "height": "100%", "padding": "12px 16px", "gap": "0px", "overflow": "auto"}, - "subwidgets": [ + "subwidgets": [sort_bar, {"widgettype": "Text", "options": { "otext": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。", "text": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。", "i18n": True, "cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}} diff --git a/wwwroot/api/opp_overseas_items.dspy b/wwwroot/api/opp_overseas_items.dspy index 4abe9a2..ada6760 100644 --- a/wwwroot/api/opp_overseas_items.dspy +++ b/wwwroot/api/opp_overseas_items.dspy @@ -1,5 +1,7 @@ # opp_overseas_items.dspy - 某海外需求主题的分项列表(references 可查证) -# 入参:category=<海外主题名>。交互(2026-09-08 用户定夺): +# 入参:category=<海外主题名>,sort=time|budget(2026-09-15 用户需求:支持按赏金 +# (折人民币)大到小 / 按时间后到前排序;排序在数据源侧全量执行后再截断,缺省 time)。 +# 交互(2026-09-08 用户定夺): # 每条分项整行点击 → opp_item_detail.dspy 弹出具体内容(爬取平台数据+附件下载); # 来源写进每条说明文字(来源:Freelancer.com / PeoplePerHour),不再渲染来源按钮。 @@ -13,17 +15,43 @@ category = ((params_kw or {}).get("category") or "").strip() days = int((params_kw or {}).get("days") or 30) if not category: return {"widgettype": "Message", "options": {"title": "缺少参数", "message": "缺少 category"}} +# 排序白名单收敛(非法/缺省一律 time=发布时间后到前,与历史行为一致) +sort = ((params_kw or {}).get("sort") or "time").strip() +if sort not in ("time", "budget"): + sort = "time" dbname = get_module_dbname('pipeline-opportunity') detail_url = entire_url("/pipeline-opportunity/api/opp_item_detail.dspy") +self_url = entire_url("/pipeline-opportunity/api/opp_overseas_items.dspy") async with DBPools().sqlorContext(dbname) as sor: from pipeline_opportunity.opp_data_capability import overseas_references from pipeline_opportunity.opp_common import source_text - ok, res = await overseas_references(sor, category, days=days, limit=500) + ok, res = await overseas_references(sor, category, days=days, limit=500, sort=sort) await sor.sqlExe("COMMIT", {}) +# ── 排序切换按钮(点击重建弹窗带 sort 参数;对齐 opp_mining_popup 刷新范式)── +def _sort_btn(label, target_sort, active): + script = ("var old=bricks.getWidgetById('opp_overseas_items_pw',bricks.app);" + "if(old&&old.destroy){old.destroy();}" + "var rp=await fetch(" + _json.dumps(self_url) + "+'?category='+encodeURIComponent(" + + _json.dumps(category) + ")+'&days=" + str(days) + "&sort=" + target_sort + "');" + "var d=await rp.json();" + "if(d){bricks.widgetBuild(d,bricks.app);}") + return {"widgettype": "Button", + "options": {"label": label, "css": ("primary" if active else "small")}, + "binds": [{"wid": "self", "event": "click", "actiontype": "script", + "target": "self", "script": script}]} + +sort_bar = {"widgettype": "HBox", "options": {"width": "100%", "gap": "8px", + "alignItems": "center", "margin": "0 0 8px 0"}, + "subwidgets": [ + _sort_btn("💰 赏金(大到小)", "budget", sort == "budget"), + _sort_btn("⏱ 发布时间(后到前)", "time", sort == "time"), + {"widgettype": "Filler"}, + ]} + rows = [] if not ok: rows.append({"widgettype": "Text", "options": { @@ -79,16 +107,17 @@ else: "subwidgets": row_subs}) total = res.get("matched", len(rows)) if ok else 0 +_sort_label = "赏金大到小" if sort == "budget" else "时间后到前" return { "widgettype": "PopupWindow", "id": "opp_overseas_items_pw", - "options": {"title": "海外需求分项:%s(%d 条)" % (category, total), + "options": {"title": "海外需求分项:%s(%d 条 · %s)" % (category, total, _sort_label), "width": "80%", "height": "82%", "auto_open": True, "resizable": True}, "subwidgets": [{ "widgettype": "VBox", "options": {"css": "filler", "width": "100%", "height": "100%", "padding": "12px 16px", "gap": "0px", "overflow": "auto"}, - "subwidgets": [ + "subwidgets": [sort_bar, {"widgettype": "Text", "options": { "otext": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。", "text": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。", "i18n": True, "cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}} diff --git a/wwwroot/api/opp_references_items.dspy b/wwwroot/api/opp_references_items.dspy index 5577fa5..57b2e4a 100644 --- a/wwwroot/api/opp_references_items.dspy +++ b/wwwroot/api/opp_references_items.dspy @@ -1,5 +1,7 @@ # opp_references_items.dspy - 某软件主题的分项列表(references 可查证) -# 入参:category=<主题名>。交互(2026-09-08 用户定夺): +# 入参:category=<主题名>,sort=time|budget(2026-09-15 用户需求:支持按标的金额 +# 大到小 / 按时间后到前排序;排序在数据源侧全量执行后再截断,缺省 time)。 +# 交互(2026-09-08 用户定夺): # 每条分项整行点击 → opp_item_detail.dspy 弹出具体内容(爬取平台全文+附件下载); # 来源写进每条说明文字(来源:xxx),不再渲染来源按钮。 @@ -13,17 +15,43 @@ category = ((params_kw or {}).get("category") or "").strip() days = int((params_kw or {}).get("days") or 30) if not category: return {"widgettype": "Message", "options": {"title": "缺少参数", "message": "缺少 category"}} +# 排序白名单收敛(非法/缺省一律 time=发布时间后到前,与历史行为一致) +sort = ((params_kw or {}).get("sort") or "time").strip() +if sort not in ("time", "budget"): + sort = "time" dbname = get_module_dbname('pipeline-opportunity') detail_url = entire_url("/pipeline-opportunity/api/opp_item_detail.dspy") +self_url = entire_url("/pipeline-opportunity/api/opp_references_items.dspy") async with DBPools().sqlorContext(dbname) as sor: from pipeline_opportunity.opp_data_capability import category_references from pipeline_opportunity.opp_common import source_text - ok, res = await category_references(sor, category, days=days, limit=500) + ok, res = await category_references(sor, category, days=days, limit=500, sort=sort) await sor.sqlExe("COMMIT", {}) +# ── 排序切换按钮(点击重建弹窗带 sort 参数;对齐 opp_mining_popup 刷新范式)── +def _sort_btn(label, target_sort, active): + script = ("var old=bricks.getWidgetById('opp_refs_items_pw',bricks.app);" + "if(old&&old.destroy){old.destroy();}" + "var rp=await fetch(" + _json.dumps(self_url) + "+'?category='+encodeURIComponent(" + + _json.dumps(category) + ")+'&days=" + str(days) + "&sort=" + target_sort + "');" + "var d=await rp.json();" + "if(d){bricks.widgetBuild(d,bricks.app);}") + return {"widgettype": "Button", + "options": {"label": label, "css": ("primary" if active else "small")}, + "binds": [{"wid": "self", "event": "click", "actiontype": "script", + "target": "self", "script": script}]} + +sort_bar = {"widgettype": "HBox", "options": {"width": "100%", "gap": "8px", + "alignItems": "center", "margin": "0 0 8px 0"}, + "subwidgets": [ + _sort_btn("💰 标的金额(大到小)", "budget", sort == "budget"), + _sort_btn("⏱ 发布时间(后到前)", "time", sort == "time"), + {"widgettype": "Filler"}, + ]} + rows = [] if not ok: rows.append({"widgettype": "Text", "options": { @@ -84,16 +112,17 @@ else: "subwidgets": row_subs}) total = res.get("count", len(rows)) if ok else 0 +_sort_label = "标的金额大到小" if sort == "budget" else "时间后到前" return { "widgettype": "PopupWindow", "id": "opp_refs_items_pw", - "options": {"title": "分项明细:%s(%d 条)" % (category, total), + "options": {"title": "分项明细:%s(%d 条 · %s)" % (category, total, _sort_label), "width": "80%", "height": "82%", "auto_open": True, "resizable": True}, "subwidgets": [{ "widgettype": "VBox", "options": {"css": "filler", "width": "100%", "height": "100%", "padding": "12px 16px", "gap": "0px", "overflow": "auto"}, - "subwidgets": [ + "subwidgets": [sort_bar, {"widgettype": "Text", "options": { "otext": "点击任一条目弹出具体内容(公告全文来自数据爬取平台,有附件的可直接下载);每条说明中已标注数据来源。", "text": "点击任一条目弹出具体内容(公告全文来自数据爬取平台,有附件的可直接下载);每条说明中已标注数据来源。", "i18n": True, "cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}