scm-crawler-mcp-server 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scm_crawler_mcp_server-1.0.0/.env +5 -0
- scm_crawler_mcp_server-1.0.0/PKG-INFO +94 -0
- scm_crawler_mcp_server-1.0.0/README.md +84 -0
- scm_crawler_mcp_server-1.0.0/pyproject.toml +19 -0
- scm_crawler_mcp_server-1.0.0/src/scm_crawler_mcp_server/__init__.py +7 -0
- scm_crawler_mcp_server-1.0.0/src/scm_crawler_mcp_server/server.py +330 -0
- scm_crawler_mcp_server-1.0.0/uv.lock +2332 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: scm-crawler-mcp-server
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: 中化采购平台公告抓取 MCP 服务
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: fastmcp>=0.2.0
|
|
7
|
+
Requires-Dist: openpyxl>=3.1.0
|
|
8
|
+
Requires-Dist: oss2>=2.18.0
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# 中化采购平台公告抓取 MCP 服务
|
|
12
|
+
|
|
13
|
+
一个基于 [FastMCP](https://github.com/jlowin/fastmcp) 的 MCP 服务,用于抓取中化采购供应链平台(scm.esinochem.com)的公告信息,按关键字筛选近 7 天数据,生成 Excel 并上传至阿里云 OSS,返回下载链接。
|
|
14
|
+
|
|
15
|
+
## 功能
|
|
16
|
+
|
|
17
|
+
- 抓取三个类别:招标公告、资格预审公告、采购公告
|
|
18
|
+
- 按标题搜索关键字:外包、派遣、招聘、猎头、培训
|
|
19
|
+
- 自动去重,保留命中关键字列表
|
|
20
|
+
- 过滤近 7 天发布且需求类型为"服务"的公告
|
|
21
|
+
- 生成格式化的多 Sheet Excel 文件
|
|
22
|
+
- 上传至阿里云 OSS,返回 7 天有效的签名下载链接
|
|
23
|
+
- 作为 MCP 工具 `fetch_notices` 供智能体调用
|
|
24
|
+
|
|
25
|
+
## 安装
|
|
26
|
+
|
|
27
|
+
本项目通过 PyPI 发布,可直接使用 `uvx` 运行:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uvx scm-crawler-mcp-server
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
或在百炼 MCP 配置中引用:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"command": "uvx",
|
|
38
|
+
"args": ["scm-crawler-mcp-server"]
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## 环境变量
|
|
43
|
+
|
|
44
|
+
运行前需配置以下 OSS 环境变量(百炼部署时通过 MCP 配置的 `env` 注入):
|
|
45
|
+
|
|
46
|
+
| 变量名 | 说明 | 示例 |
|
|
47
|
+
| -------- | ------ | ------ |
|
|
48
|
+
| `OSS_ENDPOINT` | OSS 端点(不带协议前缀) | `oss-cn-shanghai.aliyuncs.com` |
|
|
49
|
+
| `OSS_ACCESS_KEY_ID` | 阿里云 AccessKey ID | `LTAI5t...` |
|
|
50
|
+
| `OSS_ACCESS_KEY_SECRET` | 阿里云 AccessKey Secret | `dwNTj...` |
|
|
51
|
+
| `OSS_BUCKET_NAME` | Bucket 名称 | `gi-gicnisys-dev` |
|
|
52
|
+
| `OSS_PREFIX` | 上传目录前缀(可选) | `scm-reports/` |
|
|
53
|
+
|
|
54
|
+
## 使用
|
|
55
|
+
|
|
56
|
+
在支持 MCP 的客户端(如 Cline、百炼智能体)中调用工具 `fetch_notices`,无参数,返回 JSON:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"status": "success",
|
|
61
|
+
"file_name": "中化采购平台_公告关键字搜索结果_近7天_2026-09-14.xlsx",
|
|
62
|
+
"oss_url": "https://...",
|
|
63
|
+
"date": "2026-09-14",
|
|
64
|
+
"categories": {
|
|
65
|
+
"招标公告": {"count": 3, "keyword_hits": {"外包": 2, "培训": 1}},
|
|
66
|
+
"采购公告": {"count": 5, "keyword_hits": {"招聘": 4, "培训": 1}}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## 本地开发
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# 创建虚拟环境
|
|
75
|
+
uv venv
|
|
76
|
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
77
|
+
|
|
78
|
+
# 安装依赖
|
|
79
|
+
uv add fastmcp openpyxl oss2
|
|
80
|
+
|
|
81
|
+
# 运行服务
|
|
82
|
+
uv run python -m scm_crawler_mcp_server.server
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## 依赖
|
|
86
|
+
|
|
87
|
+
- Python >= 3.10
|
|
88
|
+
- fastmcp >= 0.2.0
|
|
89
|
+
- openpyxl >= 3.1.0
|
|
90
|
+
- oss2 >= 2.18.0
|
|
91
|
+
|
|
92
|
+
## 许可证
|
|
93
|
+
|
|
94
|
+
MIT
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# 中化采购平台公告抓取 MCP 服务
|
|
2
|
+
|
|
3
|
+
一个基于 [FastMCP](https://github.com/jlowin/fastmcp) 的 MCP 服务,用于抓取中化采购供应链平台(scm.esinochem.com)的公告信息,按关键字筛选近 7 天数据,生成 Excel 并上传至阿里云 OSS,返回下载链接。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
- 抓取三个类别:招标公告、资格预审公告、采购公告
|
|
8
|
+
- 按标题搜索关键字:外包、派遣、招聘、猎头、培训
|
|
9
|
+
- 自动去重,保留命中关键字列表
|
|
10
|
+
- 过滤近 7 天发布且需求类型为"服务"的公告
|
|
11
|
+
- 生成格式化的多 Sheet Excel 文件
|
|
12
|
+
- 上传至阿里云 OSS,返回 7 天有效的签名下载链接
|
|
13
|
+
- 作为 MCP 工具 `fetch_notices` 供智能体调用
|
|
14
|
+
|
|
15
|
+
## 安装
|
|
16
|
+
|
|
17
|
+
本项目通过 PyPI 发布,可直接使用 `uvx` 运行:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
uvx scm-crawler-mcp-server
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
或在百炼 MCP 配置中引用:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"command": "uvx",
|
|
28
|
+
"args": ["scm-crawler-mcp-server"]
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## 环境变量
|
|
33
|
+
|
|
34
|
+
运行前需配置以下 OSS 环境变量(百炼部署时通过 MCP 配置的 `env` 注入):
|
|
35
|
+
|
|
36
|
+
| 变量名 | 说明 | 示例 |
|
|
37
|
+
| -------- | ------ | ------ |
|
|
38
|
+
| `OSS_ENDPOINT` | OSS 端点(不带协议前缀) | `oss-cn-shanghai.aliyuncs.com` |
|
|
39
|
+
| `OSS_ACCESS_KEY_ID` | 阿里云 AccessKey ID | `LTAI5t...` |
|
|
40
|
+
| `OSS_ACCESS_KEY_SECRET` | 阿里云 AccessKey Secret | `dwNTj...` |
|
|
41
|
+
| `OSS_BUCKET_NAME` | Bucket 名称 | `gi-gicnisys-dev` |
|
|
42
|
+
| `OSS_PREFIX` | 上传目录前缀(可选) | `scm-reports/` |
|
|
43
|
+
|
|
44
|
+
## 使用
|
|
45
|
+
|
|
46
|
+
在支持 MCP 的客户端(如 Cline、百炼智能体)中调用工具 `fetch_notices`,无参数,返回 JSON:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"status": "success",
|
|
51
|
+
"file_name": "中化采购平台_公告关键字搜索结果_近7天_2026-09-14.xlsx",
|
|
52
|
+
"oss_url": "https://...",
|
|
53
|
+
"date": "2026-09-14",
|
|
54
|
+
"categories": {
|
|
55
|
+
"招标公告": {"count": 3, "keyword_hits": {"外包": 2, "培训": 1}},
|
|
56
|
+
"采购公告": {"count": 5, "keyword_hits": {"招聘": 4, "培训": 1}}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## 本地开发
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# 创建虚拟环境
|
|
65
|
+
uv venv
|
|
66
|
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
67
|
+
|
|
68
|
+
# 安装依赖
|
|
69
|
+
uv add fastmcp openpyxl oss2
|
|
70
|
+
|
|
71
|
+
# 运行服务
|
|
72
|
+
uv run python -m scm_crawler_mcp_server.server
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## 依赖
|
|
76
|
+
|
|
77
|
+
- Python >= 3.10
|
|
78
|
+
- fastmcp >= 0.2.0
|
|
79
|
+
- openpyxl >= 3.1.0
|
|
80
|
+
- oss2 >= 2.18.0
|
|
81
|
+
|
|
82
|
+
## 许可证
|
|
83
|
+
|
|
84
|
+
MIT
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "scm-crawler-mcp-server" # 包名(PyPI 上必须唯一)
|
|
3
|
+
version = "1.0.0" # 版本号(每次更新必须递增)
|
|
4
|
+
description = "中化采购平台公告抓取 MCP 服务" # 包描述
|
|
5
|
+
readme = "README.md" # README 文件
|
|
6
|
+
requires-python = ">=3.10" # Python 版本要求(百炼推荐 3.10)
|
|
7
|
+
|
|
8
|
+
dependencies = [ # 运行时依赖
|
|
9
|
+
"fastmcp>=0.2.0", # MCP 框架
|
|
10
|
+
"openpyxl>=3.1.0", # Excel 生成
|
|
11
|
+
"oss2>=2.18.0", # 阿里云 OSS SDK
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts] # 声明命令行入口(百炼 uvx 调用此入口)
|
|
15
|
+
scm-crawler-mcp-server = "scm_crawler_mcp_server:main" # 入口函数
|
|
16
|
+
|
|
17
|
+
[build-system] # 构建系统配置
|
|
18
|
+
requires = ["hatchling"] # 使用 hatchling 构建
|
|
19
|
+
build-backend = "hatchling.build" # 构建后端
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
中化采购平台公告抓取 MCP Server
|
|
5
|
+
功能:抓取平台公告 → 生成 Excel → 上传 OSS → 返回下载链接
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json # JSON 序列化
|
|
9
|
+
import os # 环境变量读取
|
|
10
|
+
import sys # 标准错误输出(日志)
|
|
11
|
+
import urllib.request # HTTP 请求
|
|
12
|
+
import urllib.error # HTTP 异常处理
|
|
13
|
+
from datetime import datetime, timedelta # 日期计算
|
|
14
|
+
import uuid # 生成唯一文件名
|
|
15
|
+
import oss2 # 阿里云 OSS SDK
|
|
16
|
+
from fastmcp import FastMCP # MCP 框架
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ==================== 全局配置 ====================
|
|
20
|
+
|
|
21
|
+
KEYWORDS = ["外包", "派遣", "招聘", "猎头", "培训"] # 搜索关键字
|
|
22
|
+
|
|
23
|
+
CATEGORIES = [ # 公告类别
|
|
24
|
+
("招标公告", "01", "Bid"), # 招标公告
|
|
25
|
+
("资格预审公告", "02", "Bid"), # 资格预审公告
|
|
26
|
+
("采购公告", "01", "UnBid"), # 采购公告
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
API_URL = "https://scm.esinochem.com/gateway/obs/business/notice/outer/page/queryPageList"
|
|
30
|
+
|
|
31
|
+
PAGE_SIZE = 50 # 每页记录数
|
|
32
|
+
REQUEST_TIMEOUT = 40 # 请求超时(秒)
|
|
33
|
+
MAX_RETRIES = 3 # 最大重试次数
|
|
34
|
+
DAYS = 7 # 抓取近 N 天
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ==================== 爬虫函数 ====================
|
|
38
|
+
|
|
39
|
+
def post_json(payload, retries=MAX_RETRIES):
|
|
40
|
+
"""向平台接口发送 POST 请求,返回 JSON 字典。"""
|
|
41
|
+
data = json.dumps(payload, ensure_ascii=False).encode("utf-8") # 转为 UTF-8 字节流
|
|
42
|
+
req = urllib.request.Request(
|
|
43
|
+
API_URL,
|
|
44
|
+
data=data,
|
|
45
|
+
headers={
|
|
46
|
+
"Content-Type": "application/json;charset=UTF-8", # JSON 格式
|
|
47
|
+
"User-Agent": "Mozilla/5.0 ... Chrome/120.0", # 伪装浏览器
|
|
48
|
+
},
|
|
49
|
+
method="POST",
|
|
50
|
+
)
|
|
51
|
+
last_err = None
|
|
52
|
+
for i in range(retries): # 重试循环
|
|
53
|
+
try:
|
|
54
|
+
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp:
|
|
55
|
+
return json.loads(resp.read().decode("utf-8")) # 解析响应
|
|
56
|
+
except Exception as e:
|
|
57
|
+
last_err = e
|
|
58
|
+
print(f" [WARN] 请求失败(第{i+1}次): {e}", file=sys.stderr)
|
|
59
|
+
raise RuntimeError(f"接口请求连续失败 {retries} 次: {last_err}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def crawl_keyword(category_name, notice_type, plate_type, keyword):
|
|
63
|
+
"""按类别+关键字抓取全部页,返回记录列表。"""
|
|
64
|
+
records = []
|
|
65
|
+
page = 0
|
|
66
|
+
while True:
|
|
67
|
+
start = page * PAGE_SIZE # 分页偏移
|
|
68
|
+
payload = {
|
|
69
|
+
"start": start,
|
|
70
|
+
"limit": PAGE_SIZE,
|
|
71
|
+
"currentPage": page + 1,
|
|
72
|
+
"model": {
|
|
73
|
+
"noticeType": notice_type, # 公告类型
|
|
74
|
+
"plateType": plate_type, # 板块类型
|
|
75
|
+
"title": keyword, # 标题关键字
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
j = post_json(payload)
|
|
79
|
+
if not j.get("status") or not j.get("data"):
|
|
80
|
+
raise RuntimeError(f"接口返回异常: {json.dumps(j, ensure_ascii=False)[:200]}")
|
|
81
|
+
data = j["data"]
|
|
82
|
+
total = int(data.get("totalCount", 0)) # 总条数
|
|
83
|
+
root = data.get("root") or [] # 当前页记录
|
|
84
|
+
records.extend(root)
|
|
85
|
+
print(f" [类别:{category_name} 关键字:{keyword}] 第{page+1}页, 累计{len(records)}/{total}", file=sys.stderr)
|
|
86
|
+
if start + PAGE_SIZE >= total or not root: # 已抓完
|
|
87
|
+
break
|
|
88
|
+
page += 1
|
|
89
|
+
return records
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def collect():
|
|
93
|
+
"""抓取全部类别×关键字,按 noticeId 去重。"""
|
|
94
|
+
result = {}
|
|
95
|
+
for cat_name, notice_type, plate_type in CATEGORIES:
|
|
96
|
+
rows = {}
|
|
97
|
+
for kw in KEYWORDS:
|
|
98
|
+
for r in crawl_keyword(cat_name, notice_type, plate_type, kw):
|
|
99
|
+
nid = r.get("noticeId") # 唯一标识
|
|
100
|
+
if not nid:
|
|
101
|
+
continue
|
|
102
|
+
if nid in rows: # 去重:合并关键字
|
|
103
|
+
if kw not in rows[nid]["keywords"]:
|
|
104
|
+
rows[nid]["keywords"].append(kw)
|
|
105
|
+
else:
|
|
106
|
+
rows[nid] = { # 首次记录
|
|
107
|
+
"title": r.get("title") or "",
|
|
108
|
+
"keywords": [kw],
|
|
109
|
+
"noticeTypeName": r.get("noticeTypeName") or "",
|
|
110
|
+
"purchaseMethodDesc": r.get("purchaseMethodDesc") or "",
|
|
111
|
+
"requireTypeDesc": r.get("requireTypeDesc") or "",
|
|
112
|
+
"purchaseCompanyName": r.get("purchaseCompanyName") or "",
|
|
113
|
+
"buName": r.get("buName") or "",
|
|
114
|
+
"createTimeStr": r.get("createTimeStr") or "",
|
|
115
|
+
"endTimeStr": r.get("endTimeStr") or "",
|
|
116
|
+
"noticeStatusDesc": r.get("noticeStatusDesc") or "",
|
|
117
|
+
"noticeId": nid,
|
|
118
|
+
}
|
|
119
|
+
result[cat_name] = rows
|
|
120
|
+
print(f"类别[{cat_name}] 去重后 {len(rows)} 条", file=sys.stderr)
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ==================== Excel 生成 ====================
|
|
125
|
+
|
|
126
|
+
def build_excel_bytes(cat_rows):
|
|
127
|
+
"""生成 Excel 字节流,返回 (字节流, 统计信息, 日期字符串)。"""
|
|
128
|
+
from openpyxl import Workbook # 工作簿
|
|
129
|
+
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
|
130
|
+
from openpyxl.utils import get_column_letter # 列号转字母
|
|
131
|
+
from io import BytesIO # 内存字节流
|
|
132
|
+
|
|
133
|
+
TODAY = datetime.now()
|
|
134
|
+
THRESHOLD = (TODAY - timedelta(days=DAYS - 1)).strftime("%Y-%m-%d") # 近 N 天起始
|
|
135
|
+
TODAY_STR = TODAY.strftime("%Y-%m-%d")
|
|
136
|
+
|
|
137
|
+
wb = Workbook() # 新建工作簿
|
|
138
|
+
|
|
139
|
+
# -------- 样式 --------
|
|
140
|
+
hdr_font = Font(name="微软雅黑", size=10, bold=True, color="FFFFFF") # 表头白字
|
|
141
|
+
hdr_fill = PatternFill("solid", fgColor="42506B") # 表头深蓝底
|
|
142
|
+
hdr_align = Alignment(horizontal="center", vertical="center")
|
|
143
|
+
body_font = Font(name="微软雅黑", size=10)
|
|
144
|
+
stripe_fill = PatternFill("solid", fgColor="F2F6FC") # 斑马纹
|
|
145
|
+
center = Alignment(horizontal="center", vertical="center")
|
|
146
|
+
left = Alignment(horizontal="left", vertical="center")
|
|
147
|
+
thin = Side(style="thin", color="C0C4CC")
|
|
148
|
+
border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
|
149
|
+
|
|
150
|
+
headers = ["序号", "公告标题", "命中关键词", "公告类型", "采购方式", "需求类型",
|
|
151
|
+
"采购单位", "需求单位", "发布日期", "截止日期", "公告状态", "公告链接"]
|
|
152
|
+
widths = [5, 55, 12, 12, 8, 8, 16, 22, 11, 11, 10, 50]
|
|
153
|
+
|
|
154
|
+
stats = {} # 各类别统计
|
|
155
|
+
|
|
156
|
+
# -------- 每个类别生成一个 Sheet --------
|
|
157
|
+
for idx, (cat_name, _n, _p) in enumerate(CATEGORIES):
|
|
158
|
+
# 过滤:近 N 天 + 需求类型为 "服务"
|
|
159
|
+
rows = [r for r in cat_rows[cat_name].values()
|
|
160
|
+
if r["createTimeStr"] and r["createTimeStr"] >= THRESHOLD
|
|
161
|
+
and r["requireTypeDesc"] == "服务"]
|
|
162
|
+
rows.sort(key=lambda r: r["createTimeStr"], reverse=True) # 按日期倒序
|
|
163
|
+
|
|
164
|
+
kw_count = {kw: 0 for kw in KEYWORDS}
|
|
165
|
+
for r in rows:
|
|
166
|
+
for kw in r["keywords"]:
|
|
167
|
+
if kw in kw_count:
|
|
168
|
+
kw_count[kw] += 1
|
|
169
|
+
stats[cat_name] = (len(rows), kw_count)
|
|
170
|
+
|
|
171
|
+
ws = wb.active if idx == 0 else wb.create_sheet() # 首个用默认 Sheet
|
|
172
|
+
ws.title = cat_name
|
|
173
|
+
ws.append(headers) # 写表头
|
|
174
|
+
|
|
175
|
+
for c in range(1, len(headers) + 1): # 表头样式
|
|
176
|
+
cell = ws.cell(row=1, column=c)
|
|
177
|
+
cell.font = hdr_font
|
|
178
|
+
cell.fill = hdr_fill
|
|
179
|
+
cell.alignment = hdr_align
|
|
180
|
+
|
|
181
|
+
for i, r in enumerate(rows, start=1): # 写数据行
|
|
182
|
+
link = "https://scm.esinochem.com/hpc/index.html#/content?noticeId=" + r["noticeId"]
|
|
183
|
+
ws.append([i, r["title"], ",".join(r["keywords"]), r["noticeTypeName"],
|
|
184
|
+
r["purchaseMethodDesc"], r["requireTypeDesc"], r["purchaseCompanyName"],
|
|
185
|
+
r["buName"], r["createTimeStr"], r["endTimeStr"],
|
|
186
|
+
r["noticeStatusDesc"], link])
|
|
187
|
+
excel_row = i + 1
|
|
188
|
+
for c in range(1, len(headers) + 1): # 正文样式
|
|
189
|
+
cell = ws.cell(row=excel_row, column=c)
|
|
190
|
+
cell.font = body_font
|
|
191
|
+
cell.border = border
|
|
192
|
+
cell.alignment = center if c not in (2, 12) else left
|
|
193
|
+
if excel_row % 2 == 0:
|
|
194
|
+
cell.fill = stripe_fill
|
|
195
|
+
ws.cell(row=excel_row, column=12).hyperlink = link # 可点击链接
|
|
196
|
+
ws.cell(row=excel_row, column=12).style = "Hyperlink"
|
|
197
|
+
|
|
198
|
+
for c, w in enumerate(widths, start=1): # 列宽
|
|
199
|
+
ws.column_dimensions[get_column_letter(c)].width = w
|
|
200
|
+
if rows:
|
|
201
|
+
ws.auto_filter.ref = f"A1:{get_column_letter(len(headers))}{len(rows)+1}"
|
|
202
|
+
ws.freeze_panes = "A2" # 冻结首行
|
|
203
|
+
|
|
204
|
+
# -------- 搜索说明 Sheet --------
|
|
205
|
+
ws = wb.create_sheet("搜索说明")
|
|
206
|
+
notes = [
|
|
207
|
+
(f"中化采购供应链平台 - 公告关键字搜索结果(近{DAYS}天)", True),
|
|
208
|
+
("", False),
|
|
209
|
+
("数据来源: https://scm.esinochem.com/hpc/index.html#/notice", False),
|
|
210
|
+
(f"生成日期: {TODAY_STR}", False),
|
|
211
|
+
(f"搜索方式: 标题包含关键字(共{len(KEYWORDS)}个)", False),
|
|
212
|
+
(f"数据范围: 仅保留近{DAYS}天发布且需求类型为「服务」的公告 ({THRESHOLD} 至 {TODAY_STR})", False),
|
|
213
|
+
("", False),
|
|
214
|
+
]
|
|
215
|
+
for i, (txt, bold) in enumerate(notes, start=1):
|
|
216
|
+
cell = ws.cell(row=i, column=1, value=txt)
|
|
217
|
+
cell.font = Font(name="微软雅黑", size=13 if bold else 10, bold=bold)
|
|
218
|
+
|
|
219
|
+
head_row = len(notes) + 1 # 统计表头行
|
|
220
|
+
matrix = ["类别"] + KEYWORDS + ["命中人次(含交叉)", "去重后条数"]
|
|
221
|
+
for c, h in enumerate(matrix, start=1):
|
|
222
|
+
cell = ws.cell(row=head_row, column=c, value=h)
|
|
223
|
+
cell.font = hdr_font
|
|
224
|
+
cell.fill = hdr_fill
|
|
225
|
+
cell.alignment = hdr_align
|
|
226
|
+
|
|
227
|
+
r = head_row + 1
|
|
228
|
+
for cat_name, _n, _p in CATEGORIES:
|
|
229
|
+
n, kw_count = stats[cat_name]
|
|
230
|
+
ws.cell(row=r, column=1, value=cat_name).font = body_font
|
|
231
|
+
for c, kw in enumerate(KEYWORDS, start=2):
|
|
232
|
+
ws.cell(row=r, column=c, value=kw_count[kw]).font = body_font
|
|
233
|
+
ws.cell(row=r, column=len(KEYWORDS) + 2, value=sum(kw_count.values())).font = body_font
|
|
234
|
+
ws.cell(row=r, column=len(KEYWORDS) + 3, value=n).font = body_font
|
|
235
|
+
r += 1
|
|
236
|
+
|
|
237
|
+
ws.cell(row=r, column=1, value="总计").font = Font(name="微软雅黑", size=10, bold=True)
|
|
238
|
+
for c in range(2, len(KEYWORDS) + 4): # 合计行公式
|
|
239
|
+
col = get_column_letter(c)
|
|
240
|
+
cell = ws.cell(row=r, column=c)
|
|
241
|
+
cell.value = f"=SUM({col}{head_row+1}:{col}{r-1})"
|
|
242
|
+
cell.font = Font(name="微软雅黑", size=10, bold=True)
|
|
243
|
+
|
|
244
|
+
ws.column_dimensions["A"].width = 22
|
|
245
|
+
for c in range(2, len(KEYWORDS) + 4):
|
|
246
|
+
ws.column_dimensions[get_column_letter(c)].width = 11
|
|
247
|
+
|
|
248
|
+
bio = BytesIO() # 写入内存
|
|
249
|
+
wb.save(bio)
|
|
250
|
+
bio.seek(0)
|
|
251
|
+
return bio.getvalue(), stats, TODAY_STR
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ==================== OSS 上传 ====================
|
|
255
|
+
|
|
256
|
+
def upload_to_oss(file_bytes, filename):
|
|
257
|
+
"""上传字节流到 OSS,返回签名下载链接。"""
|
|
258
|
+
# 从环境变量读取(百炼部署时通过 MCP 配置的 env 注入)
|
|
259
|
+
endpoint = os.environ.get("OSS_ENDPOINT", "")
|
|
260
|
+
key_id = os.environ.get("OSS_ACCESS_KEY_ID", "")
|
|
261
|
+
key_secret = os.environ.get("OSS_ACCESS_KEY_SECRET", "")
|
|
262
|
+
bucket_name = os.environ.get("OSS_BUCKET_NAME", "")
|
|
263
|
+
prefix = os.environ.get("OSS_PREFIX", "")
|
|
264
|
+
|
|
265
|
+
if not all([endpoint, key_id, key_secret, bucket_name]): # 校验必填项
|
|
266
|
+
raise RuntimeError("OSS 环境变量未完全配置")
|
|
267
|
+
|
|
268
|
+
# 去除协议前缀(oss2 只接受主机名)
|
|
269
|
+
if endpoint.startswith("http://"):
|
|
270
|
+
endpoint = endpoint[7:]
|
|
271
|
+
elif endpoint.startswith("https://"):
|
|
272
|
+
endpoint = endpoint[8:]
|
|
273
|
+
|
|
274
|
+
auth = oss2.Auth(key_id, key_secret)
|
|
275
|
+
bucket = oss2.Bucket(auth, endpoint, bucket_name)
|
|
276
|
+
|
|
277
|
+
if prefix and not prefix.endswith("/"): # 确保前缀以 / 结尾
|
|
278
|
+
prefix += "/"
|
|
279
|
+
object_key = f"{prefix}{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}_{filename}"
|
|
280
|
+
|
|
281
|
+
bucket.put_object(object_key, file_bytes) # 上传
|
|
282
|
+
url = bucket.sign_url('GET', object_key, 7 * 24 * 3600) # 7 天有效签名 URL
|
|
283
|
+
return url
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ==================== MCP 服务 ====================
|
|
287
|
+
|
|
288
|
+
mcp = FastMCP("scm-crawler-mcp-server") # 创建 MCP 实例
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@mcp.tool() # 注册工具
|
|
292
|
+
def fetch_notices() -> str:
|
|
293
|
+
"""
|
|
294
|
+
抓取中化采购平台公告,生成 Excel 并上传 OSS,返回下载链接。
|
|
295
|
+
"""
|
|
296
|
+
try:
|
|
297
|
+
print("开始抓取...", file=sys.stderr)
|
|
298
|
+
cat_rows = collect() # 1. 抓取
|
|
299
|
+
excel_bytes, stats, today_str = build_excel_bytes(cat_rows) # 2. 生成 Excel
|
|
300
|
+
|
|
301
|
+
filename = f"中化采购平台_公告关键字搜索结果_近{DAYS}天_{today_str}.xlsx"
|
|
302
|
+
oss_url = upload_to_oss(excel_bytes, filename) # 3. 上传 OSS
|
|
303
|
+
print(f"上传成功: {oss_url}", file=sys.stderr)
|
|
304
|
+
|
|
305
|
+
summary = {
|
|
306
|
+
"status": "success",
|
|
307
|
+
"file_name": filename,
|
|
308
|
+
"oss_url": oss_url,
|
|
309
|
+
"date": today_str,
|
|
310
|
+
"categories": {}
|
|
311
|
+
}
|
|
312
|
+
for cat_name, (count, kw_count) in stats.items():
|
|
313
|
+
summary["categories"][cat_name] = {
|
|
314
|
+
"count": count,
|
|
315
|
+
"keyword_hits": kw_count
|
|
316
|
+
}
|
|
317
|
+
return json.dumps(summary, ensure_ascii=False, indent=2)
|
|
318
|
+
except Exception as e:
|
|
319
|
+
import traceback
|
|
320
|
+
print(traceback.format_exc(), file=sys.stderr)
|
|
321
|
+
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def run():
|
|
325
|
+
"""MCP 服务启动入口(百炼 uvx 调用此函数)。"""
|
|
326
|
+
mcp.run(transport='stdio') # 必须用 stdio 模式
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
if __name__ == "__main__":
|
|
330
|
+
run() # 本地调试入口
|